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 @@
[](https://github.com/hacs/integration)

-[](#contributors-)
+[](#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 💻 |
 Tim Stallmann 💻 |
 lehneres 🤔 |
+  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)