From bde285cc93d74a42802c7998d68d4ab08bb22734 Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:35:22 +0200 Subject: [PATCH] Ignore unavailable members when computing fan and switch group state --- tests/test_fan.py | 50 +++++++++++++++++++++++ tests/test_switch.py | 45 ++++++++++++++++++++ zha/application/platforms/fan/__init__.py | 6 ++- zha/application/platforms/switch.py | 5 ++- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/tests/test_fan.py b/tests/test_fan.py index c02bedc4c..0a820339a 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -838,3 +838,53 @@ async def test_fan_kof_update_entity( assert entity.state.percentage == 25 assert entity.state.preset_mode is None assert entity.percentage_step == 100 / 4 + + +async def test_group_state_ignores_unavailable_members(zha_gateway: Gateway) -> None: + """Test that an unavailable member does not determine the fan group state.""" + device_fan_1 = await device_fan_1_mock(zha_gateway) + device_fan_2 = await device_fan_2_mock(zha_gateway) + members = [ + GroupMemberReference(ieee=device_fan_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_fan_2.ieee, endpoint_id=1), + ] + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + entity: GroupEntity = get_group_entity(zha_group, platform=Platform.FAN) + device_1_entity = get_entity(device_fan_1, platform=Platform.FAN) + + dev1_fan_cluster = device_fan_1.device.endpoints[1].fan + dev2_fan_cluster = device_fan_2.device.endpoints[1].fan + + # first member on high, second on low: the first member's speed wins + await send_attributes_report(zha_gateway, dev1_fan_cluster, {0: 3}) + await send_attributes_report(zha_gateway, dev2_fan_cluster, {0: 1}) + # group member updates are debounced + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert entity.state.percentage == 100 + + # the first member drops off the network while it is still on high + device_fan_1.update_available(False) + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert device_1_entity.state.available is False + assert device_1_entity.state.percentage == 100 + + # the reachable member's speed is used instead of the stale one + assert entity.state.percentage == 33 + assert entity.state.is_on is True + + # turning the reachable member off turns the group off + await send_attributes_report(zha_gateway, dev2_fan_cluster, {0: 0}) + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert entity.state.is_on is False + + # once it is reachable again its state counts + device_fan_1.update_available(True) + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert entity.state.percentage == 100 + assert entity.state.is_on is True diff --git a/tests/test_switch.py b/tests/test_switch.py index 3213d1970..ff4c1c473 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -904,3 +904,48 @@ async def test_binary_output_cluster(zha_gateway: Gateway) -> None: manufacturer=UNDEFINED, ) ] + + +async def test_group_state_ignores_unavailable_members(zha_gateway: Gateway) -> None: + """Test that an unavailable member does not keep the group on.""" + device_switch_1 = await device_switch_1_mock(zha_gateway) + device_switch_2 = await device_switch_2_mock(zha_gateway) + members = [ + GroupMemberReference(ieee=device_switch_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_switch_2.ieee, endpoint_id=1), + ] + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + entity: GroupEntity = get_group_entity(zha_group, platform=Platform.SWITCH) + device_2_entity = get_entity(device_switch_2, platform=Platform.SWITCH) + + dev1_cluster_on_off = device_switch_1.device.endpoints[1].on_off + dev2_cluster_on_off = device_switch_2.device.endpoints[1].on_off + + # both members on, so the group is on + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 1}) + await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 1}) + # group member updates are debounced + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert bool(entity.state.is_on) is True + + # the second member drops off the network while it is still on + device_switch_2.update_available(False) + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert device_2_entity.state.available is False + assert bool(device_2_entity.state.is_on) is True + + # turning the reachable member off turns the group off + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 0}) + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert bool(entity.state.is_on) is False + + # once it is reachable again its state counts, and the group is on + device_switch_2.update_available(True) + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert bool(entity.state.is_on) is True diff --git a/zha/application/platforms/fan/__init__.py b/zha/application/platforms/fan/__init__.py index ed37f19dd..18e9799ad 100644 --- a/zha/application/platforms/fan/__init__.py +++ b/zha/application/platforms/fan/__init__.py @@ -368,8 +368,10 @@ def update(self, _: Any = None) -> None: "All platform entity states for group entity members: %s", all_states ) - percentage_states = [state for state in all_states if state.percentage] - preset_mode_states = [state for state in all_states if state.preset_mode] + # Unavailable members keep their last state, ignore them + states = [state for state in all_states if state.available] + percentage_states = [state for state in states if state.percentage] + preset_mode_states = [state for state in states if state.preset_mode] if percentage_states: self._percentage = percentage_states[0].percentage diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index aa9eb4fb1..bdf61b3fe 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -376,14 +376,15 @@ async def async_turn_off(self) -> None: self.maybe_emit_state_changed_event() def update(self, _: Any | None = None) -> None: - """Query all members and determine the light group state.""" + """Query all members and determine the switch group state.""" self.debug("Updating switch group entity state") platform_entities = self._group.get_platform_entities(self.PLATFORM) all_states = [cast(SwitchState, entity.state) for entity in platform_entities] self.debug( "All platform entity states for group entity members: %s", all_states ) - on_states = [state for state in all_states if state.is_on] + # Unavailable members keep their last state, don't let them keep the group on + on_states = [state for state in all_states if state.is_on and state.available] self._state = len(on_states) > 0