Merge remote-tracking branch 'origin/main' into fix/readme-setup-1031

This commit is contained in:
Bas Nijholt 2026-09-06 01:29:11 -07:00
commit 8b35866bf6
27 changed files with 1436 additions and 168 deletions

View file

@ -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,

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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: |

View file

@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -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:

View file

@ -1,7 +1,7 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration)
![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-134-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-140-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
# 🌞 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._**
<!-- SECTION:manual-control:END -->
@ -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
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Esspel"><img src="https://avatars.githubusercontent.com/u/47383506?v=4?s=100" width="100px;" alt="Esspel"/><br /><sub><b>Esspel</b></sub></a><br /><a href="#translation-Esspel" title="Translation">🌍</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/cperuffo3"><img src="https://avatars.githubusercontent.com/u/87686305?v=4?s=100" width="100px;" alt="Corey Peruffo"/><br /><sub><b>Corey Peruffo</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=cperuffo3" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://badmotivator.io/"><img src="https://avatars.githubusercontent.com/u/1934074?v=4?s=100" width="100px;" alt="Samson Brock"/><br /><sub><b>Samson Brock</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=imwithsam" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Dennis-Dekker"><img src="https://avatars.githubusercontent.com/u/48018095?v=4?s=100" width="100px;" alt="Dennis Dekker"/><br /><sub><b>Dennis Dekker</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=Dennis-Dekker" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/proscar87"><img src="https://avatars.githubusercontent.com/u/68169114?v=4?s=100" width="100px;" alt="proscar87"/><br /><sub><b>proscar87</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=proscar87" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://jaredj.xyz/"><img src="https://avatars.githubusercontent.com/u/10385335?v=4?s=100" width="100px;" alt="Jared Jensen"/><br /><sub><b>Jared Jensen</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=jaredjxyz" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/mueslo"><img src="https://avatars.githubusercontent.com/u/847751?v=4?s=100" width="100px;" alt="mueslo"/><br /><sub><b>mueslo</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=mueslo" title="Code">💻</a></td>
</tr>
</tbody>
<tfoot>

View file

@ -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)
}

View file

@ -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))

View file

@ -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"
}

View file

@ -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: <name>"
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."""

View file

@ -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._**
<!-- OUTPUT:END -->

View file

@ -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.

View file

@ -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

View file

@ -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()

View file

@ -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(

View file

@ -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

View file

@ -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_<name>` 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

View file

@ -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,
}

View file

@ -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)

View file

@ -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