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 1/5] 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 2/5] [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 3/5] 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 4/5] 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 5/5] 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)