From c328f1645c1e1405eb8a8e8861bdd8a9da6a2883 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 19 May 2026 13:01:31 +0200 Subject: [PATCH 1/2] Fix spurious off-revert from light transitioning flag on on/off-only lights `async_turn_off` unconditionally set the transitioning flag and started a ~1.5s timer. For on/off-only lights, a follow-up `async_turn_on` does not call `async_transition_set_flag`, so neither the stale `_transition_brightness_buffer = 0` (from the device's on_off=False report after the off command) nor the pending timer get cleared. When the timer fires it overrides the optimistic on-state back to off. Mirror the predicate from `async_turn_on`: only set the flag and start the timer when brightness is actually supported. On/off-only lights have no intermediate state to buffer, so the flag has no useful purpose for them. Fixes #766. --- tests/test_light.py | 55 +++++++++++++++++++++ zha/application/platforms/light/__init__.py | 15 +++--- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/tests/test_light.py b/tests/test_light.py index b59e452ce..de6e5d529 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -2069,6 +2069,61 @@ async def test_turn_on_during_off_transition(zha_gateway: Gateway) -> None: assert bool(entity.state["on"]) is False +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +async def test_on_off_only_rapid_toggle_does_not_revert( + zha_gateway: Gateway, +) -> None: + """Test rapid turn_off then turn_on on an on/off-only light does not revert to off. + + On/off-only lights have no brightness transition to wait for, so turn_off + must not arm the transitioning flag/timer. Otherwise a buffered on_off=False + report from the off command would override the optimistic on-state when + the timer fires after the follow-up turn_on. + """ + zigpy_device = create_mock_zigpy_device(zha_gateway, LIGHT_ON_OFF) + on_off_cluster = zigpy_device.endpoints[1].on_off + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 1} + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + + entity = get_entity(zha_device, platform=Platform.LIGHT) + assert bool(entity.state["on"]) is True + + # Turn off, then immediately turn back on (within the default ~1.5s window). + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert bool(entity.state["on"]) is False + # On/off-only lights have no transition to wait for. + assert not entity.is_transitioning + + # The device's on_off=False report from the off command arrives. + await send_attributes_report( + zha_gateway, + on_off_cluster, + {general.OnOff.AttributeDefs.on_off.id: 0}, + ) + await zha_gateway.async_block_till_done() + + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert bool(entity.state["on"]) is True + + # The device's on_off=True report from the on command arrives. + await send_attributes_report( + zha_gateway, + on_off_cluster, + {general.OnOff.AttributeDefs.on_off.id: 1}, + ) + await zha_gateway.async_block_till_done() + + # Wait past the old 1.5s timer window. State must remain on. + await asyncio.sleep(2) + await zha_gateway.async_block_till_done() + assert bool(entity.state["on"]) is True + + async def test_light_state_restoration(zha_gateway: Gateway) -> None: """Test the light state restoration function.""" device_light_3 = await device_light_3_mock(zha_gateway) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index aa41c148c..4475a37a5 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -633,8 +633,13 @@ async def async_turn_off(self, *, transition: float | None = None) -> None: else DEFAULT_ON_OFF_TRANSITION ) + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT - # Start pausing attribute report parsing - if self._zha_config_enable_light_transitioning_flag: + # Start pausing attribute report parsing, but not for on/off-only + # lights (they have no transition to wait for, and a follow-up + # turn_on would not clear the buffered off-report). + set_transition_flag = ( + brightness_supported and self._zha_config_enable_light_transitioning_flag + ) + if set_transition_flag: self.async_transition_set_flag() try: @@ -654,7 +659,7 @@ async def async_turn_off(self, *, transition: float | None = None) -> None: result = await self._on_off_cluster_handler.off() # Pause parsing attribute reports until transition is complete - if self._zha_config_enable_light_transitioning_flag: + if set_transition_flag: self.async_transition_start_timer(transition_time) self.debug("turned off: %s", result) if result[1] is not Status.SUCCESS: @@ -676,9 +681,7 @@ async def async_turn_off(self, *, transition: float | None = None) -> None: finally: # If the task was cancelled before the transition timer was started, # clean up the transitioning flag so the light does not get stuck. - self._async_cleanup_transition_if_stuck( - self._zha_config_enable_light_transitioning_flag - ) + self._async_cleanup_transition_if_stuck(set_transition_flag) async def async_handle_color_commands( self, From 409f969a62825d752690c9be35e83df2bc4d1304 Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:58:03 +0100 Subject: [PATCH 2/2] Fix light test for LightState attribute access --- tests/test_light.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_light.py b/tests/test_light.py index 3c0114609..448e3f68b 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -2089,12 +2089,12 @@ async def test_on_off_only_rapid_toggle_does_not_revert( zha_device = await join_zigpy_device(zha_gateway, zigpy_device) entity = get_entity(zha_device, platform=Platform.LIGHT) - assert bool(entity.state["on"]) is True + assert bool(entity.state.on) is True # Turn off, then immediately turn back on (within the default ~1.5s window). await entity.async_turn_off() await zha_gateway.async_block_till_done() - assert bool(entity.state["on"]) is False + assert bool(entity.state.on) is False # On/off-only lights have no transition to wait for. assert not entity.is_transitioning @@ -2108,7 +2108,7 @@ async def test_on_off_only_rapid_toggle_does_not_revert( await entity.async_turn_on() await zha_gateway.async_block_till_done() - assert bool(entity.state["on"]) is True + assert bool(entity.state.on) is True # The device's on_off=True report from the on command arrives. await send_attributes_report( @@ -2121,7 +2121,7 @@ async def test_on_off_only_rapid_toggle_does_not_revert( # Wait past the old 1.5s timer window. State must remain on. await asyncio.sleep(2) await zha_gateway.async_block_till_done() - assert bool(entity.state["on"]) is True + assert bool(entity.state.on) is True async def test_light_state_restoration(zha_gateway: Gateway) -> None: