From 0a305a7a4c96d94edaf60641a0659eed906a1326 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:59:38 -0700 Subject: [PATCH 01/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20docker/buil?= =?UTF-8?q?d-push-action=20action=20to=20v7=20(#1441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index edea4bb5..190a87dc 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -39,7 +39,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@v7 with: context: . platforms: ${{ matrix.platform }} From 31762ba78d35c216908763306b032362448713ae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:00:10 -0700 Subject: [PATCH 02/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20docker/meta?= =?UTF-8?q?data-action=20action=20to=20v6=20(#1440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 190a87dc..92c94a03 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -30,7 +30,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | From 86091de0e7aae6b8ecca4707a8d3f148161bfad4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:00:21 -0700 Subject: [PATCH 03/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20docker/logi?= =?UTF-8?q?n-action=20action=20to=20v4=20(#1437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 92c94a03..d5c92c85 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v6 - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From c8c31be573d122a3ec6c30cae1299e4d14e7041f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:12:01 -0700 Subject: [PATCH 04/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20actions/upl?= =?UTF-8?q?oad-pages-artifact=20action=20to=20v5=20(#1467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7eb1e082..64a24264 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -47,7 +47,7 @@ jobs: echo "Webapp integrated at site/simulator/" - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: ./site From b787c533bfdfd5fc1dad224a72ae63cc165dcde1 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 24 Apr 2026 22:12:20 +0200 Subject: [PATCH 05/35] fix: make setup-dependencies portable on macOS (#1463) Two issues prevented the script from running on macOS without manual intervention: 1. `sed -i` requires a backup suffix on macOS BSD sed. Replace with a portable `grep -v | mv` idiom that works on both macOS and Linux. 2. `python` is not in PATH on macOS by default. Replace with `python3`. Co-authored-by: Florian Horner Co-authored-by: Bas Nijholt --- scripts/setup-dependencies | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/setup-dependencies b/scripts/setup-dependencies index d69bce3c..f3948dd5 100755 --- a/scripts/setup-dependencies +++ b/scripts/setup-dependencies @@ -5,7 +5,7 @@ cd "$(dirname "$0")/.." # Remove mypy-dev from requirements_test.txt since the maintainer deletes old versions from PyPI. # We'll install the latest version separately below. # See: https://github.com/cdce8p/mypy-dev/issues/62 -sed -i '/^mypy-dev/d' core/requirements_test.txt +grep -v '^mypy-dev' core/requirements_test.txt > core/requirements_test.txt.tmp && mv core/requirements_test.txt.tmp core/requirements_test.txt uv pip install -r core/requirements.txt uv pip install -r core/requirements_test.txt @@ -35,7 +35,7 @@ done uv pip install -e core/ uv pip install ulid-transform # this is in Adaptive-lighting's manifest.json -uv pip install $(python test_dependencies.py) +uv pip install $(python3 test_dependencies.py) # Install the latest mypy-dev (not pinned since old versions get deleted from PyPI) uv pip install --upgrade mypy-dev From 17e43e042746e42f011c22a0480eaf809c5ec477 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:13:15 -0700 Subject: [PATCH 06/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20astral-sh/s?= =?UTF-8?q?etup-uv=20action=20to=20v8=20(#1468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- .github/workflows/install_dependencies/action.yml | 2 +- .github/workflows/markdown-code-runner.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 64a24264..86367ad7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: python-version: '3.14.2' - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 - name: Install dependencies run: uv sync --group docs diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index 4ab58169..910e5b35 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -32,7 +32,7 @@ runs: with: python-version: ${{ inputs.python-version }} - name: Set up UV - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 - name: Install dependencies shell: bash run: | diff --git a/.github/workflows/markdown-code-runner.yml b/.github/workflows/markdown-code-runner.yml index efd76831..4ea1bb81 100644 --- a/.github/workflows/markdown-code-runner.yml +++ b/.github/workflows/markdown-code-runner.yml @@ -23,7 +23,7 @@ jobs: python-version: "3.14.2" - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8.1.0 - name: Update generated content run: ./scripts/update-generated-content From cb4eb7ef491cb895f4f7297c583e6cdd76696289 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:13:34 -0700 Subject: [PATCH 07/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20actions/dep?= =?UTF-8?q?loy-pages=20action=20to=20v5=20(#1453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 86367ad7..d359b54a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -61,4 +61,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 7dcad99d81082a573894ff715f637fc74884c2db Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:13:53 -0700 Subject: [PATCH 08/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20docker/setu?= =?UTF-8?q?p-buildx-action=20action=20to=20v4=20(#1439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d5c92c85..da5c74ef 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@v6 - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} From 830c5589f7076ef5ef094a0fcc2996bbf36ba5cc Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 24 Apr 2026 22:30:18 +0200 Subject: [PATCH 09/35] fix: reduce log verbosity for self-triggered off-to-on warning (#1433) (#1434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: reduce log verbosity for self-triggered off-to-on warning (#1433) Move full event object dump from warning to debug level in _off_to_on_state_event_is_from_turn_on() (switch.py:2717). For lights with large effect_list attributes (e.g. Govee lights with 130+ effects), the warning dumped the entire Event object including both old_state and new_state, creating log entries thousands of characters long. The warning now logs only entity_id and context.id, while the full event remains available at debug level for troubleshooting. Fixes #1433 * fix: correct indentation for _LOGGER.debug block * fix: correct indentation for _LOGGER.warning and _LOGGER.debug * fix: remove unintended encoding corruption, keep only log level change Reset switch.py to main and re-apply only the intended change: move the full event object from warning to debug level in _off_to_on_state_event_is_from_turn_on(). Addresses reviewer feedback about unintended Unicode corruption (→ and ≈ characters were corrupted to mojibake). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Florian Horner Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 91049970..b57aeaa7 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -2718,12 +2718,16 @@ class AdaptiveLightingManager: "service", # adaptive_lighting.apply is allowed to turn on lights ): _LOGGER.warning( - "Detected an 'off' → 'on' event for '%s' with context.id='%s' and" - " event='%s', triggered by the adaptive_lighting integration itself," + "Detected an 'off' → 'on' event for '%s' with context.id='%s'," + " triggered by the adaptive_lighting integration itself," " which *should* not happen. If you see this please submit an issue with" " your full logs at https://github.com/basnijholt/adaptive-lighting", entity_id, off_to_on_event.context.id, + ) + _LOGGER.debug( + "Full 'off' → 'on' event for '%s': %s", + entity_id, off_to_on_event, ) turn_on_event: Event | None = self.turn_on_event.get(entity_id) From 89b5e9a14fc26c9cfdc5f56dadaa9deb964cacdc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:23:26 -0700 Subject: [PATCH 10/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20docker/setu?= =?UTF-8?q?p-qemu-action=20action=20to=20v4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge Renovate GitHub Actions update. Validation: - GitHub Actions checks for PR #1438 passed before merge. --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index da5c74ef..00e8d89a 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -22,7 +22,7 @@ jobs: platform: [linux/amd64, linux/arm64] steps: - uses: actions/checkout@v6 - - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: From ddaf851be3d47320762a85affb9996d40f1ff9a3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:55:04 -0700 Subject: [PATCH 11/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20release-dra?= =?UTF-8?q?fter/release-drafter=20action=20to=20v7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge Renovate GitHub Actions update. Validation: - Fixed PR-time Release Drafter v7 execution by using dry-run for pull_request events. - GitHub Actions checks for PR #1445 passed before merge. --- .github/workflows/release-drafter.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index e3badf0f..973577d1 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -17,6 +17,8 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@v6 + - uses: release-drafter/release-drafter@v7 + with: + dry-run: ${{ github.event_name == 'pull_request' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From d4d3d50ada813d60b61d240c882f7bdab935755d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 1 Jul 2026 23:00:35 -0700 Subject: [PATCH 12/35] fix: replace deprecated `get_astral_location` with `get_astral_observer` (#1482) * fix: replace deprecated get_astral_location with get_astral_observer (#1481) HA 2026.7 deprecates homeassistant.helpers.sun.get_astral_location (removal planned for 2027.7) in favor of get_astral_observer, causing a deprecation warning in the HA logs. - Switch SunEvents/SunLightSettings from astral.location.Location to astral.Observer, using the astral.sun module functions (which return UTC times by default, matching the previous local=False calls). - Use get_astral_observer in switch.py, with a fallback for HA < 2026.7 that constructs the Observer directly from the HA config. - Update tests and the webapp simulator accordingly. * ci: handle removal of requirements_test_all.txt in HA 2026.8 dev HA core removed requirements_test_all.txt (home-assistant/core#171530), which made test_dependencies.py crash with FileNotFoundError and broke the dev pytest job and the Docker builds. Fall back to requirements_all.txt, which carries the same per-integration '# homeassistant.components.x' annotations. Also extend the aiohasupervisor pin lookup in scripts/setup-dependencies accordingly. * test: support modern template light config for HA 2026.6+ HA 2026.6 removed the legacy `light: platform: template` YAML format (home-assistant/core#169615), so setup_lights found no template platform on HA dev and every test using it failed with IndexError. Detect legacy support at runtime (PLATFORM_SCHEMA presence) and fall back to the modern `template:` config format. The group platform is set up before the template integration in the modern path, because setting up `template` also sets up the `light` domain, which would make a later async_setup_component(hass, LIGHT_DOMAIN, ...) a no-op. --- .../adaptive_lighting/color_and_brightness.py | 20 ++-- custom_components/adaptive_lighting/switch.py | 18 +++- scripts/setup-dependencies | 4 + test_dependencies.py | 4 + tests/test_color_and_brightness.py | 18 ++-- tests/test_switch.py | 89 ++++++++++++------ webapp/app.py | 8 +- webapp/color_and_brightness.py | 94 +++++++++++-------- 8 files changed, 160 insertions(+), 95 deletions(-) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index f3ae3efe..c91278fc 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -11,17 +11,15 @@ from dataclasses import dataclass from datetime import UTC, timedelta from enum import Enum from functools import cached_property, partial -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import Any, Literal, cast +import astral.sun from homeassistant.util.color import ( color_RGB_to_xy, color_temperature_to_rgb, color_xy_to_hs, ) -if TYPE_CHECKING: - import astral.location - class SunEvent(str, Enum): """A set of sun events that happen during a day.""" @@ -48,7 +46,7 @@ class SunEvents: """Track the state of the sun and associated light settings.""" name: str - astral_location: astral.location.Location + astral_observer: astral.Observer sunrise_time: datetime.time | None min_sunrise_time: datetime.time | None max_sunrise_time: datetime.time | None @@ -62,7 +60,7 @@ class SunEvents: def sunrise(self, dt: datetime.date) -> datetime.datetime: """Return the (adjusted) sunrise time for the given datetime.""" sunrise = ( - self.astral_location.sunrise(dt, local=False) + astral.sun.sunrise(self.astral_observer, dt) if self.sunrise_time is None else self._replace_time(dt, self.sunrise_time) ) + self.sunrise_offset @@ -77,7 +75,7 @@ class SunEvents: def sunset(self, dt: datetime.date) -> datetime.datetime: """Return the (adjusted) sunset time for the given datetime.""" sunset = ( - self.astral_location.sunset(dt, local=False) + astral.sun.sunset(self.astral_observer, dt) if self.sunset_time is None else self._replace_time(dt, self.sunset_time) ) + self.sunset_offset @@ -113,8 +111,8 @@ class SunEvents: and self.min_sunset_time is None and self.max_sunset_time is None ): - solar_noon = self.astral_location.noon(dt, local=False) - solar_midnight = self.astral_location.midnight(dt, local=False) + solar_noon = astral.sun.noon(self.astral_observer, dt) + solar_midnight = astral.sun.midnight(self.astral_observer, dt) return solar_noon, solar_midnight if sunset is None: @@ -208,7 +206,7 @@ class SunLightSettings: """Track the state of the sun and associated light settings.""" name: str - astral_location: astral.location.Location + astral_observer: astral.Observer adapt_until_sleep: bool max_brightness: int max_color_temp: int @@ -236,7 +234,7 @@ class SunLightSettings: """Return the SunEvents object.""" return SunEvents( name=self.name, - astral_location=self.astral_location, + astral_observer=self.astral_observer, sunrise_time=self.sunrise_time, sunrise_offset=self.sunrise_offset, min_sunrise_time=self.min_sunrise_time, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index b57aeaa7..e80e6089 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -66,7 +66,6 @@ from homeassistant.helpers.event import ( async_track_time_interval, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.sun import get_astral_location from homeassistant.util import slugify from homeassistant.util.color import ( color_temperature_to_rgb, @@ -164,6 +163,19 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import NoEventData, VolDictType +try: + from homeassistant.helpers.sun import get_astral_observer +except ImportError: # `get_astral_observer` was added in HA 2026.7 + from astral import Observer + + def get_astral_observer(hass: HomeAssistant) -> Observer: + """Get an astral observer for the current HA configuration.""" + return Observer( + hass.config.latitude, + hass.config.longitude, + hass.config.elevation, + ) + _LOGGER = logging.getLogger(__name__) @@ -944,11 +956,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) self._multi_light_intercept = False self._expand_light_groups() # updates manual control timers - location, _ = get_astral_location(self.hass) + observer = get_astral_observer(self.hass) self._sun_light_settings = SunLightSettings( name=self._name, - astral_location=location, + astral_observer=observer, adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP], max_brightness=data[CONF_MAX_BRIGHTNESS], max_color_temp=data[CONF_MAX_COLOR_TEMP], diff --git a/scripts/setup-dependencies b/scripts/setup-dependencies index f3948dd5..0a96dd77 100755 --- a/scripts/setup-dependencies +++ b/scripts/setup-dependencies @@ -12,9 +12,13 @@ uv pip install -r core/requirements_test.txt # HA 2026.4+ imports aiohasupervisor from tests/components/conftest.py # but pins it in requirements_test_all.txt instead of requirements_test.txt. +# HA 2026.8+ removed requirements_test_all.txt (home-assistant/core#171530); +# the pin lives in requirements_all.txt there. aiohasupervisor_req="" if [[ -f core/requirements_test_all.txt ]]; then aiohasupervisor_req="$(grep -m1 '^aiohasupervisor' core/requirements_test_all.txt || true)" +elif [[ -f core/requirements_all.txt ]]; then + aiohasupervisor_req="$(grep -m1 '^aiohasupervisor' core/requirements_all.txt || true)" fi if [[ -n "${aiohasupervisor_req}" ]]; then uv pip install "${aiohasupervisor_req}" diff --git a/test_dependencies.py b/test_dependencies.py index b92ff1fb..029e1beb 100644 --- a/test_dependencies.py +++ b/test_dependencies.py @@ -7,6 +7,10 @@ deps = defaultdict(list) components, packages = [], [] requirements = Path("core") / "requirements_test_all.txt" +if not requirements.exists(): + # Removed from HA core in 2026.8 (home-assistant/core#171530); the same + # per-integration annotations live in requirements_all.txt. + requirements = Path("core") / "requirements_all.txt" with requirements.open() as f: lines = f.readlines() diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index cc3e8c4d..32425055 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -9,7 +9,7 @@ from homeassistant.components.adaptive_lighting.color_and_brightness import ( SunEvents, ) -# Create a mock astral_location object +# Create a mock astral location object (its `.observer` is passed to `SunEvents`) location = Location(LocationInfo()) LAT_LONG_TZS = [ @@ -40,7 +40,7 @@ def test_replace_time(tzinfo_and_location): tzinfo, location = tzinfo_and_location sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=None, min_sunrise_time=None, max_sunrise_time=None, @@ -61,7 +61,7 @@ def test_sunrise_without_offset(tzinfo_and_location): sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=None, min_sunrise_time=None, max_sunrise_time=None, @@ -79,7 +79,7 @@ def test_sun_position_no_fixed_sunset_and_sunrise(tzinfo_and_location): tzinfo, location = tzinfo_and_location sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=None, min_sunrise_time=None, max_sunrise_time=None, @@ -107,7 +107,7 @@ def test_sun_position_fixed_sunset_and_sunrise(tzinfo_and_location): tzinfo, location = tzinfo_and_location sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=dt.time(6, 0), min_sunrise_time=None, max_sunrise_time=None, @@ -134,7 +134,7 @@ def test_noon_and_midnight(tzinfo_and_location): tzinfo, location = tzinfo_and_location sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=None, min_sunrise_time=None, max_sunrise_time=None, @@ -153,7 +153,7 @@ def test_sun_events(tzinfo_and_location): tzinfo, location = tzinfo_and_location sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=None, min_sunrise_time=None, max_sunrise_time=None, @@ -173,7 +173,7 @@ def test_prev_and_next_events(tzinfo_and_location): tzinfo, location = tzinfo_and_location sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=None, min_sunrise_time=None, max_sunrise_time=None, @@ -193,7 +193,7 @@ def test_closest_event(tzinfo_and_location): tzinfo, location = tzinfo_and_location sun_events = SunEvents( name="test", - astral_location=location, + astral_observer=location.observer, sunrise_time=None, min_sunrise_time=None, max_sunrise_time=None, diff --git a/tests/test_switch.py b/tests/test_switch.py index 470e0e25..46957466 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -97,6 +97,7 @@ except ImportError: # HA < 2025.8 from homeassistant.components.template.light import LightTemplate +from homeassistant.components.template import light as template_light from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, @@ -121,6 +122,10 @@ from homeassistant.util.color import color_temperature_mired_to_kelvin from tests.common import MockConfigEntry +# HA 2026.6 removed the legacy `light: platform: template` YAML format +# (home-assistant/core#169615); use the modern `template:` format there. +LEGACY_TEMPLATE_LIGHTS = hasattr(template_light, "PLATFORM_SCHEMA") + _LOGGER = logging.getLogger(__name__) SUNRISE = datetime.datetime( @@ -200,37 +205,65 @@ async def setup_switch(hass, extra_data) -> tuple[MockConfigEntry, AdaptiveSwitc async def setup_lights(hass: HomeAssistant, with_group: bool = False): """Set up 3 light entities using the 'template' platform.""" n = 3 if not with_group else 5 # last 2 will be put in a group - template_lights = { - f"light_{i}": { - "unique_id": f"light_{i}", - "friendly_name": f"light_{i}", - "turn_on": None, - "turn_off": None, - "set_level": None, - "set_temperature": None, - "set_color": None, - } - for i in range(1, n + 1) + + group_platform = { + "platform": "group", + "entities": ["light.light_4", "light.light_5"], + "name": "Light Group", + "unique_id": "light_group", + "all": "false", } - template_lights["light_3"]["supports_transition_template"] = True - platforms = [{"platform": "template", "lights": template_lights}] - if with_group: - platforms.append( - { - "platform": "group", - "entities": ["light.light_4", "light.light_5"], - "name": "Light Group", - "unique_id": "light_group", - "all": "false", - }, + if LEGACY_TEMPLATE_LIGHTS: + template_lights = { + f"light_{i}": { + "unique_id": f"light_{i}", + "friendly_name": f"light_{i}", + "turn_on": None, + "turn_off": None, + "set_level": None, + "set_temperature": None, + "set_color": None, + } + for i in range(1, n + 1) + } + template_lights["light_3"]["supports_transition_template"] = True + platforms = [{"platform": "template", "lights": template_lights}] + if with_group: + platforms.append(group_platform) + await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: platforms}, + ) + else: + if with_group: + # Setting up `template` below also sets up the `light` domain, + # after which `async_setup_component(hass, LIGHT_DOMAIN, ...)` + # would be a no-op, so the group platform must be set up first. + await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: [group_platform]}, + ) + modern_lights = [ + { + "name": f"light_{i}", + "unique_id": f"light_{i}", + "turn_on": None, + "turn_off": None, + "set_level": None, + "set_temperature": None, + "set_hs": None, + } + for i in range(1, n + 1) + ] + modern_lights[2]["supports_transition"] = "{{ true }}" + await async_setup_component( + hass, + "template", + {"template": {"light": modern_lights}}, ) - - await async_setup_component( - hass, - LIGHT_DOMAIN, - {LIGHT_DOMAIN: platforms}, - ) await hass.async_block_till_done() if with_group: diff --git a/webapp/app.py b/webapp/app.py index 68dbb2f9..d46debe1 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -8,8 +8,7 @@ from typing import Any import matplotlib.pyplot as plt import numpy as np import shinyswatch -from astral import LocationInfo -from astral.location import Location +from astral import Observer from homeassistant_util_color import color_temperature_to_rgb from shiny import App, render, ui @@ -298,7 +297,6 @@ def time_to_float(time: dt.time | dt.datetime) -> float: def _kw(input): - location = Location(LocationInfo(timezone=dt.timezone.utc)) return { "name": "Adaptive Lighting Simulator", "adapt_until_sleep": input.adapt_until_sleep(), @@ -324,8 +322,8 @@ def _kw(input): "max_sunrise_time": None, "min_sunset_time": None, "max_sunset_time": None, - "astral_location": location, - "timezone": location.timezone, + "astral_observer": Observer(), + "timezone": dt.timezone.utc, } diff --git a/webapp/color_and_brightness.py b/webapp/color_and_brightness.py index d636c674..85e351c5 100644 --- a/webapp/color_and_brightness.py +++ b/webapp/color_and_brightness.py @@ -9,27 +9,30 @@ import logging import math from dataclasses import dataclass from datetime import UTC, timedelta +from enum import Enum from functools import cached_property, partial -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import Any, Literal, cast +import astral.sun from homeassistant_util_color import ( color_RGB_to_xy, color_temperature_to_rgb, color_xy_to_hs, ) -if TYPE_CHECKING: - import astral.location -# Same as homeassistant.const.SUN_EVENT_SUNRISE and homeassistant.const.SUN_EVENT_SUNSET -# We re-define them here to not depend on homeassistant in this file. -SUN_EVENT_SUNRISE = "sunrise" -SUN_EVENT_SUNSET = "sunset" +class SunEvent(str, Enum): + """A set of sun events that happen during a day.""" -SUN_EVENT_NOON = "solar_noon" -SUN_EVENT_MIDNIGHT = "solar_midnight" + # Same as homeassistant.const.SUN_EVENT_SUNRISE and homeassistant.const.SUN_EVENT_SUNSET + # We re-define them here to not depend on homeassistant in this file. + SUNRISE = "sunrise" + SUNSET = "sunset" + NOON = "solar_noon" + MIDNIGHT = "solar_midnight" -_ORDER = (SUN_EVENT_SUNRISE, SUN_EVENT_NOON, SUN_EVENT_SUNSET, SUN_EVENT_MIDNIGHT) + +_ORDER = (SunEvent.SUNRISE, SunEvent.NOON, SunEvent.SUNSET, SunEvent.MIDNIGHT) _ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))} utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC) @@ -43,7 +46,7 @@ class SunEvents: """Track the state of the sun and associated light settings.""" name: str - astral_location: astral.location.Location + astral_observer: astral.Observer sunrise_time: datetime.time | None min_sunrise_time: datetime.time | None max_sunrise_time: datetime.time | None @@ -57,7 +60,7 @@ class SunEvents: def sunrise(self, dt: datetime.date) -> datetime.datetime: """Return the (adjusted) sunrise time for the given datetime.""" sunrise = ( - self.astral_location.sunrise(dt, local=False) + astral.sun.sunrise(self.astral_observer, dt) if self.sunrise_time is None else self._replace_time(dt, self.sunrise_time) ) + self.sunrise_offset @@ -72,7 +75,7 @@ class SunEvents: def sunset(self, dt: datetime.date) -> datetime.datetime: """Return the (adjusted) sunset time for the given datetime.""" sunset = ( - self.astral_location.sunset(dt, local=False) + astral.sun.sunset(self.astral_observer, dt) if self.sunset_time is None else self._replace_time(dt, self.sunset_time) ) + self.sunset_offset @@ -108,8 +111,8 @@ class SunEvents: and self.min_sunset_time is None and self.max_sunset_time is None ): - solar_noon = self.astral_location.noon(dt, local=False) - solar_midnight = self.astral_location.midnight(dt, local=False) + solar_noon = astral.sun.noon(self.astral_observer, dt) + solar_midnight = astral.sun.midnight(self.astral_observer, dt) return solar_noon, solar_midnight if sunset is None: @@ -126,21 +129,21 @@ class SunEvents: noon = midnight + timedelta(hours=12) * (1 if midnight.hour < 12 else -1) return noon, midnight - def sun_events(self, dt: datetime.datetime) -> list[tuple[str, float]]: + def sun_events(self, dt: datetime.datetime) -> list[tuple[SunEvent, float]]: """Get the four sun event's timestamps at 'dt'.""" sunrise = self.sunrise(dt) sunset = self.sunset(dt) solar_noon, solar_midnight = self.noon_and_midnight(dt, sunset, sunrise) - events = [ - (SUN_EVENT_SUNRISE, sunrise.timestamp()), - (SUN_EVENT_SUNSET, sunset.timestamp()), - (SUN_EVENT_NOON, solar_noon.timestamp()), - (SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()), + events: list[tuple[SunEvent, float]] = [ + (SunEvent.SUNRISE, sunrise.timestamp()), + (SunEvent.SUNSET, sunset.timestamp()), + (SunEvent.NOON, solar_noon.timestamp()), + (SunEvent.MIDNIGHT, solar_midnight.timestamp()), ] self._validate_sun_event_order(events) return events - def _validate_sun_event_order(self, events: list[tuple[str, float]]) -> None: + def _validate_sun_event_order(self, events: list[tuple[SunEvent, float]]) -> None: """Check if the sun events are in the expected order.""" events = sorted(events, key=lambda x: x[1]) events_names, _ = zip(*events, strict=True) @@ -154,7 +157,10 @@ class SunEvents: _LOGGER.error(msg) raise ValueError(msg) - def prev_and_next_events(self, dt: datetime.datetime) -> list[tuple[str, float]]: + def prev_and_next_events( + self, + dt: datetime.datetime, + ) -> list[tuple[SunEvent, float]]: """Get the previous and next sun event.""" events = [ event @@ -171,23 +177,26 @@ class SunEvents: (_, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt) h, x = ( (prev_ts, next_ts) - if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) + if next_event in (SunEvent.SUNSET, SunEvent.SUNRISE) else (next_ts, prev_ts) ) # k = -1 between sunset and sunrise (sun below horizon) # k = 1 between sunrise and sunset (sun above horizon) - k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1 + k = 1 if next_event in (SunEvent.SUNSET, SunEvent.NOON) else -1 return k * (1 - ((target_ts - h) / (h - x)) ** 2) - def closest_event(self, dt: datetime.datetime) -> tuple[str, float]: + def closest_event( + self, + dt: datetime.datetime, + ) -> tuple[Literal[SunEvent.SUNRISE, SunEvent.SUNSET], float]: """Get the closest sunset or sunrise event.""" (prev_event, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt) - if SUN_EVENT_SUNRISE in (prev_event, next_event): - ts_event = prev_ts if prev_event == SUN_EVENT_SUNRISE else next_ts - return SUN_EVENT_SUNRISE, ts_event - if SUN_EVENT_SUNSET in (prev_event, next_event): - ts_event = prev_ts if prev_event == SUN_EVENT_SUNSET else next_ts - return SUN_EVENT_SUNSET, ts_event + if SunEvent.SUNRISE in (prev_event, next_event): + ts_event = prev_ts if prev_event == SunEvent.SUNRISE else next_ts + return SunEvent.SUNRISE, ts_event + if SunEvent.SUNSET in (prev_event, next_event): + ts_event = prev_ts if prev_event == SunEvent.SUNSET else next_ts + return SunEvent.SUNSET, ts_event msg = "No sunrise or sunset event found." raise ValueError(msg) @@ -197,7 +206,7 @@ class SunLightSettings: """Track the state of the sun and associated light settings.""" name: str - astral_location: astral.location.Location + astral_observer: astral.Observer adapt_until_sleep: bool max_brightness: int max_color_temp: int @@ -225,7 +234,7 @@ class SunLightSettings: """Return the SunEvents object.""" return SunEvents( name=self.name, - astral_location=self.astral_location, + astral_observer=self.astral_observer, sunrise_time=self.sunrise_time, sunrise_offset=self.sunrise_offset, min_sunrise_time=self.min_sunrise_time, @@ -249,7 +258,7 @@ class SunLightSettings: event, ts_event = self.sun.closest_event(dt) dark = self.brightness_mode_time_dark.total_seconds() light = self.brightness_mode_time_light.total_seconds() - if event == SUN_EVENT_SUNRISE: + if event == SunEvent.SUNRISE: brightness = scaled_tanh( dt.timestamp() - ts_event, x1=-dark, @@ -259,7 +268,7 @@ class SunLightSettings: y_min=self.min_brightness, y_max=self.max_brightness, ) - elif event == SUN_EVENT_SUNSET: + elif event == SunEvent.SUNSET: brightness = scaled_tanh( dt.timestamp() - ts_event, x1=-light, # shifted timestamp for the start of sunset @@ -269,6 +278,9 @@ class SunLightSettings: y_min=self.min_brightness, y_max=self.max_brightness, ) + else: + msg = "Unsupported sun event" + raise ValueError(msg) return clamp(brightness, self.min_brightness, self.max_brightness) def _brightness_pct_linear(self, dt: datetime.datetime) -> float: @@ -277,7 +289,7 @@ class SunLightSettings: # at ts_event + dt_end, brightness == end_brightness dark = self.brightness_mode_time_dark.total_seconds() light = self.brightness_mode_time_light.total_seconds() - if event == SUN_EVENT_SUNRISE: + if event == SunEvent.SUNRISE: brightness = lerp( dt.timestamp() - ts_event, x1=-dark, @@ -285,7 +297,7 @@ class SunLightSettings: y1=self.min_brightness, y2=self.max_brightness, ) - elif event == SUN_EVENT_SUNSET: + elif event == SunEvent.SUNSET: brightness = lerp( dt.timestamp() - ts_event, x1=-light, @@ -293,6 +305,9 @@ class SunLightSettings: y1=self.max_brightness, y2=self.min_brightness, ) + else: + msg = "Unsupported sun event" + raise ValueError(msg) return clamp(brightness, self.min_brightness, self.max_brightness) def brightness_pct(self, dt: datetime.datetime, is_sleep: bool) -> float | None: @@ -356,7 +371,8 @@ class SunLightSettings: force_rgb_color = True else: color_temp_kelvin = self.color_temp_kelvin(sun_position) - rgb_color = color_temperature_to_rgb(color_temp_kelvin) + r, g, b = color_temperature_to_rgb(color_temp_kelvin) + rgb_color = (round(r), round(g), round(b)) # backwards compatibility for versions < 1.3.1 - see #403 color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) From 3638fb30138494aeb6cbb2f9f7dabf370eeed859 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 2 Jul 2026 08:08:14 -0700 Subject: [PATCH 13/35] fix: don't cancel adaptation when a light group turns on via a member with a reused context (#1483) * fix: don't cancel adaptation when a light group turns on via a member with a reused context (#1378) When a member of a light group is turned on (e.g., by a motion sensor automation) while the group is off, the group turns on as a side effect, but Home Assistant may reuse the context of the earlier turn_off call for the group's state change. just_turned_off() saw matching context IDs and treated the state change as a polling artifact, cancelling adaptation. - Check whether the off->on state change comes from a light.turn_on call before the matching-context polling-artifact check, so automations that turn a light off and back on with a single (automation) context adapt correctly. - For light groups, allow adaptation when a member's turn_on event falls between the group's on->off and off->on state changes, bounded on both sides so stale member events are never treated as explanatory. - Document that integration-level groups (e.g., Zigbee2MQTT groups) should not be nested inside HA Light Groups managed by Adaptive Lighting. * fix: time-bound the same-context turn_on check instead of reordering Address review findings: - Reordering the turn_on-service check above the matching-context check reintroduced stale-event false negatives: turn_on_event entries are never cleaned up, so a 'turn_on -> delay -> turn_off(transition)' automation (one shared context) would defeat the polling-artifact guard and AL could turn a light back on right after it was turned off. Restore main's check order and instead add a time-bounded own-turn_on check inside the matching-context branch, symmetric with the group-member check. This also avoids emitting the 'should not happen' warning for self-context polling artifacts. - Add a regression test for the stale same-context turn_on case. - Add an end-to-end test driving the event-bus listeners for the #1378 scenario (group kept in manager.lights, as in the reported setups). - Docs: drop the inaccurate 'expands only one level deep' claim; explain that integration-level groups cannot be expanded and nested groups make tracking unpredictable. --- README.md | 5 + custom_components/adaptive_lighting/switch.py | 68 +++++- docs/troubleshooting.md | 5 + tests/test_switch.py | 205 ++++++++++++++++++ 4 files changed, 282 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a26df24..a819f788 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,11 @@ Expose only the group (not individual bulbs) in Home Assistant Dashboards and ex > :warning: **If you control lights individually, `manual_control` cannot behave correctly! If you need to control lights individually as well, use a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/).** +When mixing group types, avoid nesting: do not add integration-level groups (e.g., Zigbee2MQTT groups) to a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/) that is managed by Adaptive Lighting, and do not nest Home Assistant Light Groups inside each other. +Adaptive Lighting cannot expand an integration-level group into its member lights, and nested groups make it unpredictable which entity Adaptive Lighting tracks and adapts, which can prevent lights from being adapted at all (see [#1378](https://github.com/basnijholt/adaptive-lighting/issues/1378)). +Instead, add the individual light entities or a single Zigbee group directly to the Adaptive Lighting configuration. +Also note that bulbs turned on via a Zigbee group broadcast may briefly flash their last (cached) brightness and color before the adapted values arrive; this happens inside the bulbs and cannot be prevented by Home Assistant or Adaptive Lighting. + #### :rainbow: Light Colors Not Matching Bulbs from different manufacturers or models may have varying color temperature specifications. For instance, if you have two Adaptive Lighting configurations—one with only Philips Hue White Ambiance bulbs and another with a mix of Philips Hue White Ambiance and Sengled bulbs—the Philips Hue bulbs may appear to have different color temperatures despite having identical settings. diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index e80e6089..3b042702 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -2746,7 +2746,45 @@ class AdaptiveLightingManager: id_off_to_on = off_to_on_event.context.id return turn_on_event is not None and id_off_to_on == turn_on_event.context.id - async def just_turned_off( # noqa: PLR0911 + def _member_turn_on_explains_group_turn_on( + self, + entity_id: str, + on_to_off_event: Event[EventStateChangedData], + off_to_on_event: Event[EventStateChangedData], + ) -> bool: + """Check if a light group's 'off' → 'on' is caused by a member's 'light.turn_on'. + + When a member of a light group is turned on while the group is off, the + group turns on as a side effect. Home Assistant may reuse the context of + an earlier 'light.turn_off' call for the group's state change (entities + keep their context for a few seconds), which makes the group's turn-on + look like a polling artifact of the turn-off. + See https://github.com/basnijholt/adaptive-lighting/issues/1378 + """ + state = self.hass.states.get(entity_id) + if state is None or not _is_light_group(state): + return False + members: list[str] = state.attributes[ATTR_ENTITY_ID] + for member in members: + member_turn_on = self.turn_on_event.get(member) + if ( + member_turn_on is not None + and on_to_off_event.time_fired + < member_turn_on.time_fired + <= off_to_on_event.time_fired + ): + _LOGGER.debug( + "just_turned_off: Light group '%s' turned on because its member" + " '%s' was turned on (context.id='%s'), so this is a legitimate" + " turn-on, not a polling artifact.", + entity_id, + member, + member_turn_on.context.id, + ) + return True + return False + + async def just_turned_off( # noqa: PLR0911, PLR0912 self, entity_id: str, ) -> bool: @@ -2774,6 +2812,34 @@ class AdaptiveLightingManager: return False if off_to_on_event.context.id == on_to_off_event.context.id: + # Matching context IDs usually mean a polling artifact (HA briefly + # reports 'on' while the light is still turning off). However, the + # context is also reused when e.g. one automation turns the light + # off and later back on, or when an integration writes the state + # with the entity's cached context. Only treat the state change as + # a legitimate turn-on if a 'light.turn_on' call for this light (or + # for a member of this light group) fired between the two state + # changes. + turn_on_event = self.turn_on_event.get(entity_id) + if ( + turn_on_event is not None + and on_to_off_event.time_fired + < turn_on_event.time_fired + <= off_to_on_event.time_fired + ): + _LOGGER.debug( + "just_turned_off: 'light.turn_on' was called for '%s' between its" + " 'on' → 'off' and 'off' → 'on' state changes, so this is a" + " legitimate turn-on, not a polling artifact.", + entity_id, + ) + return False + if self._member_turn_on_explains_group_turn_on( + entity_id, + on_to_off_event, + off_to_on_event, + ): + return False _LOGGER.debug( "just_turned_off: 'on' → 'off' state change has the same context.id as the" " 'off' → 'on' state change for '%s'. This is probably a false positive.", diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 22374949..cdff4487 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -71,6 +71,11 @@ Expose only the group (not individual bulbs) in Home Assistant Dashboards and ex > :warning: **If you control lights individually, `manual_control` cannot behave correctly! If you need to control lights individually as well, use a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/).** +When mixing group types, avoid nesting: do not add integration-level groups (e.g., Zigbee2MQTT groups) to a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/) that is managed by Adaptive Lighting, and do not nest Home Assistant Light Groups inside each other. +Adaptive Lighting cannot expand an integration-level group into its member lights, and nested groups make it unpredictable which entity Adaptive Lighting tracks and adapts, which can prevent lights from being adapted at all (see [#1378](https://github.com/basnijholt/adaptive-lighting/issues/1378)). +Instead, add the individual light entities or a single Zigbee group directly to the Adaptive Lighting configuration. +Also note that bulbs turned on via a Zigbee group broadcast may briefly flash their last (cached) brightness and color before the adapted values arrive; this happens inside the bulbs and cannot be prevented by Home Assistant or Adaptive Lighting. + #### :rainbow: Light Colors Not Matching Bulbs from different manufacturers or models may have varying color temperature specifications. For instance, if you have two Adaptive Lighting configurations—one with only Philips Hue White Ambiance bulbs and another with a mix of Philips Hue White Ambiance and Sengled bulbs—the Philips Hue bulbs may appear to have different color temperatures despite having identical settings. diff --git a/tests/test_switch.py b/tests/test_switch.py index 46957466..eabe226a 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -2429,6 +2429,211 @@ async def test_light_group( assert len(events) == 3 +def _state_changed_event(entity_id: str, ts: float, context: Context) -> Event: + return Event( + EVENT_STATE_CHANGED, + {"entity_id": entity_id}, + time_fired_timestamp=ts, + context=context, + ) + + +def _turn_on_service_event(entity_ids: list[str], ts: float, context: Context) -> Event: + return Event( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_ON, + "service_data": {ATTR_ENTITY_ID: entity_ids}, + }, + time_fired_timestamp=ts, + context=context, + ) + + +async def test_just_turned_off_group_context_reuse(hass, cleanup): + """Group 'off' → 'on' with a reused 'turn_off' context must still adapt. + + When a member of a light group is turned on (e.g., by a motion sensor + automation) while the group is off, the group turns on as a side effect, + but Home Assistant may reuse the context of the earlier 'turn_off' call + for the group's state change. `just_turned_off` used to treat this as a + polling artifact and cancel adaptation. + + Regression test for https://github.com/basnijholt/adaptive-lighting/issues/1378 + """ + await setup_lights(hass, with_group=True) + _, switch = await setup_switch(hass, {CONF_LIGHTS: ["light.light_group"]}) + await hass.async_block_till_done() + manager = switch.manager + + group = "light.light_group" + member = "light.light_4" + now = dt_util.utcnow().timestamp() + turn_off_context = Context() + + # The group was turned off 2 seconds ago... + manager.on_to_off_event[group] = _state_changed_event( + group, + now - 2, + turn_off_context, + ) + # ...then an automation turned on a member light with a fresh context... + manager.turn_on_event[member] = _turn_on_service_event( + [member], + now - 0.5, + Context(), + ) + # ...which turned the group back on, but HA reused the old turn_off context. + manager.off_to_on_event[group] = _state_changed_event( + group, + now, + turn_off_context, + ) + + # The member's turn_on explains the group's turn-on: adaptation must proceed. + assert not await manager.just_turned_off(group) + + # A member turn_on from *before* the group was turned off does not explain + # the group's turn-on: this must still be treated as a polling artifact. + manager.turn_on_event[member] = _turn_on_service_event( + [member], + now - 10, + Context(), + ) + assert await manager.just_turned_off(group) + + # Without any member turn_on event, the matching context IDs must still be + # treated as a polling artifact. + del manager.turn_on_event[member] + assert await manager.just_turned_off(group) + + +async def test_just_turned_off_same_automation_context(hass, cleanup): + """'turn_off' and 'turn_on' from one automation share a context. + + An automation calling 'light.turn_off' and later 'light.turn_on' reuses + its own context for both service calls, so the 'on' → 'off' and + 'off' → 'on' state changes have matching context IDs. The turn_on service + call must take precedence over the matching-context polling-artifact check. + """ + 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() + automation_context = Context() + + manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event( + ENTITY_LIGHT_1, + now - 2, + automation_context, + ) + manager.turn_on_event[ENTITY_LIGHT_1] = _turn_on_service_event( + [ENTITY_LIGHT_1], + now - 0.5, + automation_context, + ) + manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event( + ENTITY_LIGHT_1, + now, + automation_context, + ) + assert not await manager.just_turned_off(ENTITY_LIGHT_1) + + # A stale turn_on with an unrelated context does not explain the + # 'off' → 'on' state change: still a polling artifact. + manager.turn_on_event[ENTITY_LIGHT_1] = _turn_on_service_event( + [ENTITY_LIGHT_1], + now - 10, + Context(), + ) + assert await manager.just_turned_off(ENTITY_LIGHT_1) + + # A stale turn_on *sharing the automation's context* but fired before the + # 'on' → 'off' state change (i.e., 'turn_on' → delay → 'turn_off' in one + # automation run) does not explain the 'off' → 'on' state change either: + # `turn_on_event` entries are never cleaned up, so without the time bounds + # this would defeat the polling-artifact detection. + manager.turn_on_event[ENTITY_LIGHT_1] = _turn_on_service_event( + [ENTITY_LIGHT_1], + now - 10, + automation_context, + ) + assert await manager.just_turned_off(ENTITY_LIGHT_1) + + +async def test_just_turned_off_group_context_reuse_end_to_end(hass, cleanup): + """Drive the issue #1378 scenario through the real event bus listeners. + + Unlike `test_just_turned_off_group_context_reuse`, which calls + `just_turned_off` directly, this test fires the service and state-changed + events on the bus. Light groups are normally expanded out of + `manager.lights`, but they can remain tracked in real setups (e.g., when a + group is nested inside another configured group or is unavailable during + setup), which is the configuration under which issue #1378 was reported. + """ + await setup_lights(hass, with_group=True) + _, switch = await setup_switch(hass, {CONF_LIGHTS: ["light.light_group"]}) + await hass.async_block_till_done() + manager = switch.manager + + group = "light.light_group" + member = "light.light_4" + assert member in manager.lights + # Simulate a setup in which the group entity itself remains tracked. + manager.lights.add(group) + + turn_off_context = Context() + # The group was turned off... + hass.bus.async_fire( + EVENT_STATE_CHANGED, + { + "entity_id": group, + "old_state": State(group, STATE_ON), + "new_state": State(group, STATE_OFF), + }, + context=turn_off_context, + ) + await hass.async_block_till_done() + assert group in manager.on_to_off_event + + # ...then an automation turned on a member light with a fresh context... + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_ON, + "service_data": {ATTR_ENTITY_ID: [member]}, + }, + context=Context(), + ) + await hass.async_block_till_done() + assert member in manager.turn_on_event + + # ...which turned the group back on, but HA reused the old turn_off context. + with patch.object( + AdaptiveSwitch, + "_respond_to_off_to_on_event", + AsyncMock(), + ) as respond: + hass.bus.async_fire( + EVENT_STATE_CHANGED, + { + "entity_id": group, + "old_state": State(group, STATE_OFF), + "new_state": State(group, STATE_ON), + }, + context=turn_off_context, + ) + await hass.async_block_till_done() + + # Adaptation must not have been cancelled as a polling artifact. + respond.assert_called_once() + assert respond.call_args[0][0] == group + + @pytest.mark.parametrize("brightness_mode", ["linear", "tanh"]) @pytest.mark.parametrize(("dark", "light"), ([900, 1800], [1800, 900], [1800, 1800])) async def test_brightness_mode(hass, brightness_mode, dark, light): From 7afdf5bc5ca36bb17a999dba53ef84fc49c9b13e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 7 Jul 2026 09:38:26 -0700 Subject: [PATCH 14/35] Update manifest.json to v1.31.0 (#1486) --- custom_components/adaptive_lighting/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index fc854221..427b730a 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": ["ulid-transform"], - "version": "1.30.1" + "version": "1.31.0" } From 4a87b5ef54a631b4ac9c3b3b248519a5be7ef0d7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:02:37 -0700 Subject: [PATCH 15/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20astral-sh/s?= =?UTF-8?q?etup-uv=20action=20to=20v9=20(#1496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- .github/workflows/install_dependencies/action.yml | 2 +- .github/workflows/markdown-code-runner.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d359b54a..99a5744e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: python-version: '3.14.2' - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v9.0.0 - name: Install dependencies run: uv sync --group docs diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index 910e5b35..a9bcceae 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -32,7 +32,7 @@ runs: with: python-version: ${{ inputs.python-version }} - name: Set up UV - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v9.0.0 - name: Install dependencies shell: bash run: | diff --git a/.github/workflows/markdown-code-runner.yml b/.github/workflows/markdown-code-runner.yml index 4ea1bb81..6f50157f 100644 --- a/.github/workflows/markdown-code-runner.yml +++ b/.github/workflows/markdown-code-runner.yml @@ -23,7 +23,7 @@ jobs: python-version: "3.14.2" - name: Install uv - uses: astral-sh/setup-uv@v8.1.0 + uses: astral-sh/setup-uv@v9.0.0 - name: Update generated content run: ./scripts/update-generated-content From f3af35f95eae5f23244fef441ea96027c6e9705c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:28:48 +0200 Subject: [PATCH 16/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20astral-sh/s?= =?UTF-8?q?etup-uv=20action=20to=20v10=20(#1501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs.yml | 2 +- .github/workflows/install_dependencies/action.yml | 2 +- .github/workflows/markdown-code-runner.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 99a5744e..77f66ca6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: python-version: '3.14.2' - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 - name: Install dependencies run: uv sync --group docs diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index a9bcceae..a4a77e38 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -32,7 +32,7 @@ runs: with: python-version: ${{ inputs.python-version }} - name: Set up UV - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 - name: Install dependencies shell: bash run: | diff --git a/.github/workflows/markdown-code-runner.yml b/.github/workflows/markdown-code-runner.yml index 6f50157f..6c58609e 100644 --- a/.github/workflows/markdown-code-runner.yml +++ b/.github/workflows/markdown-code-runner.yml @@ -23,7 +23,7 @@ jobs: python-version: "3.14.2" - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@v10.0.1 - name: Update generated content run: ./scripts/update-generated-content From 928d57f8be3699f78fadc59055b5a2c2af38d561 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:28:58 +0200 Subject: [PATCH 17/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20actions/set?= =?UTF-8?q?up-python=20action=20to=20v7=20(#1493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs.yml | 2 +- .github/workflows/install_dependencies/action.yml | 2 +- .github/workflows/markdown-code-runner.yml | 2 +- .github/workflows/pre-commit.yaml | 2 +- .github/workflows/update-test-matrix.yaml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 77f66ca6..52aab744 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14.2' diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index a4a77e38..1e1e6691 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -28,7 +28,7 @@ runs: ref: ${{ inputs.core-version }} - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@v6.1.0 + uses: actions/setup-python@v7.0.0 with: python-version: ${{ inputs.python-version }} - name: Set up UV diff --git a/.github/workflows/markdown-code-runner.yml b/.github/workflows/markdown-code-runner.yml index 6c58609e..6ca94545 100644 --- a/.github/workflows/markdown-code-runner.yml +++ b/.github/workflows/markdown-code-runner.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14.2" diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 57263d00..901f2e0a 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -10,5 +10,5 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/update-test-matrix.yaml b/.github/workflows/update-test-matrix.yaml index 5f948a67..cb3237b2 100644 --- a/.github/workflows/update-test-matrix.yaml +++ b/.github/workflows/update-test-matrix.yaml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14.2" From afb447c4e81fcb74c7dc920a3a3243757d4b928a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:29:53 +0200 Subject: [PATCH 18/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20pytz=20to?= =?UTF-8?q?=20v2026=20(#1436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- webapp/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/requirements.txt b/webapp/requirements.txt index 882162f1..fd8793e1 100644 --- a/webapp/requirements.txt +++ b/webapp/requirements.txt @@ -2,5 +2,5 @@ # uv pip compile requirements.txt.in --output-file requirements.txt astral==2.2 # via -r requirements.txt.in -pytz==2023.3.post1 +pytz==2026.3.post1 # via astral From 19dd9723a32dfcc84441606c77dfe4801c249f92 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:30:13 +0200 Subject: [PATCH 19/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20python=20to?= =?UTF-8?q?=20v3.14.7=20(#1419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs.yml | 2 +- .github/workflows/markdown-code-runner.yml | 2 +- .github/workflows/update-test-matrix.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 52aab744..8142e33d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: '3.14.2' + python-version: '3.14.7' - name: Install uv uses: astral-sh/setup-uv@v10.0.1 diff --git a/.github/workflows/markdown-code-runner.yml b/.github/workflows/markdown-code-runner.yml index 6ca94545..a7d6a403 100644 --- a/.github/workflows/markdown-code-runner.yml +++ b/.github/workflows/markdown-code-runner.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.14.2" + python-version: "3.14.7" - name: Install uv uses: astral-sh/setup-uv@v10.0.1 diff --git a/.github/workflows/update-test-matrix.yaml b/.github/workflows/update-test-matrix.yaml index cb3237b2..e714cbf0 100644 --- a/.github/workflows/update-test-matrix.yaml +++ b/.github/workflows/update-test-matrix.yaml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.14.2" + python-version: "3.14.7" - name: Update test matrix run: python scripts/update-test-matrix.py From a9fd62a1107e4af045fe28f03d961d7c7807dffc Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:35:50 +0200 Subject: [PATCH 20/35] docs: add cperuffo3 as a contributor for code (#1516) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index e2a901fd..c56b5bca 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1224,6 +1224,15 @@ "contributions": [ "translation" ] + }, + { + "login": "cperuffo3", + "name": "Corey Peruffo", + "avatar_url": "https://avatars.githubusercontent.com/u/87686305?v=4", + "profile": "https://github.com/cperuffo3", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index a819f788..fb2352e9 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-134-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-135-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -668,6 +668,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Esspel
Esspel

🌍 + Corey Peruffo
Corey Peruffo

💻 From dce34134b6ea66000fbb986cab13ada7a2253996 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:37:09 +0200 Subject: [PATCH 21/35] docs: add imwithsam as a contributor for code (#1517) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- .all-contributorsrc | 29 +++++++++++++++++++---------- README.md | 3 ++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index c56b5bca..9dcd2773 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -87,7 +87,7 @@ }, { "login": "Repsionu", - "name": "Jüri Rebane", + "name": "J\u00fcri Rebane", "avatar_url": "https://avatars.githubusercontent.com/u/46962963?v=4", "profile": "https://github.com/Repsionu", "contributions": [ @@ -195,7 +195,7 @@ }, { "login": "Hypfer", - "name": "Sören Beye", + "name": "S\u00f6ren Beye", "avatar_url": "https://avatars.githubusercontent.com/u/974410?v=4", "profile": "http://hypfer.de/", "contributions": [ @@ -387,7 +387,7 @@ }, { "login": "brebtatv", - "name": "Tomáš Valigura", + "name": "Tom\u00e1\u0161 Valigura", "avatar_url": "https://avatars.githubusercontent.com/u/10747062?v=4", "profile": "https://github.com/brebtatv", "contributions": [ @@ -524,7 +524,7 @@ }, { "login": "letroll", - "name": "Julien Quiévreux", + "name": "Julien Qui\u00e9vreux", "avatar_url": "https://avatars.githubusercontent.com/u/255774?v=4", "profile": "http://www.latavernedutroll.fr", "contributions": [ @@ -642,7 +642,7 @@ }, { "login": "mstefany", - "name": "Martin Štefany", + "name": "Martin \u0160tefany", "avatar_url": "https://avatars.githubusercontent.com/u/57348587?v=4", "profile": "https://stefany.eu", "contributions": [ @@ -696,7 +696,7 @@ }, { "login": "jansigu", - "name": "Jan-Sigurd Sørensen", + "name": "Jan-Sigurd S\u00f8rensen", "avatar_url": "https://avatars.githubusercontent.com/u/8410766?v=4", "profile": "http://www.jan-sigurd.com", "contributions": [ @@ -849,7 +849,7 @@ }, { "login": "MrEbbinghaus", - "name": "Björn Ebbinghaus", + "name": "Bj\u00f6rn Ebbinghaus", "avatar_url": "https://avatars.githubusercontent.com/u/2965273?v=4", "profile": "https://blog.ebbinghaus.me/", "contributions": [ @@ -894,7 +894,7 @@ }, { "login": "TamilNeram", - "name": "தமிழ் நேரம்", + "name": "\u0ba4\u0bae\u0bbf\u0bb4\u0bcd \u0ba8\u0bc7\u0bb0\u0bae\u0bcd", "avatar_url": "https://avatars.githubusercontent.com/u/67970539?v=4", "profile": "https://github.com/TamilNeram", "contributions": [ @@ -939,7 +939,7 @@ }, { "login": "marazmarci", - "name": "Márton Maráz", + "name": "M\u00e1rton Mar\u00e1z", "avatar_url": "https://avatars.githubusercontent.com/u/1349654?v=4", "profile": "https://github.com/marazmarci", "contributions": [ @@ -1128,7 +1128,7 @@ }, { "login": "maksim2005UKR", - "name": "Горпиніч Максим Олександрович", + "name": "\u0413\u043e\u0440\u043f\u0438\u043d\u0456\u0447 \u041c\u0430\u043a\u0441\u0438\u043c \u041e\u043b\u0435\u043a\u0441\u0430\u043d\u0434\u0440\u043e\u0432\u0438\u0447", "avatar_url": "https://avatars.githubusercontent.com/u/233082001?v=4", "profile": "https://github.com/maksim2005UKR", "contributions": [ @@ -1233,6 +1233,15 @@ "contributions": [ "code" ] + }, + { + "login": "imwithsam", + "name": "Samson Brock", + "avatar_url": "https://avatars.githubusercontent.com/u/1934074?v=4", + "profile": "http://badmotivator.io/", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index fb2352e9..7873673b 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-135-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-136-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -678,6 +678,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Add your contributions + Samson Brock
Samson Brock

💻 From efee60cb9a61408b926bfb8c2a0a9479abcb9079 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:37:34 +0200 Subject: [PATCH 22/35] docs: add Dennis-Dekker as a contributor for code (#1518) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 9dcd2773..6cdb0789 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1242,6 +1242,15 @@ "contributions": [ "code" ] + }, + { + "login": "Dennis-Dekker", + "name": "Dennis Dekker", + "avatar_url": "https://avatars.githubusercontent.com/u/48018095?v=4", + "profile": "https://github.com/Dennis-Dekker", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 7873673b..a0cc031f 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-136-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-137-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -679,6 +679,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Samson Brock
Samson Brock

💻 + Dennis Dekker
Dennis Dekker

💻 From cc93af84dea7b381f95a05983c1c12f79fc76c2b Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:37:57 +0200 Subject: [PATCH 23/35] docs: add proscar87 as a contributor for code (#1519) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 6cdb0789..3d8fb965 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1251,6 +1251,15 @@ "contributions": [ "code" ] + }, + { + "login": "proscar87", + "name": "proscar87", + "avatar_url": "https://avatars.githubusercontent.com/u/68169114?v=4", + "profile": "https://github.com/proscar87", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index a0cc031f..4695cf81 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-137-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-138-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -680,6 +680,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Samson Brock
Samson Brock

💻 Dennis Dekker
Dennis Dekker

💻 + proscar87
proscar87

💻 From 5df9b30d324a18cbf1482bafb08ab119653d01ff Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:45:48 +0200 Subject: [PATCH 24/35] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Update=20actions/che?= =?UTF-8?q?ckout=20action=20to=20v7=20(#1479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- .github/workflows/docker-build.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/hassfest.yaml | 2 +- .github/workflows/install_dependencies/action.yml | 4 ++-- .github/workflows/main-to-master-sync.yml | 2 +- .github/workflows/markdown-code-runner.yml | 2 +- .github/workflows/pre-commit.yaml | 2 +- .github/workflows/pytest.yaml | 2 +- .github/workflows/update-test-matrix.yaml | 2 +- .github/workflows/validate.yml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 00e8d89a..4783ef38 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -21,7 +21,7 @@ jobs: matrix: platform: [linux/amd64, linux/arm64] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8142e33d..2204a1ef 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index b063de53..7313c4cc 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -11,5 +11,5 @@ jobs: validate_hassfest: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v6.0.2" + - uses: "actions/checkout@v7.0.1" - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index 1e1e6691..9b8dbb08 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -14,14 +14,14 @@ runs: using: "composite" steps: - name: Check out code from GitHub - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ${{ github.repository }} ref: ${{ github.ref }} persist-credentials: false fetch-depth: 0 - name: Check out code from GitHub - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: home-assistant/core path: core diff --git a/.github/workflows/main-to-master-sync.yml b/.github/workflows/main-to-master-sync.yml index 424f50b3..a2756894 100644 --- a/.github/workflows/main-to-master-sync.yml +++ b/.github/workflows/main-to-master-sync.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 diff --git a/.github/workflows/markdown-code-runner.yml b/.github/workflows/markdown-code-runner.yml index a7d6a403..279a7f8a 100644 --- a/.github/workflows/markdown-code-runner.yml +++ b/.github/workflows/markdown-code-runner.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} ref: ${{ github.head_ref || github.ref }} diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 901f2e0a..e67ef449 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -9,6 +9,6 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v7 - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index dd0e4559..8b2614f0 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -52,7 +52,7 @@ jobs: python-version: "3.14.2" steps: - name: Check out code from GitHub - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install Home Assistant uses: ./.github/workflows/install_dependencies diff --git a/.github/workflows/update-test-matrix.yaml b/.github/workflows/update-test-matrix.yaml index e714cbf0..e67ae227 100644 --- a/.github/workflows/update-test-matrix.yaml +++ b/.github/workflows/update-test-matrix.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3fa46b4e..83ce8ad9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,7 +11,7 @@ jobs: validate_hacs: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v6" + - uses: "actions/checkout@v7" - name: HACS validation uses: "hacs/action@main" with: From 6d46b823135efa8c1746e301a4f044ffc426ed23 Mon Sep 17 00:00:00 2001 From: Corey Peruffo <87686305+cperuffo3@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:53:06 -0400 Subject: [PATCH 25/35] fix: use quantization-aware comparison in skip_redundant_commands filter (#1513) * fix: use quantization-aware comparison in skip_redundant_commands filter _remove_redundant_attributes() compared target values against light state with exact equality, but many targets can never round-trip exactly through a device with coarser resolution: - brightness: HA's 0-255 scale vs the 0-99 Z-Wave Multilevel Switch scale leaves 156 of 255 targets that never converge (e.g. 230 -> 89 -> 229), - color_temp_kelvin: the kelvin -> mired -> kelvin round trip leaves most kelvin targets off by up to ~21 K at 6500 K (e.g. 5500 -> 182 -> 5495). Such attributes survived the filter and were re-sent every interval forever, which on larger Z-Wave meshes is enough to jam the controller. Compare brightness with a tolerance of 2 (the exact worst case of the 0-99 scale) and color temperature in mired space, where devices actually quantize and where the comparison is exact at every kelvin value. Both are far below the manual-control detection thresholds (BRIGHTNESS_CHANGE = 25, COLOR_TEMP_CHANGE = 100), so they cannot mask a genuine user change. Fixes #1512 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017P2wLQYGQH6npY5op5CKVD * fix: tolerate one mired to cover both floor- and round-based conversions The previous exact mired equality assumed round-based kelvin<->mired conversion, but HA core's color_temperature_kelvin_to_mired() and color_temperature_mired_to_kelvin() both use math.floor, under which a target like 5500 K comes back as 5524 K in a different rounded mired bucket and would never be filtered. Flooring in the comparison instead would merely flip the failure onto integrations that round. Comparing with a tolerance of one mired converges for both conversion schemes (verified by brute force over 1000-10000 K: zero stuck targets under either pipeline) and can hide at most ~2 mireds, far below the ~5.5 mired just-noticeable difference for color temperature. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017P2wLQYGQH6npY5op5CKVD --------- Co-authored-by: Claude Fable 5 Co-authored-by: Bas Nijholt --- .../adaptive_lighting/adaptation_utils.py | 41 ++++++++- tests/test_adaptation_utils.py | 84 +++++++++++++++++-- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/custom_components/adaptive_lighting/adaptation_utils.py b/custom_components/adaptive_lighting/adaptation_utils.py index 26acd92b..20a227ca 100644 --- a/custom_components/adaptive_lighting/adaptation_utils.py +++ b/custom_components/adaptive_lighting/adaptation_utils.py @@ -45,6 +45,15 @@ BRIGHTNESS_ATTRS = { ATTR_BRIGHTNESS_STEP_PCT, } +# Worst-case rounding error when Home Assistant's 0-255 brightness scale +# round-trips through a device with coarser resolution (e.g., the 0-99 Z-Wave +# Multilevel Switch scale). A light cannot report back a value more precise than +# its own scale, so exact equality would never hold for such targets and +# 'skip_redundant_commands' would keep sending them forever. The tolerance sits +# far below the manual-control-detection threshold (BRIGHTNESS_CHANGE = 25), so +# it cannot mask a genuine user change. +BRIGHTNESS_TOLERANCE = 2 + ServiceData = dict[str, Any] @@ -113,20 +122,46 @@ def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]: return service_datas +def _is_attribute_satisfied(key: str, value: Any, attributes: dict[str, Any]) -> bool: + """Whether the light's current state already satisfies this target value.""" + if key not in attributes: + return False + current = attributes[key] + if not isinstance(current, (int, float)) or not isinstance(value, (int, float)): + return value == current + if key == ATTR_BRIGHTNESS: + return abs(value - current) <= BRIGHTNESS_TOLERANCE + if key == ATTR_COLOR_TEMP_KELVIN and value > 0 and current > 0: + # Compare in mired space: most integrations quantize color temperature + # to whole mireds, and the kelvin error of that quantization grows + # quadratically with kelvin (~21 K at 6500 K, ~50 K at 10000 K), so no + # fixed kelvin tolerance fits the whole range. The tolerance of one + # mired absorbs the difference between conversion schemes: HA core's + # helpers floor (e.g. 5500 K -> 181 mired -> 5524 K) while some + # integrations round (5500 K -> 182 mired -> 5495 K), and no exact + # equality converges for both. One mired is far below the ~5.5 mired + # just-noticeable difference for color temperature. + return abs(round(1_000_000 / value) - round(1_000_000 / current)) <= 1 + return value == current + + def _remove_redundant_attributes( service_data: ServiceData, state: State, ) -> ServiceData: - """Filter service data by removing attributes that already equal the given state. + """Filter service data by removing attributes already satisfied by the state. Removes all attributes from service call data whose values are already present - in the target entity's state. + in the target entity's state. Quantized attributes (brightness, color temp) are + compared with a small tolerance: a light whose resolution is coarser than Home + Assistant's cannot report back the exact value it was given, so exact equality + would never hold and the attribute would never be filtered. """ attributes: dict[str, Any] = dict(state.attributes) return { k: v for k, v in service_data.items() - if k not in attributes or v != attributes[k] + if not _is_attribute_satisfied(k, v, attributes) } diff --git a/tests/test_adaptation_utils.py b/tests/test_adaptation_utils.py index 6caf335c..b5751454 100644 --- a/tests/test_adaptation_utils.py +++ b/tests/test_adaptation_utils.py @@ -95,14 +95,82 @@ async def test_split_service_call_data(input_data, expected_data_list): ), ( {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, - State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 11}), + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 13}), {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 229}), + {ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 227}), + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2}, + ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 5500, + ATTR_TRANSITION: 2, + }, + State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5495}), + {ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2}, + ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 5500, + ATTR_TRANSITION: 2, + }, + State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5524}), + {ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2}, + ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 6500, + ATTR_TRANSITION: 2, + }, + State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 6494}), + {ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2}, + ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 5500, + ATTR_TRANSITION: 2, + }, + State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5400}), + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 5500, + ATTR_TRANSITION: 2, + }, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_HS_COLOR: (30.0, 40.0)}, + State("light.test", STATE_ON, {ATTR_HS_COLOR: (30.0, 40.0)}), + {ATTR_ENTITY_ID: "light.test"}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: None}), + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10}, + ), ], ids=[ "pass all attributes on empty state", "remove attributes whose values equal the state", "keep attributes whose values differ from the state", + "remove brightness within quantization tolerance (0-99 device scale)", + "keep brightness outside quantization tolerance", + "remove color temp within one mired (round-converting integration)", + "remove color temp within one mired (floor-converting HA core helpers)", + "remove color temp within one mired (6500 K)", + "keep color temp more than one mired away", + "remove non-numeric attributes on exact equality", + "keep attribute when state value is None", ], ) async def test_remove_redundant_attributes( @@ -167,18 +235,18 @@ async def test_has_relevant_service_data_attributes( [], ), ( - [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}], + [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}], True, - [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}], + [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}], ), ( [ - {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}, + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}, {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, ], True, [ - {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}, + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}, {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, ], ), @@ -192,6 +260,11 @@ async def test_has_relevant_service_data_attributes( {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, ], ), + ( + [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}], + True, + [], + ), ], ids=[ "single item passed through without filtering", @@ -201,6 +274,7 @@ async def test_has_relevant_service_data_attributes( "filter keeps item with relevant attribute that is different from state", "filter keeps two items with relevant attributes that are different from state", "filter removes item that equals state and keeps items that differs from state", + "filter removes item with relevant attribute within tolerance of the state", ], ) async def test_create_service_call_data_iterator( From 68c66a0ff30cf3bae7816d852ee36433b66f3dc2 Mon Sep 17 00:00:00 2001 From: Samson Brock Date: Sun, 6 Sep 2026 02:00:02 -0500 Subject: [PATCH 26/35] Fix options flow changes silently discarded for pre-refactor UI entries (#1504) * Fix options flow changes being silently discarded for UI-configured entries validate() merges config_entry.options then config_entry.data, on the assumption that data only ever holds YAML-imported settings (which should win) or, for UI-created entries, just the entry name (harmless to apply last). That assumption doesn't hold for entries created before data/options were split: their data still carries the full settings snapshot from initial setup. Applying it after options means any change made through the options flow for a key that already exists in data (e.g. adding a light) is silently ignored, even though the options flow reports success and the entry reloads without error. Reproduced on a real entry: added a light via the options flow, entry reloaded cleanly, but the light was never picked up by the switch's service-call interceptor ("No switch found for entity_id=...") because data still held the old light list and clobbered the updated options. Fix: only let data win over options for genuinely YAML-imported entries (config_entry.source == SOURCE_IMPORT), matching the existing use of that check elsewhere in this file. For UI-configured entries, apply options last so changes made through the options flow actually take effect. * Add focused tests for the data/options merge order in validate() Covers both source-specific contracts the merge logic relies on, per review feedback on this PR: - SOURCE_USER: options must win over data (this PR's actual fix - proven to fail against the pre-fix code, verified locally by reverting switch.py and re-running). - SOURCE_IMPORT: data must keep winning over options (the existing, intentional YAML-precedence behavior - unchanged by this PR, verified to already pass against the pre-fix code too). Verified against a real Home Assistant instance's test harness (pytest-homeassistant-custom-component + the actual installed homeassistant package), not just reasoned about statically. --------- Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 16 ++++++- tests/test_switch.py | 43 ++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3b042702..5db91823 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -565,8 +565,20 @@ def validate( if config_entry is not None: assert service_data is None assert defaults is None - data.update(config_entry.options) # come from options flow - data.update(config_entry.data) # all yaml settings come from data + if config_entry.source == SOURCE_IMPORT: + # YAML-configured entries: `data` is the authoritative YAML config + # and must win over any stray `options` from a prior UI setup. + data.update(config_entry.options) + data.update(config_entry.data) + else: + # UI-configured entries: settings are meant to live in `options` + # (see OptionsFlowHandler in config_flow.py). `data` here is + # either just the entry name, or - for entries created before + # data/options were split - a stale snapshot from initial setup. + # Applying it last would silently discard newer changes made + # through the options flow, so `options` must win instead. + data.update(config_entry.data) + data.update(config_entry.options) else: assert service_data is not None changed_settings = { diff --git a/tests/test_switch.py b/tests/test_switch.py index eabe226a..ce9d8ec8 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -75,6 +75,7 @@ from homeassistant.components.adaptive_lighting.switch import ( is_our_context, is_our_context_id, short_hash, + validate, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -98,7 +99,7 @@ except ImportError: from homeassistant.components.template.light import LightTemplate from homeassistant.components.template import light as template_light -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, ATTR_ENTITY_ID, @@ -3235,3 +3236,43 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass): assert ( light.brightness == manual_brightness ), f"AL overrode manual brightness {manual_brightness} with {al_brightness}" + + +def test_validate_ui_options_win_over_stale_data(): + """A UI-configured entry's `options` (from the options flow) must win. + + `data` for a `SOURCE_USER` entry either only holds the entry name, or - + for entries created before `data`/`options` were split - a stale + snapshot from initial setup. Either way, a later change made through + the options flow (stored in `options`) must not be silently discarded + by that stale/legacy `data`. + """ + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={CONF_NAME: DEFAULT_NAME, CONF_LIGHTS: ["light.a"]}, + options={CONF_LIGHTS: ["light.a", "light.b"]}, + ) + + result = validate(entry) + + assert result[CONF_LIGHTS] == ["light.a", "light.b"] + + +def test_validate_yaml_data_wins_over_stray_options(): + """A YAML-imported entry's `data` must keep winning over `options`. + + YAML configuration is the source of truth for a `SOURCE_IMPORT` entry, + so any leftover `options` (e.g. from a UI setup that predates the YAML + import) must not override it. + """ + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IMPORT, + data={CONF_NAME: DEFAULT_NAME, CONF_LIGHTS: ["light.a"]}, + options={CONF_LIGHTS: ["light.b"]}, + ) + + result = validate(entry) + + assert result[CONF_LIGHTS] == ["light.a"] 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 27/35] 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): From 67d4a2f657a622ce0ea28fd67e7205393dbc2496 Mon Sep 17 00:00:00 2001 From: proscar87 <68169114+proscar87@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:10:21 -0600 Subject: [PATCH 28/35] fix: clamp() collapsed to minimum when min_brightness > max_brightness (#1507) * fix: clamp() collapsed to minimum when min_brightness > max_brightness A user can intentionally set min_brightness > max_brightness (or the equivalent for color temperature) for an inverted timescale -- e.g. a porch light that should be brighter at night than during the day. clamp()'s max(minimum, min(value, maximum)) assumed minimum <= maximum; when inverted, min(value, maximum) is always <= maximum < minimum, so max(minimum, ...) always returns minimum. linear and tanh brightness modes -- both of which end in a clamp(brightness, min_brightness, max_brightness) call -- got stuck returning one fixed value regardless of the time of day. Fixes #1421 Co-Authored-By: Claude Sonnet 5 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test: accept current Home Assistant brightness validation errors Keep asserting the maximum brightness limit without depending on the validation library's dictionary-path wording. --------- Co-authored-by: Oscar Pacheco Co-authored-by: Claude Sonnet 5 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt Co-authored-by: Bas Nijholt --- .../adaptive_lighting/color_and_brightness.py | 12 ++- tests/test_color_and_brightness.py | 90 +++++++++++++++++++ tests/test_switch.py | 2 +- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index c91278fc..5e1d8a75 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -528,5 +528,13 @@ def lerp(x: float, x1: float, x2: float, y1: float, y2: float) -> float: def clamp(value: float, minimum: float, maximum: float) -> float: - """Clamp value between minimum and maximum.""" - return max(minimum, min(value, maximum)) + """Clamp value between minimum and maximum. + + `minimum` is not assumed to be <= `maximum`: a user may intentionally + configure `min_brightness > max_brightness` (or the equivalent for color + temperature) for an inverted timescale (#1421). Sort the bounds first so + that case clamps against the real lower/upper bound instead of + collapsing to `minimum` for every input. + """ + low, high = (minimum, maximum) if minimum <= maximum else (maximum, minimum) + return max(low, min(value, high)) diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 32425055..86cec8a8 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -7,6 +7,8 @@ from astral.location import Location from homeassistant.components.adaptive_lighting.color_and_brightness import ( SunEvent, SunEvents, + SunLightSettings, + clamp, ) # Create a mock astral location object (its `.observer` is passed to `SunEvents`) @@ -207,3 +209,91 @@ def test_closest_event(tzinfo_and_location): event_name, ts = sun_events.closest_event(sunrise) assert event_name == SunEvent.SUNRISE assert ts == location.sunrise(sunrise.date()).timestamp() + + +def _make_brightness_settings( + tzinfo, + location, + *, + min_brightness, + max_brightness, + brightness_mode, +): + """Build a SunLightSettings with only the fields brightness_pct() needs.""" + return SunLightSettings( + name="test", + astral_observer=location.observer, + adapt_until_sleep=False, + max_brightness=max_brightness, + max_color_temp=6500, + min_brightness=min_brightness, + min_color_temp=2000, + sleep_brightness=1, + sleep_rgb_or_color_temp="color_temp", + sleep_color_temp=2000, + sleep_rgb_color=(255, 56, 0), + sunrise_time=None, + min_sunrise_time=None, + max_sunrise_time=None, + sunset_time=None, + min_sunset_time=None, + max_sunset_time=None, + brightness_mode_time_dark=dt.timedelta(minutes=30), + brightness_mode_time_light=dt.timedelta(minutes=30), + brightness_mode=brightness_mode, + timezone=tzinfo, + ) + + +def test_clamp_handles_inverted_bounds(): + """A user can intentionally set min_brightness > max_brightness for an + inverted timescale (#1421, e.g. a porch light that should be brighter at + night than during the day). clamp() must still bound the value between + whichever of the two is actually smaller/larger, not silently collapse + to `minimum` for every input the way `max(minimum, min(value, maximum))` + does when minimum > maximum. + """ + assert clamp(50, 100, 15) == 50 + assert clamp(0, 100, 15) == 15 + assert clamp(200, 100, 15) == 100 + + +def test_clamp_normal_bounds_unaffected(): + """The ordinary min <= max case must keep behaving exactly as before.""" + assert clamp(50, 0, 100) == 50 + assert clamp(-10, 0, 100) == 0 + assert clamp(150, 0, 100) == 100 + + +@pytest.mark.parametrize("brightness_mode", ["linear", "tanh"]) +def test_brightness_pct_varies_with_inverted_brightness_bounds( + tzinfo_and_location, + brightness_mode, +): + """#1421: with min_brightness > max_brightness, linear/tanh modes got + stuck returning min_brightness for every sample, because the final + `clamp(brightness, self.min_brightness, self.max_brightness)` call + collapsed to `minimum` regardless of the computed value. Sampling a few + points around sunrise must show the brightness actually move instead of + being pinned to one value. + """ + tzinfo, location = tzinfo_and_location + settings = _make_brightness_settings( + tzinfo, + location, + min_brightness=100, + max_brightness=15, + brightness_mode=brightness_mode, + ) + + sunrise = location.sunrise(dt.datetime(2022, 6, 1).date()) + samples = [ + settings.brightness_pct( + sunrise + dt.timedelta(minutes=offset), + is_sleep=False, + ) + for offset in (-20, -10, 0, 10, 20) + ] + + assert len({round(value) for value in samples}) > 1, samples + assert all(15 <= value <= 100 for value in samples), samples diff --git a/tests/test_switch.py b/tests/test_switch.py index a48800f2..37e9df38 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1727,7 +1727,7 @@ async def test_change_switch_settings_service(hass): # Test changing to illegal max brightness with pytest.raises( voluptuous.error.MultipleInvalid, - match="value must be at most 100 for dictionary", + match="value must be at most 100", ): await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 5000}) From 55f871fd0cdf07968c822908fe0a8d9e6db1c956 Mon Sep 17 00:00:00 2001 From: proscar87 <68169114+proscar87@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:18:49 -0600 Subject: [PATCH 29/35] Adopt has_entity_name to fix duplicated entity ids on HA 2026.4+ (#1499) * Adopt has_entity_name to fix duplicated entity ids on HA 2026.4+ Since HA core 2026.4 (PR 166246) composes entity names as device name + entity name, and only strips the device prefix when the entity name starts with it. Adaptive Lighting's names ('Adaptive Lighting Sleep Mode: stairs' on device 'Adaptive Lighting: stairs') never match, so new installs get ids like switch.adaptive_lighting_stairs_adaptive_lighting_sleep_mode_stairs. Adopt has_entity_name: the main switch takes the device name ('Adaptive Lighting: '), the simple switches use their role ('Sleep Mode', 'Adapt Brightness', 'Adapt Color'). Unique ids are unchanged, so existing installs keep their entity ids via the registry. Fixes #1459 Co-Authored-By: Claude Fable 5 * Test the new entity ids and that existing ones survive The renamed constants were defined but never asserted, so neither the fresh-install ids nor the registry-preservation claim were covered. Co-Authored-By: Claude Fable 5 * test: keep the apply-service test light on Avoid generating brightness zero in the attribute-change helper, which turns the light off and makes the test depend on the current adaptive brightness. --------- Co-authored-by: proscar87 Co-authored-by: Claude Fable 5 Co-authored-by: Bas Nijholt Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 15 ++++--- tests/test_switch.py | 40 +++++++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 696ae1a2..e5e63b3f 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -852,6 +852,8 @@ def _attributes_have_changed( class AdaptiveSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" + _attr_has_entity_name = True + def __init__( self, hass: HomeAssistant, @@ -1003,9 +1005,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the device if any.""" - return f"Adaptive Lighting: {self._name}" + # The main switch takes the device name "Adaptive Lighting: " + return None @property def unique_id(self) -> str: @@ -1024,7 +1027,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): identifiers={ (DOMAIN, self._name), }, - name=self._name, + name=f"Adaptive Lighting: {self._name}", entry_type=DeviceEntryType.SERVICE, ) @@ -1636,6 +1639,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): class SimpleSwitch(SwitchEntity, RestoreEntity): """Representation of a Adaptive Lighting switch.""" + _attr_has_entity_name = True + def __init__( self, which: str, @@ -1657,8 +1662,8 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): @property def name(self) -> str: - """Return the name of the device if any.""" - return self._name + """Return the name of the entity within its device.""" + return self._which @property def unique_id(self) -> str: diff --git a/tests/test_switch.py b/tests/test_switch.py index 37e9df38..24ead48d 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -155,9 +155,9 @@ ENTITY_LIGHT_2 = "light.light_2" ENTITY_LIGHT_3 = "light.light_3" _SWITCH_FMT = f"{SWITCH_DOMAIN}.{DOMAIN}" ENTITY_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}" -ENTITY_SLEEP_MODE_SWITCH = f"{_SWITCH_FMT}_sleep_mode_{DEFAULT_NAME}" -ENTITY_ADAPT_BRIGHTNESS_SWITCH = f"{_SWITCH_FMT}_adapt_brightness_{DEFAULT_NAME}" -ENTITY_ADAPT_COLOR_SWITCH = f"{_SWITCH_FMT}_adapt_color_{DEFAULT_NAME}" +ENTITY_SLEEP_MODE_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}_sleep_mode" +ENTITY_ADAPT_BRIGHTNESS_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}_adapt_brightness" +ENTITY_ADAPT_COLOR_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}_adapt_color" ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -1092,7 +1092,7 @@ async def test_apply_service(hass): assert entity_id not in switch.lights def increased_brightness(): - return (light._attr_brightness + 100) % 255 + return max(1, (light._attr_brightness + 100) % 255) def increased_color_temp(): return max( @@ -3254,6 +3254,38 @@ async def test_detect_non_ha_changes_with_separate_turn_on_commands(hass): ), f"AL overrode manual brightness {manual_brightness} with {al_brightness}" +async def test_fresh_install_entity_ids(hass): + """Test the entity ids a new install gets with device-relative naming.""" + _, switch = await setup_switch(hass, {}) + + assert switch.entity_id == ENTITY_SWITCH + assert switch.sleep_mode_switch.entity_id == ENTITY_SLEEP_MODE_SWITCH + assert switch.adapt_brightness_switch.entity_id == ENTITY_ADAPT_BRIGHTNESS_SWITCH + assert switch.adapt_color_switch.entity_id == ENTITY_ADAPT_COLOR_SWITCH + + +async def test_existing_entity_ids_are_preserved(hass): + """Test an install predating this change keeps its entity ids. + + The unique ids are unchanged, so the entity registry must keep the + classic `..._sleep_mode_` id instead of renaming the entity. + """ + classic_entity_id = f"{_SWITCH_FMT}_sleep_mode_{DEFAULT_NAME}" + assert classic_entity_id != ENTITY_SLEEP_MODE_SWITCH + + registry = entity_registry.async_get(hass) + registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + f"{DEFAULT_NAME}_sleep_mode", + suggested_object_id=classic_entity_id.split(".", 1)[1], + ) + + _, switch = await setup_switch(hass, {}) + + assert switch.sleep_mode_switch.entity_id == classic_entity_id + + def test_validate_ui_options_win_over_stale_data(): """A UI-configured entry's `options` (from the options flow) must win. From cc99067c732a25e2dc1f927aa227485ecb6f0831 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 09:23:21 +0200 Subject: [PATCH 30/35] fix: keep adapting during polar night and midnight sun (#1489) Handle missing polar sunrise and sunset with a shared fallback. Bound offsets between actual solar anchors while preserving the daily lighting cycle and configured time behavior. Co-authored-by: Oscar Pacheco --- .../adaptive_lighting/color_and_brightness.py | 82 ++++++- tests/test_color_and_brightness.py | 215 ++++++++++++++++++ 2 files changed, 291 insertions(+), 6 deletions(-) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 5e1d8a75..e62beb0c 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -35,6 +35,12 @@ class SunEvent(str, Enum): _ORDER = (SunEvent.SUNRISE, SunEvent.NOON, SunEvent.SUNSET, SunEvent.MIDNIGHT) _ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))} +# On polar days without a sunrise/sunset, synthetic sun events are placed this +# far from solar noon (polar night) or solar midnight (midnight sun), giving a +# 1-hour synthetic "day" or "night" so the adaptation cycle keeps working. +_POLAR_SUN_EVENT_OFFSET = timedelta(minutes=30) +_POLAR_SUN_EVENT_EPSILON = timedelta(seconds=1) + utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC) utcnow.__doc__ = "Get now in UTC time." @@ -57,13 +63,73 @@ class SunEvents: sunset_offset: datetime.timedelta = datetime.timedelta() timezone: datetime.tzinfo = UTC + def _astral_sunrise_or_sunset( + self, + dt: datetime.date, + event: Literal[SunEvent.SUNRISE, SunEvent.SUNSET], + offset: datetime.timedelta, + ) -> datetime.datetime: + """Return the astral sunrise/sunset, with a fallback for polar regions. + + Above the polar circle the sun never crosses the horizon during polar + night and midnight sun, and `astral` raises a `ValueError` (see #1485). + On such days, synthesize a 1-hour "day" around solar noon (polar night) + or a 1-hour "night" around solar midnight (midnight sun), so the + adaptation cycle keeps working. The `(min/max)_(sunrise/sunset)_time` + options are applied on top of these synthetic times and can be used to + shape the resulting schedule. Configured offsets are limited to the + surrounding solar midnight/noon interval so they cannot invert the + required event order. + """ + astral_event = ( + astral.sun.sunrise if event == SunEvent.SUNRISE else astral.sun.sunset + ) + try: + return astral_event(self.astral_observer, dt) + offset + except ValueError: + noon = astral.sun.noon(self.astral_observer, dt) + midnight = astral.sun.midnight(self.astral_observer, dt) + next_midnight = astral.sun.midnight( + self.astral_observer, + dt + timedelta(days=1), + ) + noon_elevation = astral.sun.elevation(self.astral_observer, noon) + midnight_elevation = astral.sun.elevation(self.astral_observer, midnight) + # The sum of the sun's highest and lowest elevation of the day is + # ≈2x the solar declination, so its sign robustly distinguishes + # midnight sun from polar night, even on the boundary days where + # one elevation hovers around the horizon. + if noon_elevation + midnight_elevation > 0: + # Midnight sun: the sun stays above the horizon all day. + synthetic = ( + midnight + _POLAR_SUN_EVENT_OFFSET + if event == SunEvent.SUNRISE + else next_midnight - _POLAR_SUN_EVENT_OFFSET + ) + else: + # Polar night: the sun stays below the horizon all day. + sign = -1 if event == SunEvent.SUNRISE else 1 + synthetic = noon + sign * _POLAR_SUN_EVENT_OFFSET + + lower, upper = ( + (midnight, noon) if event == SunEvent.SUNRISE else (noon, next_midnight) + ) + return min( + max(synthetic + offset, lower + _POLAR_SUN_EVENT_EPSILON), + upper - _POLAR_SUN_EVENT_EPSILON, + ) + def sunrise(self, dt: datetime.date) -> datetime.datetime: """Return the (adjusted) sunrise time for the given datetime.""" sunrise = ( - astral.sun.sunrise(self.astral_observer, dt) + self._astral_sunrise_or_sunset( + dt, + SunEvent.SUNRISE, + self.sunrise_offset, + ) if self.sunrise_time is None - else self._replace_time(dt, self.sunrise_time) - ) + self.sunrise_offset + else self._replace_time(dt, self.sunrise_time) + self.sunrise_offset + ) if self.min_sunrise_time is not None: min_sunrise = self._replace_time(dt, self.min_sunrise_time) sunrise = max(min_sunrise, sunrise) @@ -75,10 +141,14 @@ class SunEvents: def sunset(self, dt: datetime.date) -> datetime.datetime: """Return the (adjusted) sunset time for the given datetime.""" sunset = ( - astral.sun.sunset(self.astral_observer, dt) + self._astral_sunrise_or_sunset( + dt, + SunEvent.SUNSET, + self.sunset_offset, + ) if self.sunset_time is None - else self._replace_time(dt, self.sunset_time) - ) + self.sunset_offset + else self._replace_time(dt, self.sunset_time) + self.sunset_offset + ) if self.min_sunset_time is not None: min_sunset = self._replace_time(dt, self.min_sunset_time) sunset = max(min_sunset, sunset) diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 86cec8a8..e7f0a21a 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -1,10 +1,12 @@ import datetime as dt import zoneinfo +import astral.sun import pytest from astral import LocationInfo from astral.location import Location from homeassistant.components.adaptive_lighting.color_and_brightness import ( + _POLAR_SUN_EVENT_OFFSET, SunEvent, SunEvents, SunLightSettings, @@ -297,3 +299,216 @@ def test_brightness_pct_varies_with_inverted_brightness_bounds( assert len({round(value) for value in samples}) > 1, samples assert all(15 <= value <= 100 for value in samples), samples + + +# Tromsø, Norway (69.6°N) has polar night (Nov-Jan) and midnight sun (May-Jul). +TROMSO = Location( + LocationInfo( + name="Tromsø", + region="Norway", + timezone="Europe/Oslo", + latitude=69.6489, + longitude=18.9551, + ), +) +POLAR_NIGHT_DATE = dt.date(2026, 1, 7) +MIDNIGHT_SUN_DATE = dt.date(2026, 7, 7) +MCMURDO = Location( + LocationInfo( + name="McMurdo Station", + region="Antarctica", + timezone="Antarctica/McMurdo", + latitude=-77.8419, + longitude=166.6863, + ), +) + + +def _polar_sun_events(location=TROMSO, **kwargs): + defaults = { + "name": "test", + "astral_observer": location.observer, + "sunrise_time": None, + "min_sunrise_time": None, + "max_sunrise_time": None, + "sunset_time": None, + "min_sunset_time": None, + "max_sunset_time": None, + "timezone": zoneinfo.ZoneInfo(location.timezone), + } + return SunEvents(**{**defaults, **kwargs}) + + +def test_polar_night_synthesizes_short_day(): + # `astral` cannot compute sunrise/sunset (the sun never rises), see #1485 + with pytest.raises(ValueError): # noqa: PT011 + astral.sun.sunrise(TROMSO.observer, POLAR_NIGHT_DATE) + sun_events = _polar_sun_events() + noon = astral.sun.noon(TROMSO.observer, POLAR_NIGHT_DATE) + assert sun_events.sunrise(POLAR_NIGHT_DATE) == noon - _POLAR_SUN_EVENT_OFFSET + assert sun_events.sunset(POLAR_NIGHT_DATE) == noon + _POLAR_SUN_EVENT_OFFSET + + +def test_midnight_sun_synthesizes_short_night(): + # `astral` cannot compute sunrise/sunset (the sun never sets), see #1485 + with pytest.raises(ValueError): # noqa: PT011 + astral.sun.sunset(TROMSO.observer, MIDNIGHT_SUN_DATE) + sun_events = _polar_sun_events() + midnight = astral.sun.midnight(TROMSO.observer, MIDNIGHT_SUN_DATE) + next_midnight = astral.sun.midnight( + TROMSO.observer, + MIDNIGHT_SUN_DATE + dt.timedelta(days=1), + ) + assert sun_events.sunrise(MIDNIGHT_SUN_DATE) == midnight + _POLAR_SUN_EVENT_OFFSET + assert ( + sun_events.sunset(MIDNIGHT_SUN_DATE) == next_midnight - _POLAR_SUN_EVENT_OFFSET + ) + + +@pytest.mark.parametrize( + ("date", "midnight_sun"), + [(dt.date(2026, 1, 7), True), (dt.date(2026, 7, 7), False)], +) +def test_polar_fallback_handles_southern_hemisphere(date, midnight_sun): + sun_events = _polar_sun_events(MCMURDO) + noon = astral.sun.noon(MCMURDO.observer, date) + midnight = astral.sun.midnight(MCMURDO.observer, date) + next_midnight = astral.sun.midnight(MCMURDO.observer, date + dt.timedelta(days=1)) + + if midnight_sun: + assert sun_events.sunrise(date) == midnight + _POLAR_SUN_EVENT_OFFSET + assert sun_events.sunset(date) == next_midnight - _POLAR_SUN_EVENT_OFFSET + else: + assert sun_events.sunrise(date) == noon - _POLAR_SUN_EVENT_OFFSET + assert sun_events.sunset(date) == noon + _POLAR_SUN_EVENT_OFFSET + + +def test_boundary_day_with_real_sunrise_and_synthetic_sunset(): + # At the start of the midnight sun period, `astral` computes a real + # sunrise for this date but raises for sunset (this exact date depends on + # astral's numerics). The synthetic sunset must stay consistent with the + # nearly 24-hour day instead of collapsing into a polar-night day. + date = dt.date(2026, 5, 18) + astral.sun.sunrise(TROMSO.observer, date) # does not raise + with pytest.raises(ValueError): # noqa: PT011 + astral.sun.sunset(TROMSO.observer, date) + sun_events = _polar_sun_events() + day_length = sun_events.sunset(date) - sun_events.sunrise(date) + assert day_length > dt.timedelta(hours=22) + + +@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE]) +def test_sun_position_on_polar_days(date): + sun_events = _polar_sun_events() + datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc) + noon, midnight = sun_events.noon_and_midnight(datetime) + assert sun_events.sun_position(noon) == 1 + assert sun_events.sun_position(midnight) == -1 + assert sun_events.sun_position(sun_events.sunrise(date)) == 0 + assert sun_events.sun_position(sun_events.sunset(date)) == 0 + + +def test_polar_night_min_max_times_shape_the_synthetic_day(): + # The (min/max)_(sunrise/sunset)_time options apply on top of the + # synthetic sun events, so users can still shape their schedule. + sun_events = _polar_sun_events( + max_sunrise_time=dt.time(9, 0), + min_sunset_time=dt.time(17, 0), + timezone=dt.timezone.utc, + ) + expected_sunrise = dt.datetime(2026, 1, 7, 9, 0, tzinfo=dt.timezone.utc) + expected_sunset = dt.datetime(2026, 1, 7, 17, 0, tzinfo=dt.timezone.utc) + assert sun_events.sunrise(POLAR_NIGHT_DATE) == expected_sunrise + assert sun_events.sunset(POLAR_NIGHT_DATE) == expected_sunset + + +@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE]) +@pytest.mark.parametrize( + ("sunrise_offset", "sunset_offset"), + [ + (dt.timedelta(hours=-20), dt.timedelta(hours=-20)), + (dt.timedelta(hours=-20), dt.timedelta(hours=20)), + (dt.timedelta(hours=20), dt.timedelta(hours=-20)), + (dt.timedelta(hours=20), dt.timedelta(hours=20)), + ], +) +def test_polar_offsets_cannot_invert_event_order( + date, + sunrise_offset, + sunset_offset, +): + sun_events = _polar_sun_events( + sunrise_offset=sunrise_offset, + sunset_offset=sunset_offset, + ) + + events = dict( + sun_events.sun_events(dt.datetime.combine(date, dt.time(), tzinfo=dt.UTC)), + ) + midnight = dt.datetime.fromtimestamp(events[SunEvent.MIDNIGHT], tz=dt.UTC) + next_midnight = astral.sun.midnight(TROMSO.observer, date + dt.timedelta(days=1)) + noon = dt.datetime.fromtimestamp(events[SunEvent.NOON], tz=dt.UTC) + sunrise = dt.datetime.fromtimestamp(events[SunEvent.SUNRISE], tz=dt.UTC) + sunset = dt.datetime.fromtimestamp(events[SunEvent.SUNSET], tz=dt.UTC) + + assert midnight < sunrise < noon < sunset < next_midnight + + +def test_polar_fallback_applies_offsets_within_solar_anchors(): + offset = dt.timedelta(minutes=15) + plain = _polar_sun_events() + shifted = _polar_sun_events( + sunrise_offset=offset, + sunset_offset=offset, + ) + + assert ( + shifted.sunrise(MIDNIGHT_SUN_DATE) - plain.sunrise(MIDNIGHT_SUN_DATE) == offset + ) + assert shifted.sunset(MIDNIGHT_SUN_DATE) - plain.sunset(MIDNIGHT_SUN_DATE) == offset + + +def test_sun_position_all_year_in_polar_region(): + # Covers the transitions into and out of polar night and midnight sun; + # `sun_position` internally validates the order of the sun events. + sun_events = _polar_sun_events() + datetime = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc) + end = dt.datetime(2027, 1, 1, tzinfo=dt.timezone.utc) + while datetime < end: + position = sun_events.sun_position(datetime) + assert -1 <= position <= 1 + datetime += dt.timedelta(hours=8) + + +@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE]) +def test_brightness_and_color_on_polar_days(date): + settings = SunLightSettings( + name="test", + astral_observer=TROMSO.observer, + adapt_until_sleep=False, + max_brightness=100, + max_color_temp=5500, + min_brightness=30, + min_color_temp=2000, + sleep_brightness=1, + sleep_rgb_or_color_temp="color_temp", + sleep_color_temp=1000, + sleep_rgb_color=(255, 56, 0), + sunrise_time=None, + min_sunrise_time=None, + max_sunrise_time=None, + sunset_time=None, + min_sunset_time=None, + max_sunset_time=None, + brightness_mode_time_dark=dt.timedelta(hours=1), + brightness_mode_time_light=dt.timedelta(hours=1), + timezone=zoneinfo.ZoneInfo("Europe/Oslo"), + ) + datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc) + noon, midnight = settings.sun.noon_and_midnight(datetime) + at_noon = settings.brightness_and_color(noon, is_sleep=False) + assert at_noon["brightness_pct"] == 100 + assert at_noon["color_temp_kelvin"] == 5500 + at_midnight = settings.brightness_and_color(midnight, is_sleep=False) + assert at_midnight["brightness_pct"] == 30 + assert at_midnight["color_temp_kelvin"] == 2000 From 9b0f03045b989179b4bf87af80a381bfda21fff3 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:54:58 -0700 Subject: [PATCH 31/35] docs: credit jaredjxyz for code contributions (#1520) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 29 +++++++++++++++++++---------- README.md | 9 +++++---- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3d8fb965..c1c67a8b 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -87,7 +87,7 @@ }, { "login": "Repsionu", - "name": "J\u00fcri Rebane", + "name": "Jüri Rebane", "avatar_url": "https://avatars.githubusercontent.com/u/46962963?v=4", "profile": "https://github.com/Repsionu", "contributions": [ @@ -195,7 +195,7 @@ }, { "login": "Hypfer", - "name": "S\u00f6ren Beye", + "name": "Sören Beye", "avatar_url": "https://avatars.githubusercontent.com/u/974410?v=4", "profile": "http://hypfer.de/", "contributions": [ @@ -387,7 +387,7 @@ }, { "login": "brebtatv", - "name": "Tom\u00e1\u0161 Valigura", + "name": "Tomáš Valigura", "avatar_url": "https://avatars.githubusercontent.com/u/10747062?v=4", "profile": "https://github.com/brebtatv", "contributions": [ @@ -524,7 +524,7 @@ }, { "login": "letroll", - "name": "Julien Qui\u00e9vreux", + "name": "Julien Quiévreux", "avatar_url": "https://avatars.githubusercontent.com/u/255774?v=4", "profile": "http://www.latavernedutroll.fr", "contributions": [ @@ -642,7 +642,7 @@ }, { "login": "mstefany", - "name": "Martin \u0160tefany", + "name": "Martin Štefany", "avatar_url": "https://avatars.githubusercontent.com/u/57348587?v=4", "profile": "https://stefany.eu", "contributions": [ @@ -696,7 +696,7 @@ }, { "login": "jansigu", - "name": "Jan-Sigurd S\u00f8rensen", + "name": "Jan-Sigurd Sørensen", "avatar_url": "https://avatars.githubusercontent.com/u/8410766?v=4", "profile": "http://www.jan-sigurd.com", "contributions": [ @@ -849,7 +849,7 @@ }, { "login": "MrEbbinghaus", - "name": "Bj\u00f6rn Ebbinghaus", + "name": "Björn Ebbinghaus", "avatar_url": "https://avatars.githubusercontent.com/u/2965273?v=4", "profile": "https://blog.ebbinghaus.me/", "contributions": [ @@ -894,7 +894,7 @@ }, { "login": "TamilNeram", - "name": "\u0ba4\u0bae\u0bbf\u0bb4\u0bcd \u0ba8\u0bc7\u0bb0\u0bae\u0bcd", + "name": "தமிழ் நேரம்", "avatar_url": "https://avatars.githubusercontent.com/u/67970539?v=4", "profile": "https://github.com/TamilNeram", "contributions": [ @@ -939,7 +939,7 @@ }, { "login": "marazmarci", - "name": "M\u00e1rton Mar\u00e1z", + "name": "Márton Maráz", "avatar_url": "https://avatars.githubusercontent.com/u/1349654?v=4", "profile": "https://github.com/marazmarci", "contributions": [ @@ -1128,7 +1128,7 @@ }, { "login": "maksim2005UKR", - "name": "\u0413\u043e\u0440\u043f\u0438\u043d\u0456\u0447 \u041c\u0430\u043a\u0441\u0438\u043c \u041e\u043b\u0435\u043a\u0441\u0430\u043d\u0434\u0440\u043e\u0432\u0438\u0447", + "name": "Горпиніч Максим Олександрович", "avatar_url": "https://avatars.githubusercontent.com/u/233082001?v=4", "profile": "https://github.com/maksim2005UKR", "contributions": [ @@ -1260,6 +1260,15 @@ "contributions": [ "code" ] + }, + { + "login": "jaredjxyz", + "name": "Jared Jensen", + "avatar_url": "https://avatars.githubusercontent.com/u/10385335?v=4", + "profile": "http://jaredj.xyz/", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 4695cf81..7979ec41 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-138-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-139-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -669,6 +669,10 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Esspel
Esspel

🌍 Corey Peruffo
Corey Peruffo

💻 + Samson Brock
Samson Brock

💻 + Dennis Dekker
Dennis Dekker

💻 + proscar87
proscar87

💻 + Jared Jensen
Jared Jensen

💻 @@ -678,9 +682,6 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Add your contributions - Samson Brock
Samson Brock

💻 - Dennis Dekker
Dennis Dekker

💻 - proscar87
proscar87

💻 From 61896eb86a40c61a79ad309c72750edd482cc111 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:55:27 -0700 Subject: [PATCH 32/35] docs: credit mueslo for code contributions (#1521) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index c1c67a8b..7649cd78 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1269,6 +1269,15 @@ "contributions": [ "code" ] + }, + { + "login": "mueslo", + "name": "mueslo", + "avatar_url": "https://avatars.githubusercontent.com/u/847751?v=4", + "profile": "https://github.com/mueslo", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 7979ec41..7b13e678 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-139-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-140-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -673,6 +673,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark Dennis Dekker
Dennis Dekker

💻 proscar87
proscar87

💻 Jared Jensen
Jared Jensen

💻 + mueslo
mueslo

💻 From e453541a788790e084d680f07ff28fc98fb9a336 Mon Sep 17 00:00:00 2001 From: Jared Jensen Date: Sun, 6 Sep 2026 00:59:38 -0700 Subject: [PATCH 33/35] feat: expose per-attribute manual-control state (#1469) Adds two new read-only state attributes on the AdaptiveSwitch entity: - manual_control_brightness: list of light entity_ids whose brightness axis is currently in manual override (i.e. AL is paused for brightness on those lights). - manual_control_color: same, for the color axis. These mirror the existing 'manual_control' attribute (which is a union of both axes) but expose the LightControlAttributes bitfield that AL already tracks internally per-light. Why: the existing 'manual_control' state attribute and the adaptive_lighting.manual_control event are useful, but neither lets a template or dashboard see which axis is paused without subscribing to events. This is especially important with take_over_control_mode: pause_changed, where one axis can be manual while the other still adapts. Now a Lovelace card or template sensor can display brightness/color manual state directly via state_attr(). Test: extends test_manual_control to assert the new attributes reflect the bitfield correctly. No new platform, no breaking changes. Co-authored-by: Bas Nijholt --- README.md | 14 ++++++++++++++ custom_components/adaptive_lighting/switch.py | 12 ++++++++++++ docs/advanced/manual-control.md | 14 ++++++++++++++ tests/test_switch.py | 15 +++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/README.md b/README.md index 7b13e678..af0c9611 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,20 @@ This feature is available when `take_over_control` is enabled. Additionally, enabling `detect_non_ha_changes` allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings. The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖. +The Adaptive Lighting switch exposes these read-only attributes for its lights: + +- `manual_control`: lights with any attribute marked as manually controlled. +- `manual_control_brightness`: lights with brightness marked as manually controlled. +- `manual_control_color`: lights with color marked as manually controlled. + +These lists report manual-control flags. Actual adaptation also depends on `take_over_control_mode` and the brightness/color adaptation switches. For example, under the default `pause_all` mode, manually changing only brightness leaves `manual_control_color` empty while pausing both brightness and color adaptation. Under `pause_changed`, color can continue adapting. + +The attributes are absent when the Adaptive Lighting switch is off. Use a fallback when checking them in templates: + +```jinja +{{ 'light.bedroom' in (state_attr('switch.adaptive_lighting_bedroom', 'manual_control_brightness') or []) }} +``` + > ⚠️ **_Caution: Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable `detect_non_ha_changes` if you encounter such issues._** diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index e5e63b3f..0cd23a7b 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1151,6 +1151,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): extra_state_attributes["manual_control"] = [ light for light in self.lights if self.manager.manual_control.get(light) ] + extra_state_attributes["manual_control_brightness"] = [ + light + for light in self.lights + if self.manager.manual_control.get(light, LightControlAttributes.NONE) + & LightControlAttributes.BRIGHTNESS + ] + extra_state_attributes["manual_control_color"] = [ + light + for light in self.lights + if self.manager.manual_control.get(light, LightControlAttributes.NONE) + & LightControlAttributes.COLOR + ] extra_state_attributes.update(self._settings) timers = self.manager.auto_reset_manual_control_timers extra_state_attributes["autoreset_time_remaining"] = { diff --git a/docs/advanced/manual-control.md b/docs/advanced/manual-control.md index 36720f3c..4e84ca3f 100644 --- a/docs/advanced/manual-control.md +++ b/docs/advanced/manual-control.md @@ -20,6 +20,20 @@ This feature is available when `take_over_control` is enabled. Additionally, enabling `detect_non_ha_changes` allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings. The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖. +The Adaptive Lighting switch exposes these read-only attributes for its lights: + +- `manual_control`: lights with any attribute marked as manually controlled. +- `manual_control_brightness`: lights with brightness marked as manually controlled. +- `manual_control_color`: lights with color marked as manually controlled. + +These lists report manual-control flags. Actual adaptation also depends on `take_over_control_mode` and the brightness/color adaptation switches. For example, under the default `pause_all` mode, manually changing only brightness leaves `manual_control_color` empty while pausing both brightness and color adaptation. Under `pause_changed`, color can continue adapting. + +The attributes are absent when the Adaptive Lighting switch is off. Use a fallback when checking them in templates: + +```jinja +{{ 'light.bedroom' in (state_attr('switch.adaptive_lighting_bedroom', 'manual_control_brightness') or []) }} +``` + > ⚠️ **_Caution: Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable `detect_non_ha_changes` if you encounter such issues._** diff --git a/tests/test_switch.py b/tests/test_switch.py index 24ead48d..3ba39aca 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -738,10 +738,19 @@ async def test_manual_control( await turn_light(True, brightness=increased_brightness()) # Check that ENTITY_LIGHT_1 is manually controlled assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.BRIGHTNESS + # Per-attribute state attributes should reflect this + state_attrs = hass.states.get(switch.entity_id).attributes + assert ENTITY_LIGHT_1 in state_attrs["manual_control"] + assert ENTITY_LIGHT_1 in state_attrs["manual_control_brightness"] + assert ENTITY_LIGHT_1 not in state_attrs["manual_control_color"] # Test adaptive_lighting.set_manual_control await change_manual_control(False) # Check that ENTITY_LIGHT_1 is not manually controlled assert not manual_control[ENTITY_LIGHT_1] + state_attrs = hass.states.get(switch.entity_id).attributes + assert ENTITY_LIGHT_1 not in state_attrs["manual_control"] + assert ENTITY_LIGHT_1 not in state_attrs["manual_control_brightness"] + assert ENTITY_LIGHT_1 not in state_attrs["manual_control_color"] # Check that toggling light off to on resets manual control await change_manual_control(True) @@ -865,6 +874,9 @@ async def test_manual_control( assert not manual_control[ENTITY_LIGHT_1] await change_manual_control(True) assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL + state_attrs = hass.states.get(switch.entity_id).attributes + assert state_attrs["manual_control_brightness"] == [ENTITY_LIGHT_1] + assert state_attrs["manual_control_color"] == [ENTITY_LIGHT_1] # Check that manual control `False` unsets all attributes await change_manual_control(False) @@ -875,6 +887,9 @@ async def test_manual_control( assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.BRIGHTNESS await change_manual_control("color") assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.COLOR + state_attrs = hass.states.get(switch.entity_id).attributes + assert state_attrs["manual_control_brightness"] == [] + assert state_attrs["manual_control_color"] == [ENTITY_LIGHT_1] @flaky(max_runs=3, min_passes=1) From 68c0e4db69a4026d0ead942d11507ecf2dbabe7e Mon Sep 17 00:00:00 2001 From: mueslo Date: Sun, 6 Sep 2026 10:04:50 +0200 Subject: [PATCH 34/35] fix: preserve Home Assistant area target exclusions (#1511) * Exclude 'service' lights (entity_category) from area intercept turn-on Fixes #1510 When Adaptive Lighting intercepts an area/label-targeted light.turn_on, the intercept rewrites the call to target only the managed lights (see modify_service_data), so Home Assistant's own handler turns on only those. AL then re-issues light.turn_on for the remaining 'skipped' (unmanaged) entities so they still come on. The problem: HA excludes entities with an entity_category (config/diagnostic, e.g. the Home Assistant Voice LED ring) from area/label expansion, but AL's re-issue did not, so AL was the sole thing turning these service lights on. Changes: - Keep re-issuing skipped lights so unmanaged normal lights still come on, but filter out 'service' lights (entity_category set) from that re-issue. - Add _is_service_light helper (registry-based) and divert a *managed* service light to 'skipped' in _separate_entity_ids, so it is excluded from the intercept turn-on while still being adapted when on. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add toggle regression test for service light exclusion from area intercept Mirrors test_service_light_excluded_from_area_intercept_turn_on but uses light.toggle: a service light (entity_category set) in an area must remain off when AL intercepts an area toggle. The managed lights still toggle on. Refs #1510, PR #1511. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Only exclude service lights from indirect (area/device/label) expansion Previously _is_service_light filtered service lights unconditionally, which also excluded a service light explicitly named in `entity_id`. Home Assistant only excludes such lights from indirect area/device/label expansion and turns them on when directly targeted, so AL must mirror that: service lights are now excluded from the intercept/re-issue only when not directly targeted (`direct_entity_ids`). Adds a regression test for the direct-target case. Refs #1510, PR #1511. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use if/elif/else for indirect service-light exclusion; inline check Address review: collapse the two separate `if is_service`/"if not is_service" into an `if/elif/else` chain (ruff PLR5501) and inline the service check as `self._is_service_light(...) and entity_id not in direct_entity_ids`, which conceptually is `is_indirect_service`. Matches HA's indirect-only exclusion and keeps the flow simple. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Filter service lights directly in _get_entity_list expansion Move service-light filtering to the single expansion site AdaptiveLightingManager._get_entity_list. Area/device expansion is the only place where HA excludes entity_category lights, so filtering there mirrors HA and makes the later direct_entity_ids / skipped_normal guards unnecessary. Explicit entity_id targets bypass expansion and therefore still turn on service lights, matching HA. Fully reverts the direct_entity_ids / skipped_normal addition per review. --------- Co-authored-by: mueslo Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 8 ++ tests/test_switch.py | 78 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 0cd23a7b..fb210744 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1859,6 +1859,13 @@ class AdaptiveLightingManager: self._context_cnt += 1 return context + def _is_excluded_from_area(self, entity_id: str) -> bool: + """Match Home Assistant's exclusions for indirect area targets.""" + entry = entity_registry.async_get(self.hass).async_get(entity_id) + return entry is not None and ( + entry.entity_category is not None or entry.hidden_by is not None + ) + def _separate_entity_ids( self, entity_ids: list[str], @@ -2398,6 +2405,7 @@ class AdaptiveLightingManager: entity_id for entity_id in area_entity_ids if entity_id.startswith(LIGHT_DOMAIN) + and not self._is_excluded_from_area(entity_id) ] entity_ids.extend(eids) _LOGGER.debug( diff --git a/tests/test_switch.py b/tests/test_switch.py index 3ba39aca..148c8bd3 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -38,6 +38,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, CONF_MIN_COLOR_TEMP, CONF_MULTI_LIGHT_INTERCEPT, CONF_PREFER_RGB_COLOR, @@ -112,6 +113,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + EntityCategory, ) from homeassistant.const import __version__ as ha_version from homeassistant.core import Context, Event, HomeAssistant, State @@ -1299,7 +1301,11 @@ async def test_state_change_handlers(hass): 4. Assert all possible problems that would result. Also tests significant changes. """ - switch, (light, *_) = await setup_lights_and_switch(hass) + # Keep adaptive brightness distinct from the manual values 20, 40, and 50. + switch, (light, *_) = await setup_lights_and_switch( + hass, + {CONF_MIN_BRIGHTNESS: 50, CONF_MAX_BRIGHTNESS: 50}, + ) context = switch.create_context("test") # needs to be passed to update method # [Config options]: @@ -3339,3 +3345,73 @@ def test_validate_yaml_data_wins_over_stray_options(): result = validate(entry) assert result[CONF_LIGHTS] == ["light.a"] + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TOGGLE]) +@pytest.mark.parametrize("explicit", [False, True], ids=["area", "direct"]) +@pytest.mark.parametrize("managed", [False, True], ids=["unmanaged", "managed"]) +@pytest.mark.parametrize( + "registry_settings", + [ + {}, + {"entity_category": EntityCategory.CONFIG}, + {"entity_category": EntityCategory.DIAGNOSTIC}, + {"hidden_by": entity_registry.RegistryEntryHider.USER}, + ], + ids=["normal", "config", "diagnostic", "hidden"], +) +async def test_intercept_preserves_area_target_exclusions( + hass: HomeAssistant, + service: str, + explicit: bool, + managed: bool, + registry_settings: dict[str, Any], +): + """Area calls exclude hidden/categorized lights; direct calls honor them.""" + await setup_lights(hass) + mock_area_registry(hass) + registry = entity_registry.async_get(hass) + lights = [ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3] + for light in lights: + registry.async_update_entity(light, area_id="test-area") + registry.async_update_entity(ENTITY_LIGHT_3, **registry_settings) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: lights}, + blocking=True, + ) + await hass.async_block_till_done() + await setup_switch( + hass, + { + CONF_LIGHTS: ( + [ENTITY_LIGHT_1, ENTITY_LIGHT_3] if managed else [ENTITY_LIGHT_1] + ), + CONF_INTERCEPT: True, + CONF_INITIAL_TRANSITION: 0, + CONF_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + assert all(hass.states.get(light).state == STATE_OFF for light in lights) + + target = {ATTR_ENTITY_ID: lights} if explicit else {ATTR_AREA_ID: "test-area"} + await hass.services.async_call(LIGHT_DOMAIN, service, target, blocking=True) + await hass.async_block_till_done() + + # Both normal lights turn on; only the managed one gets adaptive brightness. + assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128 + assert hass.states.get(ENTITY_LIGHT_2).state == STATE_ON + assert hass.states.get(ENTITY_LIGHT_2).attributes.get(ATTR_BRIGHTNESS) != 128 + target_state = hass.states.get(ENTITY_LIGHT_3) + if registry_settings and not explicit: + assert target_state.state == STATE_OFF + else: + assert target_state.state == STATE_ON + if managed: + assert target_state.attributes[ATTR_BRIGHTNESS] == 128 + else: + assert target_state.attributes.get(ATTR_BRIGHTNESS) != 128 From a2186ecf223fbee0742f56a75a6f09e248d9d0a5 Mon Sep 17 00:00:00 2001 From: proscar87 <68169114+proscar87@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:12:28 -0600 Subject: [PATCH 35/35] perf: stagger periodic adaptation updates (#1500) Spread recurring updates with a deterministic per-switch offset while keeping turn-on adaptation immediate. Register the delayed listener on Home Assistant's event loop and cover cancellation, reconfiguration, and cadence with real timer tests. Closes #939. Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 37 ++++++-- tests/test_switch.py | 85 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index fb210744..44ad9c60 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import datetime +import hashlib import logging import zoneinfo from copy import deepcopy @@ -62,6 +63,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.event import ( EventStateChangedData, + async_call_later, async_track_state_change_event, async_track_time_interval, ) @@ -1081,6 +1083,17 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self.remove_listeners.append(remove_sleep) self._expand_light_groups() + def _stagger_offset(self, adaptation_interval: timedelta) -> timedelta: + """Return a stable relative delay to spread periodic updates. + + Hashing the switch ID gives a best-effort spread without configuration. + It does not delay the immediate turn-on adaptation or guarantee a minimum + gap between switches. + """ + digest = hashlib.sha256(self.unique_id.encode()).digest() + fraction = int.from_bytes(digest[:8], byteorder="big") / 2**64 + return adaptation_interval * fraction + def _update_time_interval_listener(self) -> None: """Create or recreate the adaptation interval listener. @@ -1101,11 +1114,25 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): + timedelta(seconds=processing_overhead_time) ) - self.remove_interval = async_track_time_interval( - self.hass, - action=self._async_update_at_interval_action, - interval=adaptation_interval, - ) + @callback + def _start_periodic_listener(_now: datetime.datetime | None = None) -> None: + self.remove_interval = async_track_time_interval( + self.hass, + action=self._async_update_at_interval_action, + interval=adaptation_interval, + ) + + # Register after the offset. The first periodic tick is at offset + + # interval, then subsequent ticks keep the configured interval. + offset = self._stagger_offset(adaptation_interval) + if offset > timedelta(0): + self.remove_interval = async_call_later( + self.hass, + offset.total_seconds(), + _start_periodic_listener, + ) + else: + _start_periodic_listener() def _call_on_remove_callbacks(self) -> None: """Call callbacks registered by async_on_remove.""" diff --git a/tests/test_switch.py b/tests/test_switch.py index 148c8bd3..b96e56fe 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1589,6 +1589,91 @@ async def test_async_update_at_interval_action(hass): await switch._async_update_at_interval_action() +async def test_stagger_offset_deterministic_and_bounded(hass): + """Test switches get stable relative delays within the interval.""" + interval = datetime.timedelta(seconds=90) + + _, switch_a = await setup_switch(hass, {CONF_NAME: "switch_a"}) + _, switch_b = await setup_switch(hass, {CONF_NAME: "switch_b"}) + + offset_a_1 = switch_a._stagger_offset(interval) + offset_a_2 = switch_a._stagger_offset(interval) + assert offset_a_1 == offset_a_2 + + offset_b = switch_b._stagger_offset(interval) + assert offset_a_1 != offset_b + + for offset in (offset_a_1, offset_b): + assert datetime.timedelta(0) <= offset < interval + + +async def test_disable_cancels_pending_stagger(hass): + """Test disabling the switch cancels delayed interval registration.""" + switch_module = "homeassistant.components.adaptive_lighting.switch" + with ( + patch( + f"{switch_module}.AdaptiveSwitch._stagger_offset", + return_value=datetime.timedelta(seconds=10), + ) as mock_offset, + patch( + f"{switch_module}.async_track_time_interval", + return_value=lambda: None, + ) as mock_track_interval, + ): + _, switch = await setup_switch(hass, {}) + mock_offset.return_value = datetime.timedelta(seconds=0.05) + switch._update_time_interval_listener() + await switch.async_turn_off() + await asyncio.sleep(0.1) + + mock_track_interval.assert_not_called() + + +async def test_reconfigure_replaces_stagger_and_preserves_interval(hass): + """Test the replacement starts at offset + interval and keeps its cadence.""" + calls: list[float] = [] + two_calls = asyncio.Event() + loop = asyncio.get_running_loop() + stagger = datetime.timedelta(seconds=0.2) + + async def record_interval(_now=None): + calls.append(loop.time()) + if len(calls) == 2: + two_calls.set() + + with patch( + "homeassistant.components.adaptive_lighting.switch.AdaptiveSwitch._stagger_offset", + return_value=datetime.timedelta(seconds=10), + ) as mock_offset: + _, switch = await setup_switch(hass, {}) + switch._interval = datetime.timedelta(0) + mock_offset.return_value = stagger + effective_interval = ( + switch._interval + + datetime.timedelta(milliseconds=switch._send_split_delay) + + datetime.timedelta(seconds=0.5) + ) + + with patch.object( + switch, + "_async_update_at_interval_action", + side_effect=record_interval, + ): + switch._update_time_interval_listener() + await asyncio.sleep(0.02) + + replacement_started = loop.time() + switch._update_time_interval_listener() + await asyncio.wait_for(two_calls.wait(), timeout=2) + await switch.async_turn_off() + + first_delay = calls[0] - replacement_started + interval_seconds = effective_interval.total_seconds() + expected_first_delay = interval_seconds + stagger.total_seconds() + assert expected_first_delay - 0.1 <= first_delay < expected_first_delay + 0.5 + assert interval_seconds - 0.1 <= calls[1] - calls[0] < interval_seconds + 0.5 + + @pytest.mark.parametrize("separate_turn_on_commands", (True, False)) async def test_separate_turn_on_commands(hass, separate_turn_on_commands): """Test 'separate_turn_on_commands' argument."""