From c620d1480a73896b3f3f2d2aa12acf6c1ebec124 Mon Sep 17 00:00:00 2001 From: Dennis Dekker <48018095+Dennis-Dekker@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:01:50 +0200 Subject: [PATCH] fix: re-adapt lights right after the auto-reset timer fires (#1506) When the auto-reset timer fired, its callback called manager.reset(), which pops the timer and calls timer.cancel() - cancelling the very task that was running the callback. The manual_control flag was cleared, but the re-adaptation that should follow was silently cancelled, so the light only changed on the next interval pass (with the normal transition). _AsyncSingleShotTimer.cancel() now never cancels the task that is currently running its own callback. The trailing assert in the callback is removed because it is reachable now and could trip when a new manual change comes in while the re-adaptation is still running. The existing auto-reset test also checks that a light.turn_on with the 'autoreset' context is sent. Fixes #1233 Co-authored-by: Dennis-Dekker <> Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 13 ++++++++++--- tests/test_switch.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5db91823..696ae1a2 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -2266,7 +2266,6 @@ class AdaptiveLightingManager: transition=switch.initial_transition, force=True, ) - assert self.manual_control[light] == LightControlAttributes.NONE self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset) @@ -3007,9 +3006,17 @@ class _AsyncSingleShotTimer: def cancel(self) -> None: """Cancel the timer.""" - if self.task: + # Never cancel the task that is currently running our own callback, e.g. + # when the auto-reset callback calls manager.reset(), which cancels the + # timer it is running in. That used to silently cancel the rest of the + # callback (the re-adaptation), see issue #1233. + try: + current_task = asyncio.current_task() + except RuntimeError: # no running event loop + current_task = None + if self.task and self.task is not current_task: self.task.cancel() - self.callback = None + self.callback = None def remaining_time(self) -> float: """Return the remaining time before the timer expires.""" diff --git a/tests/test_switch.py b/tests/test_switch.py index ce9d8ec8..a48800f2 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -914,11 +914,27 @@ async def test_auto_reset_manual_control(hass): switch.extra_state_attributes["autoreset_time_remaining"][light.entity_id] > 0 ) await update() + # The auto reset must also re-adapt the light right away, not only clear the + # flag. Collect the 'light.turn_on' calls made with the 'autoreset' context. + autoreset_calls: list[Event] = [] + + async def _on_call_service(event: Event) -> None: + if ( + event.data.get("domain") == LIGHT_DOMAIN + and event.data.get("service") == SERVICE_TURN_ON + and is_our_context(event.context, "autoreset") + ): + autoreset_calls.append(event) + + remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, _on_call_service) await asyncio.sleep(0.3) # Should be enough time for auto reset + await hass.async_block_till_done() + remove_listener() assert not manual_control[light.entity_id], (light, manual_control) assert ( light.entity_id not in switch.extra_state_attributes["autoreset_time_remaining"] ) + assert autoreset_calls, "auto reset did not re-adapt the light" # Do a couple of quick changes and check that light is not reset for i in range(3):