From 3569a072d0781bcb912dc5b3aaee4bcf98bfdb8f Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 22 Feb 2026 01:40:40 +0100 Subject: [PATCH 1/6] Buffer light brightness transition reports instead of skipping --- zha/application/platforms/light/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index dcf4393b4..fd489e7a3 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -283,6 +283,7 @@ def __init__(self, *args, **kwargs): self._transitioning_individual: bool = False self._transitioning_group: bool = False self._transition_listener: asyncio.TimerHandle | None = None + self._transition_brightness_buffer: int | None = None self._internal_supported_color_modes: set[ColorMode] = set() @@ -686,6 +687,7 @@ def async_transition_set_flag(self) -> None: self.debug("setting transitioning flag to True") self._transitioning_individual = True self._transitioning_group = False + self._transition_brightness_buffer = None if isinstance(self, LightGroup): for platform_entity in self.group.get_platform_entities(Light.PLATFORM): assert isinstance(platform_entity, Light) @@ -724,6 +726,13 @@ def async_transition_complete(self, _=None) -> None: self.debug("transition complete - future attribute reports will write HA state") self._transitioning_individual = False self._async_unsub_transition_listener() + if self._transition_brightness_buffer is not None: + self.debug( + "applying buffered brightness %s from transition", + self._transition_brightness_buffer, + ) + self._brightness = self._transition_brightness_buffer + self._transition_brightness_buffer = None self.maybe_emit_state_changed_event() if isinstance(self, LightGroup): for platform_entity in self.group.get_platform_entities(Light.PLATFORM): @@ -880,13 +889,14 @@ def handle_cluster_handler_set_level(self, event: LevelChangeEvent) -> None: on at `on_level` Zigbee attribute value, regardless of the last set level """ + value = max(0, min(254, event.level)) if self.is_transitioning: self.debug( - "received level change event %s while transitioning - skipping update", + "received level change event %s while transitioning - buffering", event, ) + self._transition_brightness_buffer = value return - value = max(0, min(254, event.level)) self._brightness = value self.maybe_emit_state_changed_event() From 97e49ef966b5f398df53eebd85a90c518e9f724d Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 22 Feb 2026 01:40:47 +0100 Subject: [PATCH 2/6] Adjust `DEFAULT_EXTRA_TRANSITION_DELAY_SHORT` --- zha/application/platforms/light/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/application/platforms/light/const.py b/zha/application/platforms/light/const.py index c5a1f1d07..69488cc86 100644 --- a/zha/application/platforms/light/const.py +++ b/zha/application/platforms/light/const.py @@ -7,7 +7,7 @@ from zigpy.zcl.clusters.general import Identify DEFAULT_ON_OFF_TRANSITION = 1 # most bulbs default to a 1-second turn on/off transition -DEFAULT_EXTRA_TRANSITION_DELAY_SHORT = 0.25 +DEFAULT_EXTRA_TRANSITION_DELAY_SHORT = 0.5 DEFAULT_EXTRA_TRANSITION_DELAY_LONG = 2.0 DEFAULT_LONG_TRANSITION_TIME = 10 DEFAULT_MIN_BRIGHTNESS = 2 From 52713a11c7c2975bfe8b784d672b5f08b95faf88 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 22 Feb 2026 01:40:51 +0100 Subject: [PATCH 3/6] Add test --- tests/test_light.py | 72 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/test_light.py b/tests/test_light.py index 449471b61..fccbab13e 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -1937,6 +1937,78 @@ async def test_group_member_assume_state(zha_gateway: Gateway) -> None: assert device_2_light_entity.state["brightness"] == 100 +@patch( + "zigpy.zcl.clusters.general.LevelControl.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +async def test_transition_brightness_buffering(zha_gateway: Gateway) -> None: + """Test that brightness reports during a transition are buffered. + + The last received report is applied when the transition completes, which + handles lights that claim SUCCESS but fail to reach the target brightness. + """ + device_light_1 = await device_light_1_mock(zha_gateway) + dev1_cluster_level = device_light_1.device.endpoints[1].level + entity = get_entity(device_light_1, platform=Platform.LIGHT) + + assert bool(entity.state["on"]) is False + + # Turn on with a short transition and a target brightness of 200. + await entity.async_turn_on(transition=0.1, brightness=200) + await zha_gateway.async_block_till_done() + + # The state is optimistically set to the target brightness immediately. + assert bool(entity.state["on"]) is True + assert entity.state["brightness"] == 200 + assert entity.is_transitioning + + # Simulate intermediate brightness reports during the transition (light slowly ramping up). + # These should be buffered, not immediately applied to HA state. + await send_attributes_report( + zha_gateway, + dev1_cluster_level, + {general.LevelControl.AttributeDefs.current_level.id: 50}, + ) + await zha_gateway.async_block_till_done() + assert entity.state["brightness"] == 200 # still the optimistic value + + # The light only goes to brightness 120 (for some reason), not the requested 200. + await send_attributes_report( + zha_gateway, + dev1_cluster_level, + {general.LevelControl.AttributeDefs.current_level.id: 120}, + ) + await zha_gateway.async_block_till_done() + assert entity.state["brightness"] == 200 # still buffered, not yet applied + + # Wait for the transition timer to fire (0.1 + 0.5s delay = 0.6s). + await asyncio.sleep(0.8) + await zha_gateway.async_block_till_done() + + # After the transition, the last buffered report (120) is applied instead of the target (200). + assert not entity.is_transitioning + assert entity.state["brightness"] == 120 + + # Now verify that if no brightness reports arrive during a transition, the + # optimistically set target brightness is preserved unchanged. + await entity.async_turn_on(transition=0.1, brightness=150) + await zha_gateway.async_block_till_done() + + assert entity.state["brightness"] == 150 + assert entity.is_transitioning + + # No level reports during this transition. + await asyncio.sleep(0.8) + await zha_gateway.async_block_till_done() + + assert not entity.is_transitioning + assert entity.state["brightness"] == 150 # target preserved + + 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) From 834915a0bb857ba191e4bcfe3ba611917d812b77 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 22 Feb 2026 01:55:28 +0100 Subject: [PATCH 4/6] WIP: Also buffer last `OnOff` report state --- zha/application/platforms/light/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index fd489e7a3..a6c7798bc 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -284,6 +284,7 @@ def __init__(self, *args, **kwargs): self._transitioning_group: bool = False self._transition_listener: asyncio.TimerHandle | None = None self._transition_brightness_buffer: int | None = None + self._transition_on_off_buffer: bool | None = None self._internal_supported_color_modes: set[ColorMode] = set() @@ -688,6 +689,7 @@ def async_transition_set_flag(self) -> None: self._transitioning_individual = True self._transitioning_group = False self._transition_brightness_buffer = None + self._transition_on_off_buffer = None if isinstance(self, LightGroup): for platform_entity in self.group.get_platform_entities(Light.PLATFORM): assert isinstance(platform_entity, Light) @@ -733,6 +735,16 @@ def async_transition_complete(self, _=None) -> None: ) self._brightness = self._transition_brightness_buffer self._transition_brightness_buffer = None + if self._transition_on_off_buffer is not None: + self.debug( + "applying buffered on/off %s from transition", + self._transition_on_off_buffer, + ) + self._state = self._transition_on_off_buffer + if self._transition_on_off_buffer: + self._off_with_transition = False + self._off_brightness = None + self._transition_on_off_buffer = None self.maybe_emit_state_changed_event() if isinstance(self, LightGroup): for platform_entity in self.group.get_platform_entities(Light.PLATFORM): @@ -953,9 +965,10 @@ def handle_cluster_handler_attribute_updated( return if self.is_transitioning: self.debug( - "received onoff %s while transitioning - skipping update", + "received onoff %s while transitioning - buffering", event.attribute_value, ) + self._transition_on_off_buffer = bool(event.attribute_value) return self._state = bool(event.attribute_value) if event.attribute_value: From e441a1a73f8d21e7ea614aa824c0c370844b7808 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 22 Feb 2026 01:55:46 +0100 Subject: [PATCH 5/6] WIP: Add test for light refusing to turn on during transitions --- tests/test_light.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_light.py b/tests/test_light.py index fccbab13e..d11855665 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -2009,6 +2009,65 @@ async def test_transition_brightness_buffering(zha_gateway: Gateway) -> None: assert entity.state["brightness"] == 150 # target preserved +@patch( + "zigpy.zcl.clusters.general.LevelControl.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +async def test_turn_on_during_off_transition(zha_gateway: Gateway) -> None: + """Test turning a light on while it is mid-way through an off transition. + + Some devices refuse to turn on while a transition to off is still running, + even though they return Status.SUCCESS for the on command. The device then + correctly reports on_off=false. That report must be buffered and applied + when the transition window ends, so HA reflects the actual off state. + """ + device_light_1 = await device_light_1_mock(zha_gateway) + dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off + entity = get_entity(device_light_1, platform=Platform.LIGHT) + + # Start with the light on. + await entity.async_turn_on(brightness=200) + await zha_gateway.async_block_till_done() + assert bool(entity.state["on"]) is True + + # Turn it off with a transition (timer runs for 1 + 0.5s = 1.5s). + await entity.async_turn_off(transition=1) + await zha_gateway.async_block_till_done() + assert bool(entity.state["on"]) is False + assert entity.is_transitioning + + # Before the off-transition timer fires, turn the light back on. + # The device accepts the command (returns SUCCESS) but refuses to execute it. + await entity.async_turn_on(brightness=150) + await zha_gateway.async_block_till_done() + # Optimistically, HA now thinks it's on. + assert bool(entity.state["on"]) is True + assert entity.state["brightness"] == 150 + assert entity.is_transitioning + + # The device correctly reports it is still off (it refused the on command). + # This must be buffered, not ignored. + await send_attributes_report( + zha_gateway, + dev1_cluster_on_off, + {general.OnOff.AttributeDefs.on_off.id: 0}, + ) + await zha_gateway.async_block_till_done() + + # During the transition window the state is still optimistically on. + assert bool(entity.state["on"]) is True + + # Once the transition timer fires, the buffered off report is applied. + await asyncio.sleep(0.8) + await zha_gateway.async_block_till_done() + assert not entity.is_transitioning + assert bool(entity.state["on"]) is False + + 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) From 22bb3ecbbaaa557ed0e9076007bbf03801a60356 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sun, 22 Feb 2026 02:02:48 +0100 Subject: [PATCH 6/6] WIP: Combine OnOff and brightness buffer --- zha/application/platforms/light/__init__.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index a6c7798bc..797437997 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -284,7 +284,6 @@ def __init__(self, *args, **kwargs): self._transitioning_group: bool = False self._transition_listener: asyncio.TimerHandle | None = None self._transition_brightness_buffer: int | None = None - self._transition_on_off_buffer: bool | None = None self._internal_supported_color_modes: set[ColorMode] = set() @@ -689,7 +688,6 @@ def async_transition_set_flag(self) -> None: self._transitioning_individual = True self._transitioning_group = False self._transition_brightness_buffer = None - self._transition_on_off_buffer = None if isinstance(self, LightGroup): for platform_entity in self.group.get_platform_entities(Light.PLATFORM): assert isinstance(platform_entity, Light) @@ -733,18 +731,12 @@ def async_transition_complete(self, _=None) -> None: "applying buffered brightness %s from transition", self._transition_brightness_buffer, ) - self._brightness = self._transition_brightness_buffer + if self._transition_brightness_buffer == 0: + # The device is actually off; override the optimistic on-state. + self._state = False + else: + self._brightness = self._transition_brightness_buffer self._transition_brightness_buffer = None - if self._transition_on_off_buffer is not None: - self.debug( - "applying buffered on/off %s from transition", - self._transition_on_off_buffer, - ) - self._state = self._transition_on_off_buffer - if self._transition_on_off_buffer: - self._off_with_transition = False - self._off_brightness = None - self._transition_on_off_buffer = None self.maybe_emit_state_changed_event() if isinstance(self, LightGroup): for platform_entity in self.group.get_platform_entities(Light.PLATFORM): @@ -968,7 +960,8 @@ def handle_cluster_handler_attribute_updated( "received onoff %s while transitioning - buffering", event.attribute_value, ) - self._transition_on_off_buffer = bool(event.attribute_value) + if not event.attribute_value: + self._transition_brightness_buffer = 0 return self._state = bool(event.attribute_value) if event.attribute_value: