From f66a03d6cb1c93f58e25b45f1b54162f5c86ba9e Mon Sep 17 00:00:00 2001 From: jbouwh Date: Mon, 15 Sep 2025 17:47:48 +0000 Subject: [PATCH] Automatically update the entity propery when a member created, updated or deleted --- homeassistant/components/mqtt/entity.py | 38 ++++++++++++++- tests/components/mqtt/test_light_json.py | 59 +++++++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 0351547c7eb..19c51e95e05 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -33,7 +33,13 @@ from homeassistant.const import ( CONF_URL, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HassJobType, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJobType, + HomeAssistant, + callback, +) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import ( DeviceEntry, @@ -476,12 +482,41 @@ class MqttAttributesMixin(Entity): [MessageCallbackType, set[str] | None, ReceiveMessage], None ] _process_update_extra_state_attributes: Callable[[dict[str, Any]], None] + _monitor_member_updates_callback: CALLBACK_TYPE def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" self._attributes_sub_state: dict[str, EntitySubscription] = {} self._attributes_config = config + def _monitor_member_updates(self) -> None: + """Update the group members if the entity registry is updated.""" + entity_registry = er.async_get(self.hass) + + async def _handle_entity_registry_updated(event: Event[Any]) -> None: + """Handle registry 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._attributes_config[CONF_GROUP] + ) or ( + event.data["action"] == "remove" + and self._group_entity_ids is not None + and event.data["entity_id"] in self._group_entity_ids + ): + self._update_group_entity_ids() + self._attr_extra_state_attributes[ATTR_ENTITY_ID] = ( + self._group_entity_ids + ) + self.async_write_ha_state() + + self.async_on_remove( + self.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + _handle_entity_registry_updated, + ) + ) + def _update_group_entity_ids(self) -> None: """Set the entity_id property if the entity represents a group of entities. @@ -506,6 +541,7 @@ class MqttAttributesMixin(Entity): await super().async_added_to_hass() self._update_group_entity_ids() if self._group_entity_ids is not None: + self._monitor_member_updates() self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._group_entity_ids} self._attributes_prepare_subscribe_topics() diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index fd2346590ef..50d13781855 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -101,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 ( @@ -1893,7 +1894,7 @@ async def test_white_scale( assert state.attributes.get("brightness") == 129 -async def test_light_group_discovery_members( +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. @@ -1919,6 +1920,62 @@ async def test_light_group_discovery_members( assert group_state.attributes.get("icon") == "mdi:lightbulb-group" +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"] + assert group_state.attributes.get("icon") == "mdi:lightbulb-group" + + # 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", [