Compare commits

...

18 Commits

Author SHA1 Message Date
jbouwh
4bb2c0b213 Remove integration domain 2025-10-17 13:45:53 +00:00
jbouwh
0307f0c781 Remove invalid import 2025-10-17 13:45:53 +00:00
jbouwh
81c4d8582a Rework with mixin - Light only 2025-10-17 13:45:53 +00:00
jbouwh
f66a03d6cb Automatically update the entity propery when a member created, updated or deleted 2025-10-17 13:45:53 +00:00
jbouwh
ff37570035 Apply light group icon to all MQTT light schemas 2025-10-17 13:45:53 +00:00
jbouwh
4f7e82ba76 Allow an MQTT entity to show as a group 2025-10-17 13:45:53 +00:00
jbouwh
87204bbfca Fix device tracker 2025-10-17 13:45:53 +00:00
jbouwh
69785a5361 Use platform name 2025-10-17 13:13:44 +00:00
jbouwh
c566950fb1 Fix device tracker state attrs 2025-10-17 13:13:44 +00:00
jbouwh
fd8e366a2f Also implement as default in base entity 2025-10-17 13:13:44 +00:00
jbouwh
ab658e05a6 Integrate with base entity component state attributes 2025-10-17 13:13:44 +00:00
jbouwh
49542e8302 Update docstr 2025-10-17 13:13:44 +00:00
jbouwh
bc8d7fc02e Move logic into Entity class 2025-10-17 13:13:44 +00:00
jbouwh
20a494e4f8 Use platform domain attribute 2025-10-17 13:13:44 +00:00
jbouwh
64ad83b1cd Fix typo 2025-10-17 13:13:44 +00:00
jbouwh
254a4de025 Follow up on code review 2025-10-17 13:13:44 +00:00
jbouwh
ec43e01d51 Implement mixin class and add feature to maintain included entities from unique IDs 2025-10-17 13:13:44 +00:00
jbouwh
4f25518671 Add included_entities attribute to base Entity class 2025-10-17 13:13:44 +00:00
30 changed files with 496 additions and 105 deletions

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Final, final
from typing import Any, Final, final
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
@@ -133,9 +133,9 @@ class AirQualityEntity(Entity):
@final
@property
def state_attributes(self) -> dict[str, str | int | float]:
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
data: dict[str, str | int | float] = {}
data: dict[str, Any] = self.generate_entity_state_attributes()
for prop, attr in PROP_TO_ATTR.items():
if (value := getattr(self, prop)) is not None:

View File

@@ -301,11 +301,12 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
return {
ATTR_CODE_FORMAT: self.code_format,
ATTR_CHANGED_BY: self.changed_by,
ATTR_CODE_ARM_REQUIRED: self.code_arm_required,
}
data: dict[str, Any] = self.generate_entity_state_attributes()
data[ATTR_CODE_FORMAT] = self.code_format
data[ATTR_CHANGED_BY] = self.changed_by
data[ATTR_CODE_ARM_REQUIRED] = self.code_arm_required
return data
async def async_internal_added_to_hass(self) -> None:
"""Call when the alarm control panel entity is added to hass."""

View File

