Compare commits

...

26 Commits

Author SHA1 Message Date
jbouwh
8be8022f9e Revert unneeded changes 2025-11-10 11:20:00 +00:00
jbouwh
1e1d6fa5c3 Set member unique ID's during class init 2025-11-10 11:20:00 +00:00
jbouwh
34cf72ea5f Remove integration domain 2025-11-10 11:20:00 +00:00
jbouwh
7c1fd889a8 Remove invalid import 2025-11-10 11:19:59 +00:00
jbouwh
c7158cbff7 Rework with mixin - Light only 2025-11-10 11:19:59 +00:00
jbouwh
a22d7d676e Automatically update the entity propery when a member created, updated or deleted 2025-11-10 11:19:59 +00:00
jbouwh
12f951c129 Apply light group icon to all MQTT light schemas 2025-11-10 11:19:59 +00:00
jbouwh
407bda7fbd Allow an MQTT entity to show as a group 2025-11-10 11:19:59 +00:00
jbouwh
c11bcb4215 Improve docstring 2025-11-10 11:19:59 +00:00
jbouwh
623468b8ce Remove _included_entities property 2025-11-10 11:19:59 +00:00
jbouwh
59a5ac6629 Do not set included entities if no unique IDs are set 2025-11-10 11:19:59 +00:00
jbouwh
7d9980921f Upfdate docstr 2025-11-10 11:19:59 +00:00
jbouwh
64abaa6175 Call async_set_included_entities from add_to_platform_finish 2025-11-10 11:19:59 +00:00
jbouwh
665bdb37fb Handle the entity_id attribute in the Entity base class 2025-11-10 11:19:59 +00:00
jbouwh
507e32ce5a Fix device tracker 2025-11-10 11:19:59 +00:00
jbouwh
433dba2c60 Use platform name 2025-11-10 11:19:59 +00:00
jbouwh
523930bf71 Fix device tracker state attrs 2025-11-10 11:19:59 +00:00
jbouwh
f9c59af46d Also implement as default in base entity 2025-11-10 11:19:59 +00:00
jbouwh
1ff1b5f936 Integrate with base entity component state attributes 2025-11-10 11:19:59 +00:00
jbouwh
4be3c3a52b Update docstr 2025-11-10 11:19:59 +00:00
jbouwh
4b42f67c04 Move logic into Entity class 2025-11-10 11:19:59 +00:00
jbouwh
29d633b577 Use platform domain attribute 2025-11-10 11:19:59 +00:00
jbouwh
c4d9a550ab Fix typo 2025-11-10 11:19:59 +00:00
jbouwh
a089dd3e18 Follow up on code review 2025-11-10 11:19:59 +00:00
jbouwh
73b337160f Implement mixin class and add feature to maintain included entities from unique IDs 2025-11-10 11:19:59 +00:00
jbouwh
472cee0c0f Add included_entities attribute to base Entity class 2025-11-10 11:19:59 +00:00
7 changed files with 339 additions and 3 deletions

