From 24d076df1196a44ce728e0dca74cb507e1744f1c Mon Sep 17 00:00:00 2001 From: David Mulcahey Date: Fri, 27 Feb 2026 10:42:29 -0500 Subject: [PATCH 1/2] Fix gateway/group startup and teardown race conditions --- tests/test_gateway.py | 406 +++++++++++++++++++++++++++++++++++++ zha/application/gateway.py | 102 ++++++---- zha/application/helpers.py | 11 + zha/zigbee/group.py | 7 + 4 files changed, 487 insertions(+), 39 deletions(-) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 677955e71..309bf7681 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -950,3 +950,409 @@ async def test_group_on_remove_entity_failure( assert "Failed to remove group entity" in caplog.text assert "Group entity removal failed" in caplog.text + + +async def test_group_removed_unknown_group_is_noop(zha_gateway: Gateway) -> None: + """Test removing an unknown group does not crash.""" + zigpy_group = MagicMock() + zigpy_group.group_id = 0x1234 + + # Issue being validated: + # group_removed() does an unconditional dict pop() on self._groups. + # + # Why this is a problem: + # late/duplicate remove events (or partial state rebuilds) can raise KeyError and + # bubble through event handling, destabilizing group lifecycle handling. + zha_gateway.group_removed(zigpy_group) + + +async def test_country_code_passthrough_updates_when_changed( + zha_data: ZHAData, +) -> None: + """Test country code updates are reflected on repeated config reads.""" + zha_data.zigpy_config = {} + gateway = Gateway(zha_data) + + # Issue being validated: + # get_application_controller_data() mutates and reuses shared config, then only sets + # country code if not already present. + # + # Why this is a problem: + # after first use, country code becomes sticky and later updates are ignored, so + # runtime config changes cannot be applied correctly. + zha_data.country_code = "US" + gateway.get_application_controller_data() + + zha_data.country_code = "GB" + _, app_config = gateway.get_application_controller_data() + + assert app_config[CONF_NWK][CONF_NWK_COUNTRY_CODE] == "GB" + + +async def _create_group_with_two_members(zha_gateway: Gateway) -> Group: + """Create a test group with two light members.""" + coordinator = await coordinator_mock(zha_gateway) + zha_gateway.coordinator_zha_device = coordinator + coordinator._zha_gateway = zha_gateway + + device_light_1 = await device_light_1_mock(zha_gateway) + device_light_2 = await device_light_2_mock(zha_gateway) + device_light_1._zha_gateway = zha_gateway + device_light_2._zha_gateway = zha_gateway + + members = [ + GroupMemberReference(ieee=device_light_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_light_2.ieee, endpoint_id=1), + ] + group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + assert group is not None + return group + + +async def test_group_async_add_members_empty_list_noop( + zha_gateway: Gateway, +) -> None: + """Test adding an empty member list is a no-op.""" + zha_group = await _create_group_with_two_members(zha_gateway) + + # Issue being validated: + # async_add_members() assumes len(members) >= 1 in the "else" path and indexes + # members[0] for an empty input. + # + # Why this is a problem: + # callers that normalize to "always call with a list" can crash on valid empty + # operations instead of getting a harmless no-op. + await zha_group.async_add_members([]) + assert len(zha_group.members) == 2 + + +async def test_group_async_remove_members_empty_list_noop( + zha_gateway: Gateway, +) -> None: + """Test removing an empty member list is a no-op.""" + zha_group = await _create_group_with_two_members(zha_gateway) + + # Issue being validated: + # async_remove_members() has the same empty-list indexing bug as add_members(). + # + # Why this is a problem: + # empty removal requests should be idempotent, but currently they can crash + # automation flows that compute dynamic member diffs. + await zha_group.async_remove_members([]) + assert len(zha_group.members) == 2 + + +async def test_group_unregister_entity_clears_member_subscriptions( + zha_gateway: Gateway, +) -> None: + """Test unregistering the final group entity clears related subscriptions.""" + zha_group = await _create_group_with_two_members(zha_gateway) + entity = get_group_entity(zha_group, platform=Platform.LIGHT) + + # Issue being validated: + # unregister_group_entity() removes only the group-entity subscription, not + # member-entity subscriptions installed via update_entity_subscriptions(). + # + # Why this is a problem: + # stale callbacks stay registered and keep reacting to member updates after the + # group entity is gone, causing leaks and unexpected background behavior. + zha_group.unregister_group_entity(entity) + + assert zha_group._entity_unsubs == {} + + +async def test_shutdown_controller_exception_resets_shutting_down_flag( + zha_gateway: Gateway, +) -> None: + """Test shutdown recovers the lifecycle flag when controller shutdown fails.""" + assert zha_gateway.application_controller is not None + + # Issue being validated: + # If the controller shutdown step raises, Gateway.shutdown() should still restore + # lifecycle state so later shutdown attempts are not blocked. + # + # Why this is a problem: + # Leaving `shutting_down` stuck at True makes future cleanup a no-op, which can + # strand devices, groups, and tasks for the rest of the gateway lifetime. + try: + with ( + patch.object( + zha_gateway.application_controller, + "shutdown", + AsyncMock(side_effect=RuntimeError("controller shutdown failed")), + ), + pytest.raises(RuntimeError, match="controller shutdown failed"), + ): + await zha_gateway.shutdown() + + assert zha_gateway.shutting_down is False + finally: + if zha_gateway.shutting_down: + zha_gateway.shutting_down = False + if zha_gateway.application_controller is not None: + await zha_gateway.shutdown() + await asyncio.sleep(0) + + +async def test_startup_polling_failure_still_enables_allow_polling( + zha_gateway: Gateway, +) -> None: + """Test startup polling failures do not leave polling globally disabled.""" + zha_gateway.config.config.device_options.enable_mains_startup_polling = True + zha_gateway.config.allow_polling = False + + created_background_tasks: list[asyncio.Task] = [] + original_create_background_task = zha_gateway.async_create_background_task + + def _capture_background_task(*args, **kwargs): + task = original_create_background_task(*args, **kwargs) + created_background_tasks.append(task) + return task + + # Issue being validated: + # async_initialize_devices_and_entities() should always restore allow_polling=True + # even if startup mains polling fails. + # + # Why this is a problem: + # A transient startup error can permanently disable periodic polling, silently + # stopping refresh/availability behavior for the entire gateway session. + try: + with ( + patch.object( + zha_gateway, + "async_fetch_updated_state_mains", + AsyncMock(side_effect=RuntimeError("startup polling failed")), + ), + patch.object( + zha_gateway, + "async_create_background_task", + side_effect=_capture_background_task, + ), + ): + await zha_gateway.async_initialize_devices_and_entities() + await asyncio.gather(*created_background_tasks, return_exceptions=True) + + assert created_background_tasks + + assert zha_gateway.config.allow_polling is True + finally: + for task in created_background_tasks: + if not task.done(): + task.cancel() + if created_background_tasks: + await asyncio.gather(*created_background_tasks, return_exceptions=True) + + +async def test_device_removed_cancels_pending_device_init_task( + zha_gateway: Gateway, +) -> None: + """Test removing a device cancels any pending init task for that device.""" + zigpy_dev_basic = create_mock_zigpy_device(zha_gateway, ZIGPY_DEVICE_BASIC) + zha_gateway.get_or_create_device(zigpy_dev_basic) + + init_blocker: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + async def _blocked_initialize(_device): + await init_blocker + + # Issue being validated: + # device_removed() should tear down pending `_device_init_tasks` for the removed + # device as part of second-pass lifecycle cleanup. + # + # Why this is a problem: + # Leaving stale init tasks running after removal leaks task ownership and can race + # with later re-joins for the same IEEE. + with patch.object( + zha_gateway, + "async_device_initialized", + AsyncMock(side_effect=_blocked_initialize), + ): + zha_gateway.device_initialized(zigpy_dev_basic) + await asyncio.sleep(0) + init_task = zha_gateway._device_init_tasks[zigpy_dev_basic.ieee] + assert not init_task.done() + + try: + zha_gateway.device_removed(zigpy_dev_basic) + await asyncio.sleep(0) + + assert init_task.cancelled() + assert zigpy_dev_basic.ieee not in zha_gateway._device_init_tasks + finally: + if not init_blocker.done(): + init_blocker.cancel() + if not init_task.done(): + if zigpy_dev_basic.ieee not in zha_gateway._device_init_tasks: + zha_gateway._device_init_tasks[zigpy_dev_basic.ieee] = init_task + init_task.cancel() + await asyncio.gather(init_task, return_exceptions=True) + zha_gateway._device_init_tasks.pop(zigpy_dev_basic.ieee, None) + await zha_gateway.async_block_till_done() + + +async def test_global_updater_second_start_stop_cancels_first_task( + zha_gateway: Gateway, +) -> None: + """Test second start does not orphan the first global updater task.""" + updater = zha_gateway.global_updater + + # Reset to a known baseline from fixture startup. + updater.stop() + await asyncio.sleep(0) + + # Issue being validated: + # Calling GlobalUpdater.start() repeatedly should be idempotent and must not leave + # previously created updater tasks running. + # + # Why this is a problem: + # If only the latest handle is tracked, stop() cancels one task while an older task + # keeps running, creating duplicated periodic work and leaked background tasks. + updater.start() + first_task = updater._updater_task_handle + assert first_task is not None + + second_task = None + try: + updater.start() + second_task = updater._updater_task_handle + assert second_task is not None + assert second_task is first_task + + updater.stop() + await asyncio.sleep(0) + + assert first_task.cancelled() + assert second_task.cancelled() + finally: + for task in (first_task, second_task): + if task is None: + continue + if not task.done(): + task.cancel() + await asyncio.gather( + *(t for t in (first_task, second_task) if t is not None), + return_exceptions=True, + ) + + +async def test_gateway_device_initialized_done_callback_race_keeps_newer_task( + zha_gateway: Gateway, +) -> None: + """Test cancelled init task callback does not drop a newer init task.""" + zigpy_dev_basic = create_mock_zigpy_device(zha_gateway, ZIGPY_DEVICE_BASIC) + + first_cancel_release = asyncio.Event() + second_task_blocker = asyncio.Event() + init_call_count = 0 + first_task: asyncio.Task | None = None + second_task: asyncio.Task | None = None + + async def _controlled_initialize(_device): + nonlocal init_call_count + init_call_count += 1 + + if init_call_count == 1: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + await first_cancel_release.wait() + raise + + if init_call_count == 2: + await second_task_blocker.wait() + return + + raise AssertionError("Unexpected extra async_device_initialized call") + + # Issue being validated: + # Gateway.device_initialized() stores tasks by IEEE and each task's done callback + # unconditionally pops that IEEE key. + # + # Why this is a problem: + # If task-1 is cancelled and task-2 is created for the same IEEE, task-1 finishing + # later can pop task-2 out of `_device_init_tasks`, orphaning task tracking. + try: + with patch.object( + zha_gateway, + "async_device_initialized", + AsyncMock(side_effect=_controlled_initialize), + ): + zha_gateway.device_initialized(zigpy_dev_basic) + await asyncio.sleep(0) + first_task = zha_gateway._device_init_tasks[zigpy_dev_basic.ieee] + assert not first_task.done() + + zha_gateway.device_initialized(zigpy_dev_basic) + second_task = zha_gateway._device_init_tasks[zigpy_dev_basic.ieee] + assert second_task is not None + assert second_task is not first_task + + first_cancel_release.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert zha_gateway._device_init_tasks[zigpy_dev_basic.ieee] is second_task + finally: + first_cancel_release.set() + second_task_blocker.set() + + for task in (first_task, second_task): + if task is None or task.done(): + continue + if ( + task is second_task + and zigpy_dev_basic.ieee not in zha_gateway._device_init_tasks + ): + zha_gateway._device_init_tasks[zigpy_dev_basic.ieee] = task + task.cancel() + + await asyncio.gather( + *(t for t in (first_task, second_task) if t is not None), + return_exceptions=True, + ) + zha_gateway._device_init_tasks.pop(zigpy_dev_basic.ieee, None) + await zha_gateway.async_block_till_done() + + +async def test_device_availability_checker_start_twice_stop_once_cancels_all_tasks( + zha_gateway: Gateway, +) -> None: + """Test repeated start does not leave an older availability task alive.""" + checker = zha_gateway._device_availability_checker + + # Reset to a known baseline from fixture startup. + checker.stop() + await asyncio.sleep(0) + + first_task: asyncio.Task | None = None + second_task: asyncio.Task | None = None + + # Issue being validated: + # DeviceAvailabilityChecker.start() should be idempotent across repeated calls. + # + # Why this is a problem: + # If start() can create multiple tasks but stop() only cancels the latest handle, + # a previous checker task keeps running and leaks periodic background work. + try: + checker.start() + first_task = checker._device_availability_task_handle + assert first_task is not None + + checker.start() + second_task = checker._device_availability_task_handle + assert second_task is not None + + checker.stop() + await asyncio.sleep(0) + + assert first_task.cancelled() + assert second_task.cancelled() + finally: + checker.stop() + tasks = list({task for task in (first_task, second_task) if task is not None}) + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) diff --git a/zha/application/gateway.py b/zha/application/gateway.py index 64434f499..4fb2a37cb 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -198,7 +198,9 @@ def radio_type(self) -> RadioType: def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" - app_config = self.config.zigpy_config + app_config = dict(self.config.zigpy_config) + if CONF_NWK in app_config: + app_config[CONF_NWK] = dict(app_config[CONF_NWK]) app_config[CONF_DEVICE] = { CONF_DEVICE_PATH: self.config.config.coordinator_configuration.path, CONF_DEVICE_BAUDRATE: self.config.config.coordinator_configuration.baudrate, @@ -380,13 +382,17 @@ async def async_initialize_devices_and_entities(self) -> None: async def fetch_updated_state() -> None: """Fetch updated state for mains powered devices.""" - if self.config.config.device_options.enable_mains_startup_polling: - async with self.request_priority(t.PacketPriority.LOW): - await self.async_fetch_updated_state_mains() - else: - _LOGGER.debug("Polling of mains powered devices at startup is disabled") - _LOGGER.debug("Allowing polled requests") - self.config.allow_polling = True + try: + if self.config.config.device_options.enable_mains_startup_polling: + async with self.request_priority(t.PacketPriority.LOW): + await self.async_fetch_updated_state_mains() + else: + _LOGGER.debug( + "Polling of mains powered devices at startup is disabled" + ) + finally: + _LOGGER.debug("Allowing polled requests") + self.config.allow_polling = True # background the fetching of state for mains powered devices self.async_create_background_task( @@ -443,7 +449,12 @@ def device_initialized(self, device: zigpy.device.Device) -> None: name=f"device_initialized_task_{str(device.ieee)}:0x{device.nwk:04x}", eager_start=True, ) - init_task.add_done_callback(lambda _: self._device_init_tasks.pop(device.ieee)) + + def _remove_device_init_task(done_task: asyncio.Task) -> None: + if self._device_init_tasks.get(device.ieee) is done_task: + self._device_init_tasks.pop(device.ieee, None) + + init_task.add_done_callback(_remove_device_init_task) def device_left(self, device: zigpy.device.Device) -> None: """Handle device leaving the network.""" @@ -494,7 +505,13 @@ def group_added(self, zigpy_group: zigpy.group.Group) -> None: def group_removed(self, zigpy_group: zigpy.group.Group) -> None: """Handle zigpy group removed event.""" self._emit_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED) - zha_group = self._groups.pop(zigpy_group.group_id) + zha_group = self._groups.pop(zigpy_group.group_id, None) + if zha_group is None: + _LOGGER.debug( + "Received group_removed for unknown group 0x%04x", + zigpy_group.group_id, + ) + return zha_group.info("group_removed") def _emit_group_gateway_message( # pylint: disable=unused-argument @@ -516,6 +533,10 @@ def _emit_group_gateway_message( # pylint: disable=unused-argument def device_removed(self, device: zigpy.device.Device) -> None: """Handle device being removed from the network.""" _LOGGER.info("Removing device %s - %s", device.ieee, f"0x{device.nwk:04x}") + + if init_task := self._device_init_tasks.pop(device.ieee, None): + init_task.cancel() + zha_device = self._devices.pop(device.ieee, None) if zha_device is not None: device_info = zha_device.extended_device_info @@ -739,40 +760,43 @@ async def shutdown(self) -> None: self.shutting_down = True - self.global_updater.stop() - self._device_availability_checker.stop() + try: + self.global_updater.stop() + self._device_availability_checker.stop() - for device in self._devices.values(): - try: - await device.on_remove() - except Exception: - _LOGGER.warning( - "Failed to remove device %s during shutdown", - device, - exc_info=True, - ) + for device in self._devices.values(): + try: + await device.on_remove() + except Exception: + _LOGGER.warning( + "Failed to remove device %s during shutdown", + device, + exc_info=True, + ) - for group in self._groups.values(): - try: - await group.on_remove() - except Exception: - _LOGGER.warning( - "Failed to remove group %s during shutdown", - group, - exc_info=True, - ) + for group in self._groups.values(): + try: + await group.on_remove() + except Exception: + _LOGGER.warning( + "Failed to remove group %s during shutdown", + group, + exc_info=True, + ) - _LOGGER.debug("Shutting down ZHA ControllerApplication") - if self.application_controller is not None: - await self.application_controller.shutdown() - self.application_controller = None - # give bellows thread callback a chance to run - await asyncio.sleep(SHUT_DOWN_DELAY_S) + _LOGGER.debug("Shutting down ZHA ControllerApplication") + if self.application_controller is not None: + await self.application_controller.shutdown() + self.application_controller = None + # give bellows thread callback a chance to run + await asyncio.sleep(SHUT_DOWN_DELAY_S) - await super().shutdown() + await super().shutdown() - self._devices.clear() - self._groups.clear() + self._devices.clear() + self._groups.clear() + finally: + self.shutting_down = False def handle_message( # pylint: disable=unused-argument self, diff --git a/zha/application/helpers.py b/zha/application/helpers.py index a0e09b179..70549793c 100644 --- a/zha/application/helpers.py +++ b/zha/application/helpers.py @@ -412,6 +412,11 @@ def __init__(self, gateway: Gateway): def start(self): """Start the global updater.""" + if self._updater_task_handle and not ( + self._updater_task_handle.done() or self._updater_task_handle.cancelled() + ): + return + self._updater_task_handle = self._gateway.async_create_background_task( self.update_listeners(), name=f"global-updater_{self.__class__.__name__}", @@ -477,6 +482,12 @@ def __init__(self, gateway: Gateway): def start(self): """Start the device availability checker.""" + if self._device_availability_task_handle and not ( + self._device_availability_task_handle.done() + or self._device_availability_task_handle.cancelled() + ): + return + self._device_availability_task_handle = ( self._gateway.async_create_background_task( self.check_device_availability(), diff --git a/zha/zigbee/group.py b/zha/zigbee/group.py index f1d975bf1..c022058a4 100644 --- a/zha/zigbee/group.py +++ b/zha/zigbee/group.py @@ -236,6 +236,7 @@ def unregister_group_entity(self, group_entity: GroupEntity) -> None: if group_entity.unique_id in self._group_entities: self._group_entities.pop(group_entity.unique_id) self._entity_unsubs.pop(group_entity.unique_id)() + self.update_entity_subscriptions() def _handle_maybe_update_group_members(self, event: EntityStateChangedEvent): """Handle the maybe update group members event.""" @@ -297,6 +298,9 @@ def update_entity_subscriptions(self) -> None: async def async_add_members(self, members: list[GroupMemberReference]) -> None: """Add members to this group.""" + if not members: + return + devices: dict[EUI64, Device] = self._gateway.devices if len(members) > 1: tasks = [] @@ -316,6 +320,9 @@ async def async_add_members(self, members: list[GroupMemberReference]) -> None: async def async_remove_members(self, members: list[GroupMemberReference]) -> None: """Remove members from this group.""" + if not members: + return + devices: dict[EUI64, Device] = self._gateway.devices if len(members) > 1: tasks = [] From 3646c90d9048b614facf0fdad047e0f5bcac15cf Mon Sep 17 00:00:00 2001 From: David Mulcahey Date: Fri, 27 Feb 2026 11:47:19 -0500 Subject: [PATCH 2/2] update coverage --- tests/test_gateway.py | 177 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 309bf7681..79dffc85b 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1356,3 +1356,180 @@ async def test_device_availability_checker_start_twice_stop_once_cancels_all_tas task.cancel() if tasks: await asyncio.gather(*tasks, return_exceptions=True) + + +async def test_shutdown_duplicate_call_short_circuits_cleanup( + zha_data: ZHAData, +) -> None: + """Test duplicate shutdown calls exit early without cleanup side effects.""" + gateway = Gateway(zha_data) + updater_stop = patch.object( + gateway.global_updater, + "stop", + wraps=gateway.global_updater.stop, + ) + availability_stop = patch.object( + gateway._device_availability_checker, + "stop", + wraps=gateway._device_availability_checker.stop, + ) + + # Issue being validated: + # shutdown() should immediately return when `shutting_down` is already True. + # + # Why this is a problem: + # Without a hard short-circuit, duplicate shutdown calls can race through + # teardown and repeatedly manipulate lifecycle-managed resources. + with updater_stop as updater_stop_mock, availability_stop as availability_stop_mock: + gateway.shutting_down = True + await gateway.shutdown() + + assert updater_stop_mock.call_count == 0 + assert availability_stop_mock.call_count == 0 + assert gateway.shutting_down is True + + +async def test_find_coordinator_device_prefers_backup_ieee_when_available( + zha_data: ZHAData, +) -> None: + """Test coordinator lookup uses backup IEEE when a backup exists.""" + gateway = Gateway(zha_data) + primary_coordinator = MagicMock() + backup_coordinator = MagicMock() + backup = MagicMock() + backup.node_info.ieee = zigpy.types.EUI64.convert("00:11:22:33:44:55:66:77") + + gateway.application_controller = MagicMock() + gateway.application_controller.backups.most_recent_backup.return_value = backup + gateway.application_controller.get_device.side_effect = [ + primary_coordinator, + backup_coordinator, + ] + + # Issue being validated: + # _find_coordinator_device() should attempt a second lookup by backup IEEE when + # backup metadata exists, rather than relying only on nwk=0x0000. + # + # Why this is a problem: + # After restores/reforms, nwk-based lookup can point at stale coordinator state; + # skipping IEEE recovery can bind gateway coordinator logic to the wrong device. + resolved = gateway._find_coordinator_device() + + assert resolved is backup_coordinator + assert gateway.application_controller.get_device.call_args_list == [ + call(nwk=0x0000), + call(ieee=backup.node_info.ieee), + ] + + +async def test_load_devices_handles_last_seen_delta_branch( + zha_data: ZHAData, +) -> None: + """Test load_devices executes last-seen delta logic for known devices.""" + gateway = Gateway(zha_data) + zigpy_device = MagicMock() + gateway.application_controller = MagicMock() + gateway.application_controller.devices = {zigpy_device.ieee: zigpy_device} + + restored_device = MagicMock() + restored_device.last_seen = 100.0 + restored_device.nwk = 0x1234 + restored_device.name = "test-device" + restored_device.available = True + restored_device.consider_unavailable_time = 120 + restored_device.async_initialize = AsyncMock() + + # Issue being validated: + # load_devices() has a branch that computes elapsed time since last_seen for + # restored devices. + # + # Why this is a problem: + # Startup diagnostics and availability reasoning rely on this path; if untested, + # regressions can silently degrade restore-time observability. + with ( + patch.object(gateway, "get_or_create_device", return_value=restored_device), + patch("zha.application.gateway.time.time", return_value=130.0), + ): + await gateway.load_devices() + + restored_device.async_initialize.assert_awaited_once_with(from_cache=True) + + +async def test_load_groups_adds_discovered_entities(zha_data: ZHAData) -> None: + """Test load_groups adds discovered group entities.""" + gateway = Gateway(zha_data) + zigpy_group = MagicMock() + zha_group = MagicMock() + discovered_group_entity = MagicMock() + + gateway.application_controller = MagicMock() + gateway.application_controller.groups = {0x1001: zigpy_group} + + # Issue being validated: + # load_groups() should call on_add() for entities discovered on restored groups. + # + # Why this is a problem: + # If discovered entities are not added during group restoration, group-backed + # functionality comes up partially initialized after startup/reload cycles. + with ( + patch.object(gateway, "get_or_create_group", return_value=zha_group), + patch( + "zha.application.gateway.discovery.discover_group_entities", + return_value=[discovered_group_entity], + ), + ): + gateway.load_groups() + + discovered_group_entity.on_add.assert_called_once_with() + + +async def test_group_maybe_update_group_members_awaits_pollable_entities( + zha_gateway: Gateway, +) -> None: + """Test group member updates await pollable entity refresh tasks.""" + zha_group = await _create_group_with_two_members(zha_gateway) + pollable_entity = MagicMock() + pollable_entity.should_poll = True + pollable_entity.async_update = AsyncMock() + event = MagicMock(platform=Platform.LIGHT) + + # Issue being validated: + # _maybe_update_group_members() should aggregate async_update() tasks for member + # entities that are marked should_poll. + # + # Why this is a problem: + # If pollable members are skipped, group state drifts stale and composite group + # entity behavior diverges from actual member state over time. + with patch.object( + zha_group, "get_platform_entities", return_value=[pollable_entity] + ): + await zha_group._maybe_update_group_members(event) + + pollable_entity.async_update.assert_awaited_once_with() + + +async def test_async_from_config_registers_unbuilt_v2_quirks( + zha_data: ZHAData, +) -> None: + """Test async_from_config registers unbuilt v2 quirks before setup.""" + zha_data.config.quirks_configuration.enabled = True + unregistered_quirk = MagicMock() + unregistered_quirk.manufacturer_model_metadata = [("Test", "Model")] + + # Issue being validated: + # async_from_config() should detect builders left in UNBUILT_QUIRK_BUILDERS and + # explicitly register builders that include manufacturer/model metadata. + # + # Why this is a problem: + # If these builders are not registered, matching quirks never activate and + # affected devices run with incomplete or incorrect runtime behavior. + with ( + patch( + "zha.application.gateway.UNBUILT_QUIRK_BUILDERS", + [unregistered_quirk], + ), + patch("zha.application.gateway.setup_quirks"), + ): + await Gateway.async_from_config(zha_data) + + unregistered_quirk.add_to_registry.assert_called_once_with()