diff --git a/.all-contributorsrc b/.all-contributorsrc
index e2a901fd..7649cd78 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1224,6 +1224,60 @@
"contributions": [
"translation"
]
+ },
+ {
+ "login": "cperuffo3",
+ "name": "Corey Peruffo",
+ "avatar_url": "https://avatars.githubusercontent.com/u/87686305?v=4",
+ "profile": "https://github.com/cperuffo3",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "imwithsam",
+ "name": "Samson Brock",
+ "avatar_url": "https://avatars.githubusercontent.com/u/1934074?v=4",
+ "profile": "http://badmotivator.io/",
+ "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"
+ ]
+ },
+ {
+ "login": "proscar87",
+ "name": "proscar87",
+ "avatar_url": "https://avatars.githubusercontent.com/u/68169114?v=4",
+ "profile": "https://github.com/proscar87",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "jaredjxyz",
+ "name": "Jared Jensen",
+ "avatar_url": "https://avatars.githubusercontent.com/u/10385335?v=4",
+ "profile": "http://jaredj.xyz/",
+ "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/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index edea4bb5..4783ef38 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -21,16 +21,16 @@ jobs:
matrix:
platform: [linux/amd64, linux/arm64]
steps:
- - uses: actions/checkout@v6
- - uses: docker/setup-qemu-action@v3
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
+ - uses: actions/checkout@v7
+ - uses: docker/setup-qemu-action@v4
+ - uses: docker/setup-buildx-action@v4
+ - uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
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: |
@@ -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 }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 7eb1e082..2204a1ef 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -20,15 +20,15 @@ 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@v6
+ uses: actions/setup-python@v7
with:
- python-version: '3.14.2'
+ python-version: '3.14.7'
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v10.0.1
- name: Install dependencies
run: uv sync --group docs
@@ -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
@@ -61,4 +61,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
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 4ab58169..9b8dbb08 100644
--- a/.github/workflows/install_dependencies/action.yml
+++ b/.github/workflows/install_dependencies/action.yml
@@ -14,25 +14,25 @@ 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
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
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v10.0.1
- name: Install dependencies
shell: bash
run: |
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 efd76831..279a7f8a 100644
--- a/.github/workflows/markdown-code-runner.yml
+++ b/.github/workflows/markdown-code-runner.yml
@@ -11,19 +11,19 @@ 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 }}
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v7
with:
- python-version: "3.14.2"
+ python-version: "3.14.7"
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v10.0.1
- name: Update generated content
run: ./scripts/update-generated-content
diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml
index 57263d00..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/setup-python@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/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 }}
diff --git a/.github/workflows/update-test-matrix.yaml b/.github/workflows/update-test-matrix.yaml
index 5f948a67..e67ae227 100644
--- a/.github/workflows/update-test-matrix.yaml
+++ b/.github/workflows/update-test-matrix.yaml
@@ -16,12 +16,12 @@ 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@v6
+ 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
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:
diff --git a/README.md b/README.md
index 6bbd10ad..6a2f024a 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
[](https://github.com/hacs/integration)

-[](#contributors-)
+[](#contributors-)
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙
@@ -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._**
@@ -409,6 +423,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.
@@ -663,6 +682,12 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
 Esspel 🌍 |
+  Corey Peruffo 💻 |
+  Samson Brock 💻 |
+  Dennis Dekker 💻 |
+  proscar87 💻 |
+  Jared Jensen 💻 |
+  mueslo 💻 |
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/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py
index f3ae3efe..e62beb0c 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."""
@@ -37,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."
@@ -48,7 +52,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
@@ -59,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 = (
- self.astral_location.sunrise(dt, local=False)
+ 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)
@@ -77,10 +141,14 @@ 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)
+ 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)
@@ -113,8 +181,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 +276,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 +304,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,
@@ -530,5 +598,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/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"
}
diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py
index 91049970..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,11 +63,11 @@ 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,
)
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 +165,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__)
@@ -553,8 +567,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 = {
@@ -828,6 +854,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,
@@ -944,11 +972,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],
@@ -979,9 +1007,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:
@@ -1000,7 +1029,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
identifiers={
(DOMAIN, self._name),
},
- name=self._name,
+ name=f"Adaptive Lighting: {self._name}",
entry_type=DeviceEntryType.SERVICE,
)
@@ -1054,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.
@@ -1074,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."""
@@ -1124,6 +1178,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"] = {
@@ -1612,6 +1678,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,
@@ -1633,8 +1701,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:
@@ -1818,6 +1886,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],
@@ -2242,7 +2317,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)
@@ -2358,6 +2432,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(
@@ -2718,19 +2793,61 @@ 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)
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:
@@ -2758,6 +2875,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.",
@@ -2913,9 +3058,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/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/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/scripts/setup-dependencies b/scripts/setup-dependencies
index d69bce3c..0a96dd77 100755
--- a/scripts/setup-dependencies
+++ b/scripts/setup-dependencies
@@ -5,16 +5,20 @@ 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
# 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}"
@@ -35,7 +39,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
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_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(
diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py
index cc3e8c4d..e7f0a21a 100644
--- a/tests/test_color_and_brightness.py
+++ b/tests/test_color_and_brightness.py
@@ -1,15 +1,19 @@
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,
+ clamp,
)
-# 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 +44,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 +65,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 +83,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 +111,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 +138,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 +157,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 +177,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 +197,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,
@@ -207,3 +211,304 @@ 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
+
+
+# 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
diff --git a/tests/test_switch.py b/tests/test_switch.py
index 470e0e25..b96e56fe 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,
@@ -75,6 +76,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,
@@ -97,7 +99,8 @@ except ImportError:
# HA < 2025.8
from homeassistant.components.template.light import LightTemplate
-from homeassistant.config_entries import ConfigEntryState
+from homeassistant.components.template import light as template_light
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_ENTITY_ID,
@@ -110,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
@@ -121,6 +125,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(
@@ -149,9 +157,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
@@ -200,37 +208,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:
@@ -704,10 +740,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)
@@ -831,6 +876,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)
@@ -841,6 +889,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)
@@ -880,11 +931,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):
@@ -1042,7 +1109,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(
@@ -1234,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]:
@@ -1518,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."""
@@ -1677,7 +1833,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})
@@ -2396,6 +2552,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):
@@ -2997,3 +3358,145 @@ 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}"
+
+
+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.
+
+ `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"]
+
+
+@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
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)
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