View File

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

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from .const import ( from .const import (
CONF_COMMAND_TOPIC, CONF_COMMAND_TOPIC,
CONF_ENCODING, CONF_ENCODING,
CONF_GROUP,
CONF_QOS, CONF_QOS,
CONF_RETAIN, CONF_RETAIN,
CONF_STATE_TOPIC, CONF_STATE_TOPIC,
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
SCHEMA_BASE = { SCHEMA_BASE = {
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, 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) 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_TEMPLATE = "position_template"
CONF_GET_POSITION_TOPIC = "position_topic" CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GREEN_TEMPLATE = "green_template" CONF_GREEN_TEMPLATE = "green_template"
CONF_GROUP = "group"
CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic" CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_STATE_TOPIC = "hs_state_topic"

View File

@@ -79,6 +79,7 @@ from .const import (
CONF_ENABLED_BY_DEFAULT, CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING, CONF_ENCODING,
CONF_ENTITY_PICTURE, CONF_ENTITY_PICTURE,
CONF_GROUP,
CONF_HW_VERSION, CONF_HW_VERSION,
CONF_IDENTIFIERS, CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE, CONF_JSON_ATTRS_TEMPLATE,
@@ -136,6 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class", "device_class",
"device_info", "device_info",
"entity_category", "entity_category",
"entity_id",
"entity_picture", "entity_picture",
"entity_registry_enabled_default", "entity_registry_enabled_default",
"extra_state_attributes", "extra_state_attributes",
@@ -475,6 +477,8 @@ class MqttAttributesMixin(Entity):
def __init__(self, config: ConfigType) -> None: def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin.""" """Initialize the JSON attributes mixin."""
self._attributes_sub_state: dict[str, EntitySubscription] = {} self._attributes_sub_state: dict[str, EntitySubscription] = {}
if CONF_GROUP in config:
self._attr_included_unique_ids = config[CONF_GROUP]
self._attributes_config = config self._attributes_config = config
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
@@ -546,7 +550,7 @@ class MqttAttributesMixin(Entity):
_LOGGER.warning("Erroneous JSON: %s", payload) _LOGGER.warning("Erroneous JSON: %s", payload)
else: else:
if isinstance(json_dict, dict): if isinstance(json_dict, dict):
filtered_dict = { filtered_dict: dict[str, Any] = {
k: v k: v
for k, v in json_dict.items() for k, v in json_dict.items()
if k not in MQTT_ATTRIBUTES_BLOCKED if k not in MQTT_ATTRIBUTES_BLOCKED

View File

@@ -25,6 +25,7 @@ from homeassistant.const import (
ATTR_ASSUMED_STATE, ATTR_ASSUMED_STATE,
ATTR_ATTRIBUTION, ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE, ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
ATTR_ICON, ATTR_ICON,
@@ -524,6 +525,9 @@ class Entity(
__capabilities_updated_at_reported: bool = False __capabilities_updated_at_reported: bool = False
__remove_future: asyncio.Future[None] | None = None __remove_future: asyncio.Future[None] | None = None
# Remember we keep track of included entities
__init_track_included_entities: bool = False
# Entity Properties # Entity Properties
_attr_assumed_state: bool = False _attr_assumed_state: bool = False
_attr_attribution: str | None = None _attr_attribution: str | None = None
@@ -539,6 +543,8 @@ class Entity(
_attr_extra_state_attributes: dict[str, Any] _attr_extra_state_attributes: dict[str, Any]
_attr_force_update: bool _attr_force_update: bool
_attr_icon: str | None _attr_icon: str | None
_attr_included_entities: list[str]
_attr_included_unique_ids: list[str]
_attr_name: str | None _attr_name: str | None
_attr_should_poll: bool = True _attr_should_poll: bool = True
_attr_state: StateType = STATE_UNKNOWN _attr_state: StateType = STATE_UNKNOWN
@@ -1085,6 +1091,8 @@ class Entity(
available = self.available # only call self.available once per update cycle available = self.available # only call self.available once per update cycle
state = self._stringify_state(available) state = self._stringify_state(available)
if available: if available:
if hasattr(self, "_attr_included_entities"):
attr[ATTR_ENTITY_ID] = self._attr_included_entities.copy()
if state_attributes := self.state_attributes: if state_attributes := self.state_attributes:
attr |= state_attributes attr |= state_attributes
if extra_state_attributes := self.extra_state_attributes: if extra_state_attributes := self.extra_state_attributes:
@@ -1376,6 +1384,7 @@ class Entity(
"""Finish adding an entity to a platform.""" """Finish adding an entity to a platform."""
await self.async_internal_added_to_hass() await self.async_internal_added_to_hass()
await self.async_added_to_hass() await self.async_added_to_hass()
self.async_set_included_entities()
self._platform_state = EntityPlatformState.ADDED self._platform_state = EntityPlatformState.ADDED
self.async_write_ha_state() self.async_write_ha_state()
@@ -1633,6 +1642,59 @@ class Entity(
self.hass, integration_domain=platform_name, module=type(self).__module__ self.hass, integration_domain=platform_name, module=type(self).__module__
) )
@callback
def async_set_included_entities(self) -> None:
"""Set the list of included entities identified by their unique IDs.
Integrations need to call this when the list of included unique IDs changes.
"""
if not self.included_unique_ids:
return
entity_registry = er.async_get(self.hass)
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 hasattr(self, "_attr_included_entities")
and event.data["entity_id"] in self._attr_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 []
class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes toggle entities.""" """A class that describes toggle entities."""

View File

@@ -82,6 +82,7 @@ light:
""" """
import copy import copy
import json
from typing import Any from typing import Any
from unittest.mock import call, patch from unittest.mock import call, patch
@@ -100,6 +101,7 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
) )
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.util.json import json_loads from homeassistant.util.json import json_loads
from .common import ( 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: class JsonValidator:
"""Helper to compare JSON.""" """Helper to compare JSON."""
@@ -1859,6 +1894,86 @@ async def test_white_scale(
assert state.attributes.get("brightness") == 129 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( @pytest.mark.parametrize(
"hass_config", "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 hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None: ) -> None:
"""Test the setting of attribute via MQTT with JSON payload.""" """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( async def test_setting_blocked_attribute_via_mqtt_json_message(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None: ) -> None:

View File

@@ -6,7 +6,7 @@ import dataclasses
from datetime import timedelta from datetime import timedelta
import logging import logging
import threading import threading
from typing import Any from typing import Any, final
from unittest.mock import MagicMock, PropertyMock, patch from unittest.mock import MagicMock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
@@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME, ATTR_FRIENDLY_NAME,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
@@ -1878,6 +1879,7 @@ async def test_change_entity_id(
self.remove_calls = [] self.remove_calls = []
async def async_added_to_hass(self): async def async_added_to_hass(self):
await super().async_added_to_hass()
self.added_calls.append(None) self.added_calls.append(None)
self.async_on_remove(lambda: result.append(1)) self.async_on_remove(lambda: result.append(1))
@@ -2896,3 +2898,107 @@ async def test_platform_state_write_from_init_unique_id(
# The early attempt to write is interpreted as a unique ID collision # 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 "Platform test_platform does not generate unique IDs." in caplog.text
assert "Entity id already exists - ignoring: test.test" not 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."""
return {"extra": "beer"}
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"
mock_entity._attr_included_unique_ids = [
"very_unique_continents",
"very_unique_oceans",
]
await platform.async_add_entities([mock_entity])
# Initiate mock grouped entity for hello domain
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._attr_included_unique_ids = [
"very_unique_continents",
"very_unique_moon",
"very_unique_oceans",
]
mock_entity.async_set_included_entities()
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._attr_included_unique_ids = ["very_unique_moon", "very_unique_oceans"]
mock_entity.async_set_included_entities()
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"]