@@ -525,17 +525,18 @@ class CalendarEntity(Entity):
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return the entity state attributes."""
if (event := self.event) is None:
return None
data: dict[str, Any] = self.generate_entity_state_attributes()
return {
"message": event.summary,
"all_day": event.all_day,
"start_time": event.start_datetime_local.strftime(DATE_STR_FORMAT),
"end_time": event.end_datetime_local.strftime(DATE_STR_FORMAT),
"location": event.location if event.location else "",
"description": event.description if event.description else "",
}
if (event := self.event) is None:
return data or None
data["message"] = event.summary
data["all_day"] = event.all_day
data["start_time"] = event.start_datetime_local.strftime(DATE_STR_FORMAT)
data["end_time"] = event.end_datetime_local.strftime(DATE_STR_FORMAT)
data["location"] = event.location if event.location else ""
data["description"] = event.description if event.description else ""
return data
@final
@property

View File

@@ -664,7 +664,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, str | None]:
"""Return the camera state attributes."""
attrs = {"access_token": self.access_tokens[-1]}
attrs: dict[str, Any] = self.generate_entity_state_attributes()
attrs["access_token"] = self.access_tokens[-1]
if model := self.model:
attrs["model_name"] = model

View File

@@ -341,16 +341,16 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the optional state attributes."""
data: dict[str, Any] = self.generate_entity_state_attributes()
supported_features = self.supported_features
temperature_unit = self.temperature_unit
precision = self.precision
hass = self.hass
data: dict[str, str | float | None] = {
ATTR_CURRENT_TEMPERATURE: show_temp(
hass, self.current_temperature, temperature_unit, precision
),
}
data[ATTR_CURRENT_TEMPERATURE] = show_temp(
hass, self.current_temperature, temperature_unit, precision
)
if ClimateEntityFeature.TARGET_TEMPERATURE in supported_features:
data[ATTR_TEMPERATURE] = show_temp(

View File

@@ -267,7 +267,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
data = {}
data: dict[str, Any] = self.generate_entity_state_attributes()
if (current := self.current_cover_position) is not None:
data[ATTR_CURRENT_POSITION] = current

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from typing import final
from typing import Any, final
from propcache.api import cached_property
@@ -28,7 +28,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.typing import StateType
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -189,9 +188,11 @@ class BaseTrackerEntity(Entity):
raise NotImplementedError
@property
def state_attributes(self) -> dict[str, StateType]:
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
attr: dict[str, Any] = self.generate_entity_state_attributes()
attr[ATTR_SOURCE_TYPE] = self.source_type
if self.battery_level is not None:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
@@ -278,9 +279,9 @@ class TrackerEntity(
@final
@property
def state_attributes(self) -> dict[str, StateType]:
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {}
attr: dict[str, Any] = {}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
@@ -431,9 +432,10 @@ class ScannerEntity(
@final
@property
def state_attributes(self) -> dict[str, StateType]:
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr = super().state_attributes
attr: dict[str, Any] = self.generate_entity_state_attributes()
attr.update(super().state_attributes)
if ip_address := self.ip_address:
attr[ATTR_IP] = ip_address

View File

@@ -48,7 +48,7 @@ from homeassistant.helpers.event import (
async_track_utc_time_change,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, GPSType, StateType
from homeassistant.helpers.typing import ConfigType, GPSType
from homeassistant.setup import (
SetupPhases,
async_notify_setup_error,
@@ -842,9 +842,11 @@ class Device(RestoreEntity):
@final
@property
def state_attributes(self) -> dict[str, StateType]:
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attributes: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
attributes: dict[str, Any] = self.generate_entity_state_attributes()
attributes[ATTR_SOURCE_TYPE] = self.source_type
if self.gps is not None:
attributes[ATTR_LATITUDE] = self.gps[0]

View File

@@ -180,7 +180,9 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
attributes = {ATTR_EVENT_TYPE: self.__last_event_type}
attributes: dict[str, Any] = self.generate_entity_state_attributes()
attributes[ATTR_EVENT_TYPE] = self.__last_event_type
if last_event_attributes := self.__last_event_attributes:
attributes |= last_event_attributes
return attributes

View File

@@ -385,9 +385,10 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@final
@property
def state_attributes(self) -> dict[str, float | str | None]:
def state_attributes(self) -> dict[str, Any]:
"""Return optional state attributes."""
data: dict[str, float | str | None] = {}
data: dict[str, Any] = self.generate_entity_state_attributes()
supported_features = self.supported_features
if FanEntityFeature.DIRECTION in supported_features:

View File

@@ -101,7 +101,9 @@ class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of this external event."""
data: dict[str, Any] = {ATTR_SOURCE: self.source}
data: dict[str, Any] = self.generate_entity_state_attributes()
data[ATTR_SOURCE] = self.source
if self.latitude is not None:
data[ATTR_LATITUDE] = round(self.latitude, 5)
if self.longitude is not None:

View File

@@ -188,7 +188,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the optional state attributes."""
data: dict[str, Any] = {}
data: dict[str, Any] = self.generate_entity_state_attributes()
if self.action is not None:
data[ATTR_ACTION] = self.action if self.is_on else HumidifierAction.OFF

View File

@@ -10,7 +10,7 @@ from datetime import datetime, timedelta
import logging
import os
from random import SystemRandom
from typing import Final, final
from typing import Any, Final, final
from aiohttp import hdrs, web
import httpx
@@ -281,9 +281,12 @@ class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@final
@property
def state_attributes(self) -> dict[str, str | None]:
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {"access_token": self.access_tokens[-1]}
data: dict[str, Any] = self.generate_entity_state_attributes()
data["access_token"] = self.access_tokens[-1]
return data
@callback
def async_update_token(self) -> None:

View File

@@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
@@ -1261,7 +1262,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return state attributes."""
data: dict[str, Any] = {}
data: dict[str, Any] = self.generate_entity_state_attributes()
supported_features = self.supported_features_compat
supported_color_modes = self.supported_color_modes
legacy_supported_color_modes = (
@@ -1336,6 +1338,9 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if color_mode:
data.update(self._light_internal_convert_color(color_mode))
if included_entities := getattr(self, "included_entities", None):
data[ATTR_ENTITY_ID] = included_entities
return data
@property

View File

@@ -25,7 +25,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN, LockState
@@ -244,9 +244,10 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@final
@property
def state_attributes(self) -> dict[str, StateType]:
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
state_attr = {}
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
for prop, attr in PROP_TO_ATTR.items():
if (value := getattr(self, prop)) is not None:
state_attr[attr] = value

View File

@@ -1123,7 +1123,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
state_attr: dict[str, Any] = {}
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
if self.support_grouping:
state_attr[ATTR_GROUP_MEMBERS] = self.group_members

View File

@@ -73,6 +73,7 @@ ABBREVIATIONS = {
"fan_mode_stat_t": "fan_mode_state_topic",
"frc_upd": "force_update",
"g_tpl": "green_template",
"grp": "group",
"hs_cmd_t": "hs_command_topic",
"hs_cmd_tpl": "hs_command_template",
"hs_stat_t": "hs_state_topic",

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_GROUP,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
SCHEMA_BASE = {
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
}
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)

View File

@@ -110,6 +110,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GREEN_TEMPLATE = "green_template"
CONF_GROUP = "group"
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic"

View File

@@ -79,6 +79,7 @@ from .const import (
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_ENTITY_PICTURE,
CONF_GROUP,
CONF_HW_VERSION,
CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE,
@@ -136,6 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class",
"device_info",
"entity_category",
"entity_id",
"entity_picture",
"entity_registry_enabled_default",
"extra_state_attributes",
@@ -480,6 +482,9 @@ class MqttAttributesMixin(Entity):
async def async_added_to_hass(self) -> None:
"""Subscribe MQTT events."""
await super().async_added_to_hass()
if CONF_GROUP in self._attributes_config:
self.async_set_included_entities(self._attributes_config[CONF_GROUP])
self._attributes_prepare_subscribe_topics()
self._attributes_subscribe_topics()
@@ -546,7 +551,7 @@ class MqttAttributesMixin(Entity):
_LOGGER.warning("Erroneous JSON: %s", payload)
else:
if isinstance(json_dict, dict):
filtered_dict = {
filtered_dict: dict[str, Any] = {
k: v
for k, v in json_dict.items()
if k not in MQTT_ATTRIBUTES_BLOCKED

View File

@@ -184,13 +184,14 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return optional state attributes."""
if RemoteEntityFeature.ACTIVITY not in self.supported_features:
return None
data: dict[str, Any] = self.generate_entity_state_attributes()
return {
ATTR_ACTIVITY_LIST: self.activity_list,
ATTR_CURRENT_ACTIVITY: self.current_activity,
}
if RemoteEntityFeature.ACTIVITY not in self.supported_features:
return data or None
data[ATTR_ACTIVITY_LIST] = self.activity_list
data[ATTR_CURRENT_ACTIVITY] = self.current_activity
return data
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send commands to a device."""

View File

@@ -16,6 +16,7 @@ from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONF_UNIT_OF_MEASUREMENT,
EntityCategory,
@@ -437,6 +438,8 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@override
def state_attributes(self) -> dict[str, Any] | None:
"""Return state attributes."""
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
if last_reset := self.last_reset:
state_class = self.state_class
if state_class != SensorStateClass.TOTAL:
@@ -448,9 +451,9 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
)
if state_class == SensorStateClass.TOTAL:
return {ATTR_LAST_RESET: last_reset.isoformat()}
state_attr[ATTR_LAST_RESET] = last_reset.isoformat()
return None
return state_attr or None
@cached_property
def native_value(self) -> StateType | date | datetime | Decimal:

View File

@@ -433,6 +433,8 @@ class UpdateEntity(
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return state attributes."""
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
if (release_summary := self.release_summary) is not None:
release_summary = release_summary[:255]
@@ -459,18 +461,17 @@ class UpdateEntity(
skipped_version = None
self.__skipped_version = None
return {
ATTR_AUTO_UPDATE: self.auto_update,
ATTR_DISPLAY_PRECISION: self.display_precision,
ATTR_INSTALLED_VERSION: installed_version,
ATTR_IN_PROGRESS: in_progress,
ATTR_LATEST_VERSION: latest_version,
ATTR_RELEASE_SUMMARY: release_summary,
ATTR_RELEASE_URL: self.release_url,
ATTR_SKIPPED_VERSION: skipped_version,
ATTR_TITLE: self.title,
ATTR_UPDATE_PERCENTAGE: update_percentage,
}
state_attr[ATTR_AUTO_UPDATE] = self.auto_update
state_attr[ATTR_DISPLAY_PRECISION] = self.display_precision
state_attr[ATTR_INSTALLED_VERSION] = installed_version
state_attr[ATTR_IN_PROGRESS] = in_progress
state_attr[ATTR_LATEST_VERSION] = latest_version
state_attr[ATTR_RELEASE_SUMMARY] = release_summary
state_attr[ATTR_RELEASE_URL] = self.release_url
state_attr[ATTR_SKIPPED_VERSION] = skipped_version
state_attr[ATTR_TITLE] = self.title
state_attr[ATTR_UPDATE_PERCENTAGE] = update_percentage
return state_attr
@final
async def async_install_with_progress(

View File

@@ -364,10 +364,12 @@ class StateVacuumEntity(
"""Get the list of available fan speed steps of the vacuum cleaner."""
return self._attr_fan_speed_list
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the vacuum cleaner."""
data: dict[str, Any] = {}
data: dict[str, Any] = self.generate_entity_state_attributes()
supported_features = self.supported_features
if VacuumEntityFeature.BATTERY in supported_features:

View File

@@ -191,9 +191,11 @@ class ValveEntity(Entity):
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if not self.reports_position:
return None
return {ATTR_CURRENT_POSITION: self.current_valve_position}
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
if self.reports_position:
state_attr[ATTR_CURRENT_POSITION] = self.current_valve_position
return state_attr or None
@property
def supported_features(self) -> ValveEntityFeature:

View File

@@ -233,32 +233,36 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the optional state attributes."""
data: dict[str, Any] = {
ATTR_CURRENT_TEMPERATURE: show_temp(
self.hass,
self.current_temperature,
self.temperature_unit,
self.precision,
),
ATTR_TEMPERATURE: show_temp(
self.hass,
self.target_temperature,
self.temperature_unit,
self.precision,
),
ATTR_TARGET_TEMP_HIGH: show_temp(
self.hass,
self.target_temperature_high,
self.temperature_unit,
self.precision,
),
ATTR_TARGET_TEMP_LOW: show_temp(
self.hass,
self.target_temperature_low,
self.temperature_unit,
self.precision,
),
}
data: dict[str, Any] = self.generate_entity_state_attributes()
data.update(
{
ATTR_CURRENT_TEMPERATURE: show_temp(
self.hass,
self.current_temperature,
self.temperature_unit,
self.precision,
),
ATTR_TEMPERATURE: show_temp(
self.hass,
self.target_temperature,
self.temperature_unit,
self.precision,
),
ATTR_TARGET_TEMP_HIGH: show_temp(
self.hass,
self.target_temperature_high,
self.temperature_unit,
self.precision,
),
ATTR_TARGET_TEMP_LOW: show_temp(
self.hass,
self.target_temperature_low,
self.temperature_unit,
self.precision,
),
}
)
supported_features = self.supported_features

View File

@@ -562,7 +562,7 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A
Attributes are configured from native units to user-configured units.
"""
data: dict[str, Any] = {}
data: dict[str, Any] = self.generate_entity_state_attributes()
precision = self.precision

View File

@@ -25,6 +25,7 @@ from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME,
ATTR_ICON,
@@ -526,6 +527,9 @@ class Entity(
__capabilities_updated_at_reported: bool = False
__remove_future: asyncio.Future[None] | None = None
# Remember we keep track of included entities
__init_track_included_entities: bool = False
# Entity Properties
_attr_assumed_state: bool = False
_attr_attribution: str | None = None
@@ -541,6 +545,8 @@ class Entity(
_attr_extra_state_attributes: dict[str, Any]
_attr_force_update: bool
_attr_icon: str | None
_attr_included_entities: list[str]
_attr_included_unique_ids: list[str]
_attr_name: str | None
_attr_should_poll: bool = True
_attr_state: StateType = STATE_UNKNOWN
@@ -777,14 +783,27 @@ class Entity(
"""
return None
@cached_property
@property
def state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes.
Implemented by component base class, should not be extended by integrations.
An entity base class should use self.generate_entity_state_attributes()
to generate the initial state attributes.
Convention for attribute names is lowercase snake_case.
"""
return None
return self.generate_entity_state_attributes() or None
def generate_entity_state_attributes(self) -> dict[str, Any]:
"""Generate base state attributes dict for entity base components.
Base entity classes can extend the state attributes. This helper
creates a new dict and sets the sharedcommon entity attributes
"""
state_attrs: dict[str, Any] = {}
if included_entities := getattr(self, "included_entities", None):
state_attrs[ATTR_ENTITY_ID] = included_entities
return state_attrs
@cached_property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
@@ -1635,6 +1654,72 @@ class Entity(
self.hass, integration_domain=platform_name, module=type(self).__module__
)
@callback
def async_set_included_entities(self, unique_ids: list[str]) -> None:
"""Set the list of included entities identified by their unique IDs.
Integrations need to initialize this in entity.async_async_added_to_hass,
and when the list of included entities changes.
The entity ids of included entities will will be looked up and they will be
tracked for changes.
None existing entities for the supplied unique IDs will be ignored.
"""
entity_registry = er.async_get(self.hass)
self._attr_included_unique_ids = unique_ids
assert self.entity_id is not None
def _update_group_entity_ids() -> None:
self._attr_included_entities = []
for included_id in self.included_unique_ids:
if entity_id := entity_registry.async_get_entity_id(
self.platform.domain, self.platform.platform_name, included_id
):
self._attr_included_entities.append(entity_id)
async def _handle_entity_registry_updated(event: Event[Any]) -> None:
"""Handle registry create or update event."""
if (
event.data["action"] in {"create", "update"}
and (entry := entity_registry.async_get(event.data["entity_id"]))
and entry.unique_id in self.included_unique_ids
) or (
event.data["action"] == "remove"
and self.included_entities is not None
and event.data["entity_id"] in self.included_entities
):
_update_group_entity_ids()
self.async_write_ha_state()
if not self.__init_track_included_entities:
self.async_on_remove(
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
_handle_entity_registry_updated,
)
)
self.__init_track_included_entities = True
_update_group_entity_ids()
@property
def included_unique_ids(self) -> list[str]:
"""Return the list of unique IDs if the entity represents a group.
The corresponding entities will be shown as members in the UI.
"""
if hasattr(self, "_attr_included_unique_ids"):
return self._attr_included_unique_ids
return []
@property
def included_entities(self) -> list[str] | None:
"""Return a list of entity IDs if the entity represents a group.
Included entities will be shown as members in the UI.
"""
if hasattr(self, "_attr_included_entities"):
return self._attr_included_entities
return None
class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes toggle entities."""

View File

@@ -82,6 +82,7 @@ light:
"""
import copy
import json
from typing import Any
from unittest.mock import call, patch
@@ -100,6 +101,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.util.json import json_loads
from .common import (
@@ -169,6 +171,39 @@ COLOR_MODES_CONFIG = {
}
}
GROUP_MEMBER_1_TOPIC = "homeassistant/light/member_1/config"
GROUP_MEMBER_2_TOPIC = "homeassistant/light/member_2/config"
GROUP_TOPIC = "homeassistant/light/group/config"
GROUP_DISCOVERY_MEMBER_1_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-member1",
"unique_id": "very_unique_member1",
"name": "member1",
"default_entity_id": "light.member1",
}
)
GROUP_DISCOVERY_MEMBER_2_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-member2",
"unique_id": "very_unique_member2",
"name": "member2",
"default_entity_id": "light.member2",
}
)
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-group",
"state_topic": "test-state-topic-group",
"unique_id": "very_unique_group",
"name": "group",
"default_entity_id": "light.group",
"group": ["very_unique_member1", "very_unique_member2"],
}
)
class JsonValidator:
"""Helper to compare JSON."""
@@ -1859,6 +1894,86 @@ async def test_white_scale(
assert state.attributes.get("brightness") == 129
async def test_light_group_discovery_members_before_group(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test the discovery of a light group and linked entity IDs.
The members are discovered first, so they are known in the entity registry.
"""
await mqtt_mock_entry()
# Discover light group members
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
await hass.async_block_till_done()
# Discover group
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
await hass.async_block_till_done()
assert hass.states.get("light.member1") is not None
assert hass.states.get("light.member2") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("entity_id") == ["light.member1", "light.member2"]
async def test_light_group_discovery_group_before_members(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the discovery of a light group and linked entity IDs.
The group is discovered first, so the group members are
not (all) known yet in the entity registry.
The entity property should be updates as soon as member entities
are discovered, updated or removed.
"""
await mqtt_mock_entry()
# Discover group
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
await hass.async_block_till_done()
# Discover light group members
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
await hass.async_block_till_done()
assert hass.states.get("light.member1") is not None
assert hass.states.get("light.member2") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("entity_id") == ["light.member1", "light.member2"]
# Remove member 1
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, "")
await hass.async_block_till_done()
assert hass.states.get("light.member1") is None
assert hass.states.get("light.member2") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("entity_id") == ["light.member2"]
# Rename member 2
entity_registry.async_update_entity(
"light.member2", new_entity_id="light.member2_updated"
)
await hass.async_block_till_done()
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("entity_id") == ["light.member2_updated"]
@pytest.mark.parametrize(
"hass_config",
[
@@ -2040,7 +2155,7 @@ async def test_custom_availability_payload(
)
async def test_setting_attribute_via_mqtt_json_message(
async def test_setting_attribute_via_mqtt_json_message_single_light(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test the setting of attribute via MQTT with JSON payload."""
@@ -2049,6 +2164,51 @@ async def test_setting_attribute_via_mqtt_json_message(
)
@pytest.mark.parametrize(
"hass_config",
[
help_custom_config(
light.DOMAIN,
DEFAULT_CONFIG,
(
{
"unique_id": "very_unique_member_1",
"name": "Part 1",
"default_entity_id": "light.member_1",
},
{
"unique_id": "very_unique_member_2",
"name": "Part 2",
"default_entity_id": "light.member_2",
},
{
"unique_id": "very_unique_group",
"name": "My group",
"default_entity_id": "light.my_group",
"json_attributes_topic": "attr-topic",
"group": [
"very_unique_member_1",
"very_unique_member_2",
"member_3_not_exists",
],
},
),
)
],
)
async def test_setting_attribute_via_mqtt_json_message_light_group(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test the setting of attribute via MQTT with JSON payload."""
await mqtt_mock_entry()
async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
state = hass.states.get("light.my_group")
assert state and state.attributes.get("val") == "100"
assert state.attributes.get("entity_id") == ["light.member_1", "light.member_2"]
async def test_setting_blocked_attribute_via_mqtt_json_message(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:

View File

@@ -6,7 +6,7 @@ import dataclasses
from datetime import timedelta
import logging
import threading
from typing import Any
from typing import Any, final
from unittest.mock import MagicMock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory
@@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -2896,3 +2897,104 @@ async def test_platform_state_write_from_init_unique_id(
# The early attempt to write is interpreted as a unique ID collision
assert "Platform test_platform does not generate unique IDs." in caplog.text
assert "Entity id already exists - ignoring: test.test" not in caplog.text
async def test_included_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test included entities are exposed via the entity_id attribute."""
entity_registry.async_get_or_create(
domain="hello",
platform="test",
unique_id="very_unique_oceans",
suggested_object_id="oceans",
)
entity_registry.async_get_or_create(
domain="hello",
platform="test",
unique_id="very_unique_continents",
suggested_object_id="continents",
)
entity_registry.async_get_or_create(
domain="hello",
platform="test",
unique_id="very_unique_moon",
suggested_object_id="moon",
)
class MockHelloBaseClass(entity.Entity):
"""Domain base entity platform domain Hello."""
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
data: dict[str, Any] = self.generate_entity_state_attributes()
data["extra"] = "beer"
return data
class MockHelloIncludedEntitiesClass(MockHelloBaseClass, entity.Entity):
"""Mock hello grouped entity class for a test integration."""
platform = MockEntityPlatform(hass, domain="hello", platform_name="test")
mock_entity = MockHelloIncludedEntitiesClass()
mock_entity.hass = hass
mock_entity.entity_id = "hello.universe"
mock_entity.unique_id = "very_unique_universe"
await platform.async_add_entities([mock_entity])
# Initiate mock grouped entity for hello domain
mock_entity.async_set_included_entities(
["very_unique_continents", "very_unique_oceans"]
)
mock_entity.async_schedule_update_ha_state(True)
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.continents", "hello.oceans"]
# Add an entity to the group of included entities
mock_entity.async_set_included_entities(
["very_unique_continents", "very_unique_moon", "very_unique_oceans"]
)
mock_entity.async_schedule_update_ha_state(True)
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get("extra") == "beer"
assert state.attributes.get(ATTR_ENTITY_ID) == [
"hello.continents",
"hello.moon",
"hello.oceans",
]
# Remove an entity from the group of included entities
mock_entity.async_set_included_entities(["very_unique_moon", "very_unique_oceans"])
mock_entity.async_schedule_update_ha_state(True)
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon", "hello.oceans"]
# Rename an included entity via the registry entity
entity_registry.async_update_entity(
entity_id="hello.moon", new_entity_id="hello.moon_light"
)
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon_light", "hello.oceans"]
# Remove an included entity from the registry entity
entity_registry.async_remove(entity_id="hello.oceans")
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon_light"]