diff --git a/tests/test_device.py b/tests/test_device.py index 2dce8975f..ec463ce5e 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -2026,6 +2026,39 @@ async def test_entity_recomputation(zha_gateway: Gateway) -> None: ] +async def test_async_configure_does_not_queue_entities(zha_gateway: Gateway) -> None: + """Test a reconfigure does not leave half-added entities behind.""" + zigpy_dev = await zigpy_device_from_json( + zha_gateway.application_controller, + "tests/data/devices/ikea-of-sweden-tradfri-bulb-gu10-ws-400lm-0x23095631.json", + ) + zha_device = await join_zigpy_device(zha_gateway, zigpy_dev) + + entities_before = dict(zha_device.platform_entities) + + await zha_device.async_configure() + + # Discovery ran for the cluster config aggregation, but no entities were + # activated via `on_add()` and queued (nothing would ever flush them here) + assert zha_device._discovered_entities + assert not zha_device._pending_entities + assert zha_device.platform_entities == entities_before + + # After a rebuild (same-quirk re-interview), the device has no live entities + # anymore, so configuration must activate and queue the entities again (e.g. + # the IAS zone enrollment entity has to listen during CIE configuration) + await zha_device.async_rebuild_from_zigpy_device(zigpy_dev) + await zha_device.async_configure() + + assert zha_device._pending_entities + + # The following initialization adds them, like on the initial join + await zha_device.async_initialize(from_cache=True) + + assert not zha_device._pending_entities + assert set(zha_device.platform_entities) == set(entities_before) + + async def test_add_entity_duplicate(zha_gateway: Gateway) -> None: """Test that adding a duplicate entity raises an error.""" zigpy_dev = await zigpy_device_from_json( diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index 44b3a2a4c..02322ab14 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -1008,7 +1008,17 @@ async def async_configure(self) -> None: self.debug("applying quirks custom device configuration") await self._zigpy_device.apply_custom_configuration() - self._discover_new_entities() + # On a reconfigure of a live device, discovery is only needed to + # aggregate the cluster configs. The entities must not be queued for + # adding then: nothing flushes the pending entities, so they would + # linger with active `on_add()` side effects (e.g. cluster listeners) + # without ever being added — the live entities already listen. Without + # live entities (initial join, or a rebuild after a re-interview), the + # entities must be activated, however: e.g. the IAS zone enrollment + # entity has to answer a device-initiated enroll request right after + # `configure_cluster_configs` writes the CIE address below. + # `async_initialize()` adds them and cleans up its own re-discoveries. + self._discover_new_entities(add_entities=not self._platform_entities) # Configure binding and reporting from entity-level cluster configs aggregated = aggregate_cluster_configs(self._discovered_entities) @@ -1078,7 +1088,13 @@ def discover_entities(self) -> Iterator[BaseEntity]: ) yield from discovery.discover_entities_for_endpoint(endpoint) - def _discover_new_entities(self) -> None: + def _discover_new_entities(self, *, add_entities: bool = True) -> None: + """Discover entities and queue them to be added to the device. + + With `add_entities=False`, only `self._discovered_entities` is populated + (e.g. for aggregating cluster configs): the entities are neither activated + via `on_add()` nor queued for `_add_pending_entities`. + """ self._discovered_entities.clear() # Iterate defensively so a failure in any single entity construction @@ -1101,6 +1117,9 @@ def _discover_new_entities(self) -> None: # Apply any metadata changes from quirks v2 self._apply_entity_metadata_changes(entity) + if not add_entities: + continue + entity.on_add() self._pending_entities.append(entity)