From 2f37b6ea4070e2b9b0637acc3ca2a2aab9252405 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 7 Sep 2026 09:03:03 +0200 Subject: [PATCH 1/6] Fix pending adaptations after light or profile removal (#1587) --- custom_components/adaptive_lighting/switch.py | 19 +- tests/test_switch.py | 245 ++++++++++++++++++ 2 files changed, 261 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index a22c8cb4..871dcf03 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -886,6 +886,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): assert hass is not None self.hass = hass self.manager = manager + self._removed = False self.sleep_mode_switch = sleep_mode_switch self.adapt_color_switch = adapt_color_switch self.adapt_brightness_switch = adapt_brightness_switch @@ -1078,6 +1079,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): async def async_will_remove_from_hass(self) -> None: """Remove the listeners upon removing the component.""" + self._removed = True self._remove_listeners() def _resolve_lights(self, lights: list[str] | None = None) -> list[str]: @@ -1433,6 +1435,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if not is_first_call or data.initial_sleep: await asyncio.sleep(data.sleep_time) + if self._removed: + return + # Instead of directly iterating the generator in the while-loop, we get # the next item here after the sleep to make sure it incorporates state # changes which happened during the sleep. @@ -1488,6 +1493,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): Wraps the sequence of service calls in a task that can be cancelled from elsewhere, e.g., to cancel an ongoing adaptation when a light is turned off. """ + if self._removed: + return + # Prevent overlap of multiple adaptation sequences self.manager.cancel_ongoing_adaptation_calls(data.entity_id) _LOGGER.debug( @@ -1691,7 +1699,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): await asyncio.sleep(self._adapt_delay) # Runtime settings may retire this profile's target while the event waits. - if entity_id not in self.lights: + if self._removed or entity_id not in self.lights: return await self._update_attrs_and_maybe_adapt_lights( @@ -1705,7 +1713,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self, event: Event[EventStateChangedData], ) -> None: - if not _is_state_event(event, (STATE_ON, STATE_OFF)): + new_state = event.data.get("new_state") + if new_state is None or new_state.state not in (STATE_ON, STATE_OFF): _LOGGER.debug("%s: Ignoring sleep event %s", self._name, event) return _LOGGER.debug( @@ -2730,7 +2739,7 @@ class AdaptiveLightingManager: elif state.state == STATE_OFF: # is turning on await on(eid, event) - async def state_changed_event_listener( + async def state_changed_event_listener( # noqa: PLR0912 self, event: Event[EventStateChangedData], ) -> None: @@ -2808,6 +2817,10 @@ class AdaptiveLightingManager: new_on.context.id, ) + if old_on and not new_on: + # Availability loss invalidates pending commands, not manual state. + self.cancel_ongoing_adaptation_calls(entity_id) + if old_on and new_off: # Tracks 'on' → 'off' state changes self.on_to_off_event[entity_id] = event diff --git a/tests/test_switch.py b/tests/test_switch.py index 5db7c9a5..6efe68d5 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -30,6 +30,7 @@ from homeassistant.components.adaptive_lighting.const import ( ATTR_ADAPT_BRIGHTNESS, ATTR_ADAPT_COLOR, ATTR_ADAPTIVE_LIGHTING_MANAGER, + CONF_ADAPT_DELAY, CONF_ADAPT_ONLY_ON_BARE_TURN_ON, CONF_ADAPT_UNTIL_SLEEP, CONF_AUTORESET_CONTROL, @@ -46,6 +47,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_MIN_BRIGHTNESS, CONF_MIN_COLOR_TEMP, CONF_MULTI_LIGHT_INTERCEPT, + CONF_ONLY_ONCE, CONF_PREFER_RGB_COLOR, CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE, CONF_SEND_SPLIT_DELAY, @@ -119,6 +121,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, EntityCategory, ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, State @@ -5988,3 +5991,245 @@ async def test_shared_profiles_keep_independent_sun_schedules( noon = hass.states.get(ENTITY_LIGHT_1) assert noon.attributes[ATTR_BRIGHTNESS] == 77 assert noon.attributes[ATTR_COLOR_TEMP_KELVIN] > 2000 + + +@pytest.mark.parametrize("via_unavailable", [False, True]) +async def test_split_adaptation_cancelled_after_physical_off( + hass, + monkeypatch, + via_unavailable, +): + """Pending split commands must not resurrect a physically switched-off light.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: True, + CONF_ONLY_ONCE: True, + CONF_SEPARATE_TURN_ON_COMMANDS: True, + CONF_SEND_SPLIT_DELAY: 1234, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + state = hass.states.get(ENTITY_LIGHT_1) + hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes) + await hass.async_block_till_done() + # Isolate the split-command lifetime from the separate turn-off debounce. + monkeypatch.setattr( + switch.manager, + "just_turned_off", + AsyncMock(return_value=False), + ) + entered, release = asyncio.Event(), asyncio.Event() + original_sleep = asyncio.sleep + + async def controlled_sleep(delay, *args, **kwargs): + if delay == 1.234: + entered.set() + await release.wait() + else: + await original_sleep(delay, *args, **kwargs) + + monkeypatch.setattr(asyncio, "sleep", controlled_sleep) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, state.attributes) + await asyncio.wait_for(entered.wait(), 2) + assert len(calls) == 1 + if via_unavailable: + hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, state.attributes) + await original_sleep(0) + hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes) + await original_sleep(0) + release.set() + await hass.async_block_till_done() + assert len(calls) == 1, f"Physical OFF resurrected by split command: {calls}" + assert hass.states.get(ENTITY_LIGHT_1).state == STATE_OFF + + +@pytest.mark.parametrize("remaining_profile", [False, True]) +async def test_profile_unloaded_during_adapt_delay( + hass, + monkeypatch, + remaining_profile, +): + """A removed profile must not send commands after its adaptation delay.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: True, + CONF_ONLY_ONCE: True, + CONF_ADAPT_DELAY: 0.1234, + }, + ) + if remaining_profile: + _, other = await setup_switch( + hass, + { + CONF_NAME: "remaining", + CONF_LIGHTS: [ENTITY_LIGHT_1], + CONF_ONLY_ONCE: True, + CONF_INITIAL_TRANSITION: 0, + }, + ) + await other.async_turn_off() + state = hass.states.get(ENTITY_LIGHT_1) + hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes) + await hass.async_block_till_done() + monkeypatch.setattr( + switch.manager, + "just_turned_off", + AsyncMock(return_value=False), + ) + entered, release = asyncio.Event(), asyncio.Event() + original_sleep = asyncio.sleep + + async def controlled_sleep(delay, *args, **kwargs): + if delay == 0.1234: + entered.set() + await release.wait() + else: + await original_sleep(delay, *args, **kwargs) + + monkeypatch.setattr(asyncio, "sleep", controlled_sleep) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, state.attributes) + await asyncio.wait_for(entered.wait(), 2) + entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(entry.entry_id) + calls.clear() + release.set() + await hass.async_block_till_done() + assert calls == [] + if remaining_profile: + await other.async_turn_on() + await other._update_attrs_and_maybe_adapt_lights( + context=other.create_context("test"), + lights=[ENTITY_LIGHT_1], + force=True, + ) + await hass.async_block_till_done() + assert calls + assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON + + +async def test_profile_unloaded_during_split_delay(hass, monkeypatch): + """Removed profiles must not send remaining split commands.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: True, + CONF_ONLY_ONCE: True, + CONF_SEPARATE_TURN_ON_COMMANDS: True, + CONF_SEND_SPLIT_DELAY: 1234, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + state = hass.states.get(ENTITY_LIGHT_1) + hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes) + await hass.async_block_till_done() + # Isolate the split-command lifetime from the separate turn-off debounce. + monkeypatch.setattr( + switch.manager, + "just_turned_off", + AsyncMock(return_value=False), + ) + entered, release = asyncio.Event(), asyncio.Event() + original_sleep = asyncio.sleep + + async def controlled_sleep(delay, *args, **kwargs): + if delay == 1.234: + entered.set() + await release.wait() + else: + await original_sleep(delay, *args, **kwargs) + + monkeypatch.setattr(asyncio, "sleep", controlled_sleep) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, state.attributes) + await asyncio.wait_for(entered.wait(), 2) + assert len(calls) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert await hass.config_entries.async_unload(entry.entry_id) + release.set() + await hass.async_block_till_done() + assert len(calls) == 1 + assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON + + +@pytest.mark.parametrize("unload_before_split", [False, True]) +async def test_unloaded_polling_profile_preserves_other_split_adaptation( + hass, + monkeypatch, + unload_before_split, +): + """A removed profile resuming a poll must not cancel another profile's work.""" + switch, _ = await setup_lights_and_switch(hass, {CONF_ONLY_ONCE: True}) + _, other = await setup_switch( + hass, + { + CONF_NAME: "remaining", + CONF_LIGHTS: [ENTITY_LIGHT_1], + CONF_ONLY_ONCE: True, + CONF_SEPARATE_TURN_ON_COMMANDS: True, + CONF_SEND_SPLIT_DELAY: 1234, + CONF_INITIAL_TRANSITION: 0, + }, + ) + poll_entered, poll_release = asyncio.Event(), asyncio.Event() + split_entered, split_release = asyncio.Event(), asyncio.Event() + original_update = switch.manager.update_manually_controlled_from_untracked_change + original_sleep = asyncio.sleep + + async def delayed_update(profile, *args, **kwargs): + if profile is switch: + poll_entered.set() + await poll_release.wait() + await original_update(profile, *args, **kwargs) + + async def controlled_sleep(delay, *args, **kwargs): + if delay == 1.234: + split_entered.set() + await split_release.wait() + else: + await original_sleep(delay, *args, **kwargs) + + monkeypatch.setattr( + switch.manager, + "update_manually_controlled_from_untracked_change", + delayed_update, + ) + monkeypatch.setattr(asyncio, "sleep", controlled_sleep) + calls = _track_adaptive_light_calls(hass) + polling = hass.async_create_task( + switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("test"), + lights=[ENTITY_LIGHT_1], + force=True, + ), + ) + await asyncio.wait_for(poll_entered.wait(), 2) + entry = hass.config_entries.async_entries(DOMAIN)[0] + if unload_before_split: + assert await hass.config_entries.async_unload(entry.entry_id) + adapting = hass.async_create_task( + other._adapt_light( + ENTITY_LIGHT_1, + other.create_context("test"), + 0, + force=True, + ), + ) + await asyncio.wait_for(split_entered.wait(), 2) + assert len(calls) == 1 + if not unload_before_split: + assert await hass.config_entries.async_unload(entry.entry_id) + poll_release.set() + await polling + split_release.set() + await adapting + await hass.async_block_till_done() + assert len(calls) == 2 + assert ATTR_COLOR_TEMP_KELVIN in calls[-1] From 7c445af63bf2684f1b21d1fcb8094fa1f59088aa Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:54:05 +0200 Subject: [PATCH 2/6] docs: add ahmadtawakol as a contributor for code, bug, and maintenance (#1595) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 11 +++++++++++ README.md | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 6004155e..fbff14b8 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1568,6 +1568,17 @@ "contributions": [ "ideas" ] + }, + { + "login": "ahmadtawakol", + "name": "Ahmad Tawakol", + "avatar_url": "https://avatars.githubusercontent.com/u/2355493?v=4", + "profile": "https://github.com/ahmadtawakol", + "contributions": [ + "code", + "bug", + "maintenance" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 0736b56a..89685620 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) ![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge) -[![All Contributors](https://img.shields.io/badge/all_contributors-172-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-173-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -1118,6 +1118,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Leonhard Hesse
Leonhard Hesse

💻 Tim Stallmann
Tim Stallmann

💻 lehneres
lehneres

🤔 + Ahmad Tawakol
Ahmad Tawakol

💻 🐛 🚧 From 51ea83dba3bc1de7a3a36736de6e8ff34ccc5284 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:02:08 +0200 Subject: [PATCH 3/6] [pre-commit.ci] pre-commit autoupdate (#1592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.16.5 → v0.16.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.5...v0.16.6) * test: avoid mired rounding boundary --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- .pre-commit-config.yaml | 2 +- tests/test_switch.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53b84021..80893d1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: files: ^README[^/]*\.md$ args: ["--notitle"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.5 + rev: v0.16.6 hooks: - id: ruff args: ["--fix"] diff --git a/tests/test_switch.py b/tests/test_switch.py index 6efe68d5..65fb5c32 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1537,8 +1537,10 @@ async def test_apply_updates_non_ha_change_baseline( ) direction = 1 if manual_value < adaptive_value else -1 + # Legacy template lights round via mireds; 70 K keeps one reported step + # below 100 K and two steps above it across the configured range. small_change = ( - 15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 60 + 15 if manual_attribute == LightControlAttributes.BRIGHTNESS else 70 ) freezer.tick(90) set_physical_state(manual_value + direction * small_change) From 3e78ac7e212cc215b01ceea29abbd8bfe63f6c62 Mon Sep 17 00:00:00 2001 From: Ahmad Tawakol <2355493+ahmadtawakol@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:02:45 -0300 Subject: [PATCH 4/6] Make scripts/setup-symlinks idempotent (#1590) `ln -fs` dereferences an existing symlink to a directory and creates the new link *inside* it, so running the script a second time left two stray symlinks in the working tree instead of replacing the existing ones: tests/tests -> ../../../tests/ custom_components/adaptive_lighting/adaptive_lighting -> ../../../custom_components/adaptive_lighting Neither path is gitignored, so `git add -A` commits them. Add `-n` so an existing symlink is treated as a file and replaced. Co-authored-by: Claude Opus 5 --- scripts/setup-symlinks | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/setup-symlinks b/scripts/setup-symlinks index 91026b3a..af95b831 100755 --- a/scripts/setup-symlinks +++ b/scripts/setup-symlinks @@ -2,12 +2,17 @@ set -ex cd "$(dirname "$0")/.." +# '-n' keeps a re-run idempotent: without it 'ln -fs' follows an existing +# symlink and creates the new link *inside* the target directory, leaving a +# stray 'tests/tests' and 'custom_components/adaptive_lighting/adaptive_lighting' +# in the working tree. + # Link custom components cd core/homeassistant/components/ -ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting +ln -fsn ../../../custom_components/adaptive_lighting adaptive_lighting cd - # Link tests cd core/tests/components/ -ln -fs ../../../tests/ adaptive_lighting +ln -fsn ../../../tests/ adaptive_lighting cd - From da749bcf6153d0537bc66fb18f16728f6acdf3b5 Mon Sep 17 00:00:00 2001 From: Ahmad Tawakol <2355493+ahmadtawakol@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:02:51 -0300 Subject: [PATCH 5/6] Add a .dockerignore (#1591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/README.md has developers clone Home Assistant core into ./core, but Docker does not read .gitignore, so `COPY . /app/` shipped that ~300MB checkout into the build context and into the image on every build. It also changed what the build did. With /app/core already present as a real directory, `ln -s /core /app/core` linked *inside* it — leaving a stray /app/core/core -> /core — and scripts/setup-dependencies then installed from the copied host checkout rather than the image's own pinned clone. Excluding core/ (plus local virtualenvs, VCS state and caches) takes the build context from 412MB to 4.6MB and the image from 2.34GB to 2.1GB, and makes a build with a local ./core behave like a clean one: /app/core is the intended symlink to /core. This does remove an accident. An image built while a local ./core existed happened to run without `-v $(pwd):/app`, because the copied checkout carried relative symlinks that still resolved inside /app. A clean-checkout build never had that property — there the symlinks setup-symlinks writes into /core dangle — and tests/README.md requires the mount either way. 479 passed, unchanged. Co-authored-by: Claude Opus 5 --- .dockerignore | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..767b6084 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# The Home Assistant core checkout. tests/README.md has you clone it to ./core, +# but the Dockerfile clones its own copy to /core and links /app/core to it. +# Without this entry `COPY . /app/` ships ~300MB into every build and leaves +# /app/core as a real directory, so `ln -s /core /app/core` links *inside* it +# rather than creating the intended symlink. +core/ + +# Local virtualenvs +.venv/ +venv/ +env/ +ENV/ + +# Not used by the build +.git/ +.vscode/ +.idea/ + +# Caches and test output +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +htmlcov/ +.coverage +.coverage.* +coverage.xml From 7d0f4b610acb5088125aff266ba3d9af10164b2f Mon Sep 17 00:00:00 2001 From: Ahmad Tawakol <2355493+ahmadtawakol@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:03:07 -0300 Subject: [PATCH 6/6] Fix TypeError when 'light.turn_off' is called with a string transition (#1589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix TypeError when 'light.turn_off' is called with a string transition `EVENT_CALL_SERVICE` carries the *raw* service data, not the data `light.turn_off`'s schema produced for the service handler, so its `vol.Coerce(float)` never reaches `AdaptiveLightingManager`. A caller passing `transition: "2"` — a template rendering to a string, or any JSON payload where the value was quoted — therefore stores a `str` in `turn_off_event`. Both places that derive a delay from it compare it against an int: delay = max(transition or 0, TURNING_OFF_DELAY) # during turn-off delay = max(transition, TURNING_OFF_DELAY) # just_turned_off which raises `TypeError: '>' not supported between instances of 'int' and 'str'`. Because `just_turned_off` runs inside the state-change listener task, the exception is swallowed: it surfaces only as "Error doing job: Task exception was never retrieved (task: None)", while the light quietly stops being adapted after that turn-off. Read the transition through a helper that coerces to float. Schema validation runs before the event fires, so whatever reaches the helper is coercible. Co-Authored-By: Claude Opus 5 * Normalize turn-off transitions with the light service validator --------- Co-authored-by: Claude Opus 5 Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 17 ++- tests/test_switch.py | 119 +++++++++++++++++- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 871dcf03..db1c2d0b 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -20,6 +20,7 @@ from homeassistant.components.light import ( ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_XY_COLOR, + VALID_TRANSITION, ColorMode, LightEntityFeature, is_on, @@ -622,6 +623,18 @@ def _is_state_event( ) +def _turn_off_transition(turn_off_event: Event) -> float | None: + """Normalize the raw event transition using the light service's validator. + + Service-call events retain raw data after validation, so repeat the + service's coercion and clamping before calculating transition windows. + """ + transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + if transition is None: + return None + return VALID_TRANSITION(transition) + + def _expand_light_groups( hass: HomeAssistant, lights: list[str], @@ -3109,7 +3122,7 @@ class AdaptiveLightingManager: ): return False - transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + transition = _turn_off_transition(turn_off_event) delay = max(transition or 0, TURNING_OFF_DELAY) elapsed = (dt_util.utcnow() - turn_off_event.time_fired).total_seconds() if not 0 <= elapsed <= delay: @@ -3193,7 +3206,7 @@ class AdaptiveLightingManager: turn_off_event = self.turn_off_event.get(entity_id) if turn_off_event is not None: - transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + transition = _turn_off_transition(turn_off_event) else: transition = None diff --git a/tests/test_switch.py b/tests/test_switch.py index 65fb5c32..a3f5b689 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -83,6 +83,7 @@ from homeassistant.components.adaptive_lighting.switch import ( SimpleSwitch, _attributes_have_changed, _expand_light_groups, + _turn_off_transition, color_difference_redmean, create_context, is_our_context, @@ -112,6 +113,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FLOOR_ID, ATTR_LABEL_ID, + ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, CONF_LIGHTS, CONF_NAME, @@ -4262,17 +4264,17 @@ def _turn_off_service_event( entity_ids: list[str], ts: float, context: Context, - transition: float, + transition: float | str | None, ) -> Event: + service_data = {ATTR_ENTITY_ID: entity_ids} + if transition is not None: + service_data[ATTR_TRANSITION] = transition return Event( EVENT_CALL_SERVICE, { "domain": LIGHT_DOMAIN, "service": SERVICE_TURN_OFF, - "service_data": { - ATTR_ENTITY_ID: entity_ids, - ATTR_TRANSITION: transition, - }, + "service_data": service_data, }, time_fired_timestamp=ts, context=context, @@ -4566,6 +4568,113 @@ async def test_just_turned_off_same_automation_context(hass, cleanup): assert not await manager.just_turned_off(ENTITY_LIGHT_1) +@pytest.mark.parametrize( + ("transition", "window"), + [(10, 10), (10.0, 10), ("10", 10), ("10000", 6553), ("inf", 6553), (None, 5)], +) +async def test_just_turned_off_normalized_transition(hass, cleanup, transition, window): + """Both turn-off guards use coerced and clamped transition windows.""" + await setup_lights(hass) + _, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]}) + await hass.async_block_till_done() + manager = switch.manager + + now = dt_util.utcnow().timestamp() + context = Context() + other_context = Context() + + # Setting up the switch turns the light on, and that 'turn_on' would be read + # as the legitimate explanation for the 'off' → 'on' state changes below. + manager.turn_on_event.pop(ENTITY_LIGHT_1, None) + + def set_events(turn_off_ts: float, off_to_on_context: Context) -> None: + manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event( + [ENTITY_LIGHT_1], + turn_off_ts, + context, + transition=transition, + ) + manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event( + ENTITY_LIGHT_1, + turn_off_ts, + other_context, + ) + manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event( + ENTITY_LIGHT_1, + now, + off_to_on_context, + ) + + # A matching context is ignored within the normalized transition window. + set_events(now - window + 1, context) + assert await manager.just_turned_off(ENTITY_LIGHT_1) + + # Past that window the same shape must stop matching. + set_events(now - window - 1, context) + assert not await manager.just_turned_off(ENTITY_LIGHT_1) + + # `just_turned_off`'s own `max(transition, TURNING_OFF_DELAY)`: reached when + # the 'off' → 'on' state change carries a fresh context, so the check above + # returns early and the delay is computed from the 'on' → 'off' change. + manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event( + [ENTITY_LIGHT_1], + now - window - 1, + context, + transition=transition, + ) + manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event( + ENTITY_LIGHT_1, + now - window - 1, + context, + ) + manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event( + ENTITY_LIGHT_1, + now, + Context(), + ) + assert not await manager.just_turned_off(ENTITY_LIGHT_1) + + +@pytest.mark.parametrize( + ("transition", "expected"), + [("2", 2.0), ("10000", 6553), ("inf", 6553), ("-2", 0), (None, None)], +) +async def test_turn_off_event_keeps_raw_transition(hass, cleanup, transition, expected): + """Normalize raw event data to the same transition used by the light service.""" + await setup_lights(hass) + _, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]}) + await hass.async_block_till_done() + manager = switch.manager + + service_data = {ATTR_ENTITY_ID: ENTITY_LIGHT_1} + if transition is not None: + service_data[ATTR_TRANSITION] = transition + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + service_data, + blocking=True, + ) + await hass.async_block_till_done() + + event = manager.turn_off_event[ENTITY_LIGHT_1] + assert event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) == transition + assert _turn_off_transition(event) == expected + + # A 'transition' that cannot be coerced is rejected by the schema, so it + # never reaches the listener. + manager.turn_off_event.pop(ENTITY_LIGHT_1) + with pytest.raises(voluptuous.error.MultipleInvalid): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_TRANSITION: "not-a-number"}, + blocking=True, + ) + await hass.async_block_till_done() + assert ENTITY_LIGHT_1 not in manager.turn_off_event + + async def test_just_turned_off_group_context_reuse_end_to_end(hass, cleanup): """A tracked member turn-on explains a group's reused OFF context (#1378).""" await setup_lights(hass, with_group=True)