feat: add fixed evening color temperature schedule

This commit is contained in:
Charles-HL 2026-10-19 00:21:34 +02:00
commit 90d9bbc201
13 changed files with 957 additions and 8 deletions

View file

@ -158,6 +158,8 @@ The YAML and frontend configuration methods support all of the options listed be
| `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` |
| `color_temp_plateau_morning` | The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️ | `0` | `int` 0-43200 |
| `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 |
| `color_temp_fixed_plateau_end` | Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year, instead of following the sun. Needs `color_temp_fixed_descent_end`; unset keeps the default curve. Only affects the color temperature. 🌙 | `None` | `str` (HH:MM:SS) |
| `color_temp_fixed_descent_end` | Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight, at most 12 hours later. Needs `color_temp_fixed_plateau_end`; unset keeps the default curve. 🌙 | `None` | `str` (HH:MM:SS) |
| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` |
| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` |
| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` |

View file

@ -247,6 +247,66 @@ class SunEvents:
i_now = bisect.bisect([ts for _, ts in events], dt.timestamp())
return events[i_now - 1 : i_now + 1]
def fixed_evening_position(
self,
dt: datetime.datetime,
plateau_end: datetime.time,
descent_end: datetime.time,
) -> float | None:
"""Position in [0, 1] for the fixed evening color temperature, or None.
`plateau_end` and `descent_end` are local wall-clock times. Starting at
the last solar noon before `dt`, the position is 1 until the first
`plateau_end` after that noon (or the noon itself if it is later), then
eases from 1 to 0 (cosine, so without kinks) until the `descent_end`
following it, and stays 0 until sunset if that is later. After that
(or before the noon) None is returned and the regular curve applies.
The window is cut at the next sunrise, so the position reaches 0 no
later than the morning curve starts at 0, and it is ignored for a day
where it does not fit between noon and the next sunrise (None). The
times are wall-clock: across a DST change the real duration changes
by the shift, but the position stays continuous and monotonic.
"""
target_ts = dt.timestamp()
events = sorted(
(event, ts)
for days in (-1, 0, 1)
for event, ts in self.sun_events(dt + timedelta(days=days))
)
noons = [ts for event, ts in events if event == SunEvent.NOON]
noon_ts = max((ts for ts in noons if ts <= target_ts), default=None)
if noon_ts is None:
return None
sunrise_ts = min(
(ts for e, ts in events if e == SunEvent.SUNRISE and ts > noon_ts),
default=None,
)
sunset_ts = min(
(ts for e, ts in events if e == SunEvent.SUNSET and ts > noon_ts),
default=None,
)
if sunrise_ts is None or sunset_ts is None: # pragma: no cover
return None
local_day = datetime.datetime.fromtimestamp(noon_ts, self.timezone).date()
plateau_ts = self._replace_time(local_day, plateau_end).timestamp()
descent_day = local_day
if descent_end <= plateau_end: # the descent ends after midnight
descent_day += timedelta(days=1)
descent_ts = self._replace_time(descent_day, descent_end).timestamp()
start = max(plateau_ts, noon_ts)
end = min(descent_ts, sunrise_ts)
if end <= start:
return None
if target_ts <= start:
return 1.0
if target_ts <= end:
return 0.5 * (1 + math.cos(math.pi * (target_ts - start) / (end - start)))
# Past the descent: hold the minimum until sunset, where the regular
# curve (which is 0 at sunset) continues, so a descent finishing
# before sunset does not jump back up.
return 0.0 if target_ts <= sunset_ts else None
def sun_position(
self,
dt: datetime.datetime,
@ -334,6 +394,8 @@ class SunLightSettings:
brightness_mode: Literal["default", "linear", "tanh"] = "default"
color_temp_plateau_morning: datetime.timedelta = datetime.timedelta()
color_temp_plateau_evening: datetime.timedelta = datetime.timedelta()
color_temp_fixed_plateau_end: datetime.time | None = None
color_temp_fixed_descent_end: datetime.time | None = None
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
@ -464,6 +526,17 @@ class SunLightSettings:
if self.color_temp_plateau_morning or self.color_temp_plateau_evening
else sun_position
)
if (
self.color_temp_fixed_plateau_end is not None
and self.color_temp_fixed_descent_end is not None
):
fixed_position = self.sun.fixed_evening_position(
dt,
self.color_temp_fixed_plateau_end,
self.color_temp_fixed_descent_end,
)
if fixed_position is not None:
color_sun_position = fixed_position
rgb_color: tuple[int, int, int]
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
force_rgb_color = False
@ -475,6 +548,7 @@ class SunLightSettings:
self.sleep_rgb_or_color_temp == "rgb_color"
and self.adapt_until_sleep
and sun_position < 0
and color_sun_position < 0
):
# Feature requested in
# https://github.com/basnijholt/adaptive-lighting/issues/624

View file

@ -11,11 +11,14 @@ from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from .const import ( # pylint: disable=unused-import
BASIC_OPTIONS,
CONF_COLOR_TEMP_FIXED_DESCENT_END,
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
CONF_LIGHTS,
DOMAIN,
EXTRA_VALIDATION,
NONE_STR,
VALIDATION_TUPLES,
validate_fixed_evening,
)
from .switch import validate
@ -124,6 +127,14 @@ def validate_options(user_input: dict[str, Any], errors: dict[str, str]) -> None
except vol.Invalid:
_LOGGER.exception("Configuration option %s=%s is incorrect", key, value)
errors["base"] = "option_error"
try:
validate_fixed_evening(
user_input.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END),
user_input.get(CONF_COLOR_TEMP_FIXED_DESCENT_END),
)
except vol.Invalid:
_LOGGER.exception("The fixed evening color temperature times are incorrect")
errors["base"] = "option_error"
class OptionsFlowHandler(config_entries.OptionsFlow):

View file

@ -1,6 +1,6 @@
"""Constants for the Adaptive Lighting integration."""
from datetime import timedelta
from datetime import date, datetime, time, timedelta
from enum import Enum
from typing import Any
@ -228,6 +228,24 @@ DOCS[CONF_COLOR_TEMP_PLATEAU_EVENING] = (
# Each side of the plateau is limited to half a day.
MAX_COLOR_TEMP_PLATEAU = 12 * 60 * 60
CONF_COLOR_TEMP_FIXED_PLATEAU_END = "color_temp_fixed_plateau_end"
DOCS[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = (
"Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at "
"`max_color_temp` in the evening, every day of the year. Needs "
"`color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. "
"Only affects the color temperature. 🌙"
)
CONF_COLOR_TEMP_FIXED_DESCENT_END = "color_temp_fixed_descent_end"
DOCS[CONF_COLOR_TEMP_FIXED_DESCENT_END] = (
"Local time (HH:MM:SS) at which the color temperature has smoothly descended from "
"`max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. "
"May be after midnight but at most 12 hours after the plateau end. Needs "
"`color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙"
)
# The descent from the plateau end to the descent end lasts at most this long.
MAX_COLOR_TEMP_FIXED_DESCENT = timedelta(hours=12)
MIN_COLOR_TEMP_FIXED_PLATEAU_END = time(12, 0)
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
DOCS[CONF_TAKE_OVER_CONTROL] = (
"Pause adaptation of individual lights and hand over (manual) control to other sources that "
@ -443,6 +461,8 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
DEFAULT_COLOR_TEMP_PLATEAU_EVENING,
int_between(0, MAX_COLOR_TEMP_PLATEAU),
),
(CONF_COLOR_TEMP_FIXED_PLATEAU_END, NONE_STR, str),
(CONF_COLOR_TEMP_FIXED_DESCENT_END, NONE_STR, str),
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
(
CONF_TAKE_OVER_CONTROL_MODE,
@ -520,9 +540,54 @@ EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = {
CONF_BRIGHTNESS_MODE_TIME_DARK: (cv.time_period, timedelta_as_int),
CONF_COLOR_TEMP_PLATEAU_MORNING: (_PLATEAU_PERIOD, timedelta_as_int),
CONF_COLOR_TEMP_PLATEAU_EVENING: (_PLATEAU_PERIOD, timedelta_as_int),
CONF_COLOR_TEMP_FIXED_PLATEAU_END: (cv.time, str),
CONF_COLOR_TEMP_FIXED_DESCENT_END: (cv.time, str),
}
def validate_fixed_evening(plateau_end: Any, descent_end: Any) -> None:
"""Check the pair of fixed evening color temperature times.
Both are local times of day (strings or `datetime.time`), or unset
(`None` or "None"). Raises `vol.Invalid` for an incoherent pair.
"""
plateau_end, descent_end = (
None if value in (None, NONE_STR) else cv.time(value)
for value in (plateau_end, descent_end)
)
if plateau_end is None and descent_end is None:
return
if plateau_end is None or descent_end is None:
msg = (
f"{CONF_COLOR_TEMP_FIXED_PLATEAU_END} and"
f" {CONF_COLOR_TEMP_FIXED_DESCENT_END} must be set together"
)
raise vol.Invalid(msg)
if plateau_end < MIN_COLOR_TEMP_FIXED_PLATEAU_END:
msg = (
f"{CONF_COLOR_TEMP_FIXED_PLATEAU_END} must be"
f" {MIN_COLOR_TEMP_FIXED_PLATEAU_END:%H:%M} or later (it is an evening time)"
)
raise vol.Invalid(msg)
if plateau_end == descent_end:
msg = (
f"{CONF_COLOR_TEMP_FIXED_DESCENT_END} must differ from"
f" {CONF_COLOR_TEMP_FIXED_PLATEAU_END}"
)
raise vol.Invalid(msg)
# A descent end that is not after the plateau end is on the next day.
duration = (
datetime.combine(date(2000, 1, 1), descent_end)
- datetime.combine(date(2000, 1, 1), plateau_end)
) % timedelta(days=1)
if duration > MAX_COLOR_TEMP_FIXED_DESCENT:
msg = (
f"{CONF_COLOR_TEMP_FIXED_DESCENT_END} must be at most"
f" {MAX_COLOR_TEMP_FIXED_DESCENT} after {CONF_COLOR_TEMP_FIXED_PLATEAU_END}"
)
raise vol.Invalid(msg)
def maybe_coerce(key: str, validation: Any) -> vol.All | Any:
"""Coerce the validation into a json serializable type."""
validation, coerce = EXTRA_VALIDATION.get(key, (validation, None))
@ -541,11 +606,26 @@ _yaml_validation_tuples = [
for key, default, validation in VALIDATION_TUPLES
] + [(CONF_NAME, DEFAULT_NAME, cv.string)]
_DOMAIN_SCHEMA = vol.Schema(
{
vol.Optional(key, default=replace_none_str(default, vol.UNDEFINED)): validation
for key, default, validation in _yaml_validation_tuples
},
def _validate_yaml_fixed_evening(config: dict[str, Any]) -> dict[str, Any]:
validate_fixed_evening(
config.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END),
config.get(CONF_COLOR_TEMP_FIXED_DESCENT_END),
)
return config
_DOMAIN_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(
key,
default=replace_none_str(default, vol.UNDEFINED),
): validation
for key, default, validation in _yaml_validation_tuples
},
),
_validate_yaml_fixed_evening,
)

View file

@ -223,6 +223,18 @@ change_switch_settings:
min: 0
max: 43200
unit_of_measurement: s
color_temp_fixed_plateau_end:
description: Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙
example: ''
required: false
selector:
time: null
color_temp_fixed_descent_end:
description: Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙
example: ''
required: false
selector:
time: null
sunset_time:
description: Set a fixed time (HH:MM:SS) for sunset. 🌇
example: ''

View file

@ -66,6 +66,8 @@
"brightness_mode_time_light": "brightness_mode_time_light",
"color_temp_plateau_morning": "color_temp_plateau_morning",
"color_temp_plateau_evening": "color_temp_plateau_evening",
"color_temp_fixed_plateau_end": "color_temp_fixed_plateau_end",
"color_temp_fixed_descent_end": "color_temp_fixed_descent_end",
"take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"take_over_control_mode": "take_over_control_mode",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
@ -101,6 +103,8 @@
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"color_temp_plateau_morning": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️",
"color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"color_temp_fixed_plateau_end": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙",
"color_temp_fixed_descent_end": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
@ -264,6 +268,14 @@
"description": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"name": "color_temp_plateau_evening"
},
"color_temp_fixed_plateau_end": {
"description": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙",
"name": "color_temp_fixed_plateau_end"
},
"color_temp_fixed_descent_end": {
"description": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
"name": "color_temp_fixed_descent_end"
},
"sunset_time": {
"description": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
"name": "sunset_time"

View file

@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any
import homeassistant.util.dt as dt_util
import ulid_transform
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@ -100,6 +101,8 @@ from .const import (
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
CONF_COLOR_TEMP_FIXED_DESCENT_END,
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
CONF_COLOR_TEMP_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_DETECT_NON_HA_CHANGES,
@ -153,6 +156,7 @@ from .const import (
TakeOverControlMode,
change_switch_settings_schema,
replace_none_str,
validate_fixed_evening,
)
from .hass_utils import setup_service_call_interceptor, target_entities
from .helpers import (
@ -396,7 +400,11 @@ async def handle_change_switch_settings(
defaults = None
# deep copy the defaults so we don't modify the original dicts
switch._set_changeable_settings(data=data, defaults=deepcopy(defaults))
try:
switch._set_changeable_settings(data=data, defaults=deepcopy(defaults))
except vol.Invalid as err:
msg = f"adaptive-lighting: Incorrect data provided in service call: {err}"
raise ServiceValidationError(msg) from err
if switch.is_on:
switch._update_time_interval_listener()
@ -608,6 +616,20 @@ def validate(
value = data.get(key)
if value is not None:
data[key] = validate_value(value) # Fix the types of the inputs
try:
validate_fixed_evening(
data[CONF_COLOR_TEMP_FIXED_PLATEAU_END],
data[CONF_COLOR_TEMP_FIXED_DESCENT_END],
)
except vol.Invalid:
if config_entry is None: # service call: report it to the caller
raise
_LOGGER.exception(
"%s: ignoring the incoherent fixed evening color temperature times",
data.get(CONF_NAME),
)
data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = None
data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = None
return data
@ -1041,6 +1063,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT],
color_temp_plateau_morning=data[CONF_COLOR_TEMP_PLATEAU_MORNING],
color_temp_plateau_evening=data[CONF_COLOR_TEMP_PLATEAU_EVENING],
color_temp_fixed_plateau_end=data[CONF_COLOR_TEMP_FIXED_PLATEAU_END],
color_temp_fixed_descent_end=data[CONF_COLOR_TEMP_FIXED_DESCENT_END],
timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone),
)
_LOGGER.debug(

View file

@ -67,6 +67,8 @@
"brightness_mode_time_light": "brightness_mode_time_light",
"color_temp_plateau_morning": "color_temp_plateau_morning",
"color_temp_plateau_evening": "color_temp_plateau_evening",
"color_temp_fixed_plateau_end": "color_temp_fixed_plateau_end",
"color_temp_fixed_descent_end": "color_temp_fixed_descent_end",
"take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"take_over_control_mode": "take_over_control_mode",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
@ -102,6 +104,8 @@
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"color_temp_plateau_morning": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️",
"color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"color_temp_fixed_plateau_end": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙",
"color_temp_fixed_descent_end": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
@ -265,6 +269,14 @@
"description": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"name": "color_temp_plateau_evening"
},
"color_temp_fixed_plateau_end": {
"description": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙",
"name": "color_temp_fixed_plateau_end"
},
"color_temp_fixed_descent_end": {
"description": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
"name": "color_temp_fixed_descent_end"
},
"sunset_time": {
"description": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
"name": "sunset_time"

View file

@ -34,3 +34,30 @@ adaptive_lighting:
```
The same options can be changed at runtime with `adaptive_lighting.change_switch_settings`.
## Fixed evening times
The plateau options above are measured from solar noon, so the evening moves with the seasons. To get the same evening every day of the year, set two optional **local** times instead:
| Option | Default | Description |
|--------|---------|-------------|
| `color_temp_fixed_plateau_end` | unset | Local time until which the color temperature stays at `max_color_temp` |
| `color_temp_fixed_descent_end` | unset | Local time at which the color temperature has descended to `min_color_temp` |
```yaml
adaptive_lighting:
- name: kitchen
min_color_temp: 2700
max_color_temp: 4000
color_temp_fixed_plateau_end: "21:00:00" # 4000 K until 21:00
color_temp_fixed_descent_end: "23:00:00" # smooth descent to 2700 K at 23:00
```
Both options are available in the UI (advanced section), in YAML and in `adaptive_lighting.change_switch_settings`. When both are unset, which is the default, nothing changes.
- Only the color temperature is affected. The brightness, the sleep mode (which keeps priority) and the morning curve are unchanged. `color_temp_plateau_evening` is superseded by these times while they are set.
- The color temperature follows the default curve until solar noon (and stays at `max_color_temp` from then until `color_temp_fixed_plateau_end`), then descends with a smooth cosine easing, then stays at `min_color_temp` until sunset or later.
- Validation: both times must be set together, they must differ, `color_temp_fixed_plateau_end` must be 12:00 or later, and the descent must last at most 12 hours. `color_temp_fixed_descent_end` may be after midnight (for example 23:00 → 01:00). In YAML or the UI an invalid pair is rejected; through the service it raises an error; in a stored configuration it is ignored with an error in the log.
- The times are wall-clock times of the Home Assistant time zone. On a day with a DST change inside the window, the real duration of the descent is one hour shorter or longer, but the curve stays continuous.
- The window is anchored on the last solar noon and cut at the next sunrise: it ends at `color_temp_fixed_descent_end` or at that sunrise, whichever comes first, so the color temperature is never higher than the morning curve at sunrise. If solar noon is later than `color_temp_fixed_plateau_end`, the descent starts at noon; if the window does not fit between noon and the next sunrise, the default curve is used for that day.
- Between sunset and `color_temp_fixed_descent_end` the fixed curve replaces the night value (so winter evenings stay at `max_color_temp` after sunset). After `color_temp_fixed_descent_end` the default night behavior resumes. With `adapt_until_sleep` this means a step from `min_color_temp` to the night gradient at that moment.

View file

@ -62,6 +62,8 @@ All configuration options are listed below with their default values. These opti
| `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` |
| `color_temp_plateau_morning` | The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️ | `0` | `int` 0-43200 |
| `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 |
| `color_temp_fixed_plateau_end` | Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year, instead of following the sun. Needs `color_temp_fixed_descent_end`; unset keeps the default curve. Only affects the color temperature. 🌙 | `None` | `str` (HH:MM:SS) |
| `color_temp_fixed_descent_end` | Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight, at most 12 hours later. Needs `color_temp_fixed_plateau_end`; unset keeps the default curve. 🌙 | `None` | `str` (HH:MM:SS) |
| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` |
| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` |
| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` |

View file

@ -11,6 +11,8 @@ except ImportError:
from voluptuous_serialize import convert as to_field_list
from homeassistant.components.adaptive_lighting.const import (
BASIC_OPTIONS,
CONF_COLOR_TEMP_FIXED_DESCENT_END,
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
CONF_COLOR_TEMP_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_EXPAND_LIGHT_GROUPS,
@ -114,6 +116,8 @@ async def test_options(hass):
advanced_data[CONF_COLOR_TEMP_PLATEAU_EVENING] = 10800
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
advanced_data[CONF_SUNSET_TIME] = NONE_STR
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = "21:00:00"
advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = "23:00:00"
basic_data = {**BASIC_DATA, "min_brightness": 12}
user_input = {
**basic_data,
@ -224,6 +228,41 @@ async def test_incorrect_options(hass, lights):
)
@pytest.mark.parametrize(
("plateau_end", "descent_end"),
[
("21:00:00", NONE_STR), # only one of the two
("21:00:00", "21:00:00"), # no descent
("23:00:00", "21:00:00"), # more than 12 hours
("09:00:00", "10:00:00"), # not an evening time
],
)
async def test_options_flow_rejects_incoherent_fixed_evening(
hass,
plateau_end,
descent_end,
):
"""An incoherent pair of fixed evening times is an error in the options flow."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
data={CONF_NAME: DEFAULT_NAME},
options={},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
advanced_data = ADVANCED_DATA.copy()
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = plateau_end
advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = descent_end
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={**BASIC_DATA, "advanced": advanced_data},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "option_error"}
async def test_import_twice(hass):
"""Test importing twice."""
data = DEFAULT_DATA.copy()

518
tests/test_fixed_evening.py Normal file
View file

@ -0,0 +1,518 @@
"""Tests for the fixed local-time evening color temperature."""
import datetime as dt
import zoneinfo
from itertools import pairwise
import pytest
import voluptuous as vol
from astral import LocationInfo
from astral.location import Location
from homeassistant.components.adaptive_lighting.color_and_brightness import (
SunLightSettings,
)
from homeassistant.components.adaptive_lighting.const import (
_DOMAIN_SCHEMA,
validate_fixed_evening,
)
H = dt.timedelta(hours=1)
PARIS = zoneinfo.ZoneInfo("Europe/Paris")
PARIS_LOCATION = Location(
LocationInfo(
name="Paris",
region="France",
timezone="Europe/Paris",
latitude=48.8566,
longitude=2.3522,
),
)
TROMSO_LOCATION = Location(
LocationInfo(
name="Tromso",
region="Norway",
timezone="Europe/Oslo",
latitude=69.6492,
longitude=18.9553,
),
)
def make_settings(
plateau_end=None,
descent_end=None,
*,
location=PARIS_LOCATION,
timezone=PARIS,
fixed_times=False,
**kwargs,
):
"""Astral based settings, or with fixed sunrise 06:00 / sunset 18:00 (local)."""
params = {
"name": "kitchen",
"astral_observer": location.observer,
"adapt_until_sleep": False,
"max_brightness": 100,
"max_color_temp": 4000,
"min_brightness": 30,
"min_color_temp": 2700,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": (255, 56, 0),
"sunrise_time": dt.time(6, 0) if fixed_times else None,
"min_sunrise_time": None,
"max_sunrise_time": None,
"sunset_time": dt.time(18, 0) if fixed_times else 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),
"timezone": timezone,
"color_temp_fixed_plateau_end": plateau_end,
"color_temp_fixed_descent_end": descent_end,
}
params.update(kwargs)
return SunLightSettings(**params)
def kitchen(**kwargs):
return make_settings(dt.time(21, 0), dt.time(23, 0), **kwargs)
def local(day, hour, minute=0, *, tz=PARIS):
return dt.datetime.combine(day, dt.time(hour, minute), tz)
def kelvin(settings, when, is_sleep=False):
return settings.brightness_and_color(when, is_sleep)["color_temp_kelvin"]
def grid(day, step_minutes=5, days=1, tz=PARIS):
start = dt.datetime.combine(day, dt.time(0), tz)
return [
start + dt.timedelta(minutes=m) for m in range(0, days * 24 * 60, step_minutes)
]
# One day per season, plus the days around the DST changes and the solstices.
SEASON_DAYS = [
dt.date(2026, 1, 15),
dt.date(2026, 3, 20),
dt.date(2026, 6, 21),
dt.date(2026, 9, 22),
dt.date(2026, 12, 21),
]
@pytest.mark.parametrize("day", SEASON_DAYS)
@pytest.mark.parametrize("adapt_until_sleep", [True, False])
@pytest.mark.parametrize("rgb", ["color_temp", "rgb_color"])
def test_unset_is_identical_to_before(day, adapt_until_sleep, rgb):
"""No option set: the result is bit-for-bit the same as without the fields."""
kwargs = {"adapt_until_sleep": adapt_until_sleep, "sleep_rgb_or_color_temp": rgb}
default = make_settings(**kwargs)
explicit_none = make_settings(None, None, **kwargs)
for when in grid(day, 7):
for is_sleep in (False, True):
assert default.brightness_and_color(
when,
is_sleep,
) == explicit_none.brightness_and_color(when, is_sleep)
assert default.sun.sun_position(when) == explicit_none.sun.sun_position(when)
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_kitchen_curve_all_year(day):
"""4000 K until 21:00, smooth descent, 2700 K from 23:00 whatever the season."""
settings = kitchen()
assert kelvin(settings, local(day, 21, 0)) == 4000
assert kelvin(settings, local(day, 20, 59)) == 4000
assert kelvin(settings, local(day, 21, 0)) == 4000
# cosine easing: exactly halfway at 22:00
assert kelvin(settings, local(day, 22, 0)) == 3350
assert 3350 < kelvin(settings, local(day, 21, 30)) < 4000
assert 2700 < kelvin(settings, local(day, 22, 30)) < 3350
assert kelvin(settings, local(day, 23, 0)) == 2700
assert kelvin(settings, local(day, 23, 30)) == 2700
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_morning_and_brightness_are_unchanged(day):
default = make_settings()
settings = kitchen()
for when in grid(day, 5, days=2):
# brightness is never touched, sleep mode keeps priority
assert settings.brightness_pct(when, False) == default.brightness_pct(
when,
False,
)
assert settings.brightness_and_color(
when,
True,
) == default.brightness_and_color(
when,
True,
)
brightness = settings.brightness_and_color(when, False)
reference = default.brightness_and_color(when, False)
assert brightness["brightness_pct"] == reference["brightness_pct"]
assert brightness["sun_position"] == reference["sun_position"]
# morning: from the end of the previous descent to solar noon
for when in grid(day, 5):
noon, _ = settings.sun.noon_and_midnight(when)
if when.hour < 12 and when < noon:
assert kelvin(settings, when) == kelvin(default, when)
def test_unrelated_brightness_modes_are_unchanged():
day = dt.date(2026, 1, 15)
for mode in ("default", "linear", "tanh"):
default = make_settings(brightness_mode=mode)
settings = kitchen(brightness_mode=mode)
for when in grid(day, 11):
assert settings.brightness_pct(when, False) == default.brightness_pct(
when,
False,
)
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_monotonic_and_continuous_from_noon_to_the_morning(day):
settings = kitchen()
noon, _ = settings.sun.noon_and_midnight(local(day, 12))
sunrise = settings.sun.sunrise(day + dt.timedelta(days=1))
values = []
when = noon
while when <= sunrise:
values.append(settings.brightness_and_color(when, False)["color_temp_kelvin"])
when += dt.timedelta(minutes=1)
assert values[0] == 4000
assert values[-1] == 2700
assert all(a >= b for a, b in pairwise(values))
# cosine slope is at most pi/2 * 1300 K / 120 min = 17 K/min (rounded to 5 K)
assert max(a - b for a, b in pairwise(values)) <= 20
def test_winter_evening_stays_at_max_after_sunset():
settings = kitchen()
day = dt.date(2026, 12, 21)
sunset = settings.sun.sunset(day)
assert sunset.astimezone(PARIS).hour < 18
assert kelvin(make_settings(), sunset + 2 * H) == 2700 # legacy
assert kelvin(settings, sunset + 2 * H) == 4000
assert kelvin(settings, local(day, 20, 30)) == 4000
def test_summer_sun_still_up_at_the_plateau_end():
settings = kitchen()
day = dt.date(2026, 6, 21)
sunset = settings.sun.sunset(day).astimezone(PARIS)
assert sunset.hour == 21
# the default curve is already near the minimum at sunset
assert kelvin(make_settings(), local(day, 21, 0)) < 3000
assert kelvin(settings, local(day, 21, 0)) == 4000
assert kelvin(settings, local(day, 22, 0)) == 3350
def test_descent_ending_before_sunset_does_not_jump_back_up():
# sunset 18:00, plateau until 14:00 and descent to 15:00: stays at 2700 until sunset
settings = make_settings(dt.time(14, 0), dt.time(15, 0), fixed_times=True)
day = dt.date(2026, 6, 1)
assert kelvin(settings, local(day, 14, 0)) == 4000
assert kelvin(settings, local(day, 15, 0)) == 2700
assert kelvin(settings, local(day, 16, 30)) == 2700
assert kelvin(settings, local(day, 17, 59)) == 2700
assert kelvin(settings, local(day, 18, 30)) == 2700
def test_before_the_plateau_end_it_is_the_regular_afternoon_value_at_noon():
settings = make_settings(dt.time(18, 0), dt.time(20, 0), fixed_times=True)
day = dt.date(2026, 6, 1)
noon, _ = settings.sun.noon_and_midnight(local(day, 12))
assert kelvin(settings, noon) == 4000
assert kelvin(settings, noon + 3 * H) == 4000 # default curve would be lower
assert kelvin(make_settings(fixed_times=True), noon + 3 * H) < 4000
# --- midnight ---------------------------------------------------------------
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_descent_across_midnight(day):
settings = make_settings(dt.time(23, 0), dt.time(1, 0))
assert kelvin(settings, local(day, 22, 59)) == 4000
assert kelvin(settings, local(day, 23, 0)) == 4000
next_day = day + dt.timedelta(days=1)
assert kelvin(settings, local(day, 23, 30)) > 3350
assert kelvin(settings, local(next_day, 0, 0)) == 3350
assert kelvin(settings, local(next_day, 0, 30)) < 3350
assert kelvin(settings, local(next_day, 1, 0)) == 2700
assert kelvin(settings, local(next_day, 3, 0)) == 2700
# the day after, the same thing happens again at the same wall-clock times
assert kelvin(settings, local(next_day, 23, 0)) == 4000
def test_pre_midnight_times_do_not_leak_into_the_morning():
settings = kitchen()
default = make_settings()
day = dt.date(2026, 3, 20)
for hour in range(12):
when = local(day, hour, 30)
noon, _ = settings.sun.noon_and_midnight(when)
if when < noon:
assert kelvin(settings, when) == kelvin(default, when)
def test_descent_end_clamped_at_the_next_sunrise():
# plateau 20:00, descent end 08:00 (12h) but sunrise is 06:00
settings = make_settings(dt.time(20, 0), dt.time(8, 0), fixed_times=True)
day = dt.date(2026, 6, 1)
nxt = day + dt.timedelta(days=1)
assert kelvin(settings, local(day, 20, 0)) == 4000
assert kelvin(settings, local(nxt, 6, 0)) == 2700
assert kelvin(settings, local(nxt, 5, 59)) <= 2705
assert kelvin(settings, local(nxt, 6, 30)) == kelvin(
make_settings(fixed_times=True),
local(nxt, 6, 30),
)
def test_window_that_does_not_fit_before_the_next_sunrise_is_ignored():
# plateau 12:00-12:30 but noon is 15:00: nothing to do
settings = make_settings(
dt.time(12, 0),
dt.time(12, 30),
fixed_times=True,
sunrise_time=dt.time(8, 0),
sunset_time=dt.time(22, 0),
)
default = make_settings(
fixed_times=True,
sunrise_time=dt.time(8, 0),
sunset_time=dt.time(22, 0),
)
for when in grid(dt.date(2026, 6, 1), 10):
assert settings.brightness_and_color(
when,
False,
) == default.brightness_and_color(
when,
False,
)
def test_plateau_end_before_solar_noon_starts_the_descent_at_noon():
# noon is 15:00, plateau_end 12:00, descent end 17:00: 2h descent from noon
settings = make_settings(
dt.time(12, 0),
dt.time(17, 0),
fixed_times=True,
sunrise_time=dt.time(8, 0),
sunset_time=dt.time(22, 0),
)
day = dt.date(2026, 6, 1)
assert kelvin(settings, local(day, 15, 0)) == 4000
assert kelvin(settings, local(day, 16, 0)) == 3350
assert kelvin(settings, local(day, 17, 0)) == 2700
# --- DST and time zones --------------------------------------------------
@pytest.mark.parametrize(
"day",
[
dt.date(2026, 3, 28), # night to the spring-forward day
dt.date(2026, 3, 29),
dt.date(2026, 10, 24), # night to the fall-back day
dt.date(2026, 10, 25),
],
)
@pytest.mark.parametrize(
("plateau_end", "descent_end"),
[
(dt.time(21, 0), dt.time(23, 0)),
(dt.time(23, 0), dt.time(3, 30)), # contains the 02:00-03:00 DST change
(dt.time(23, 0), dt.time(2, 30)), # 02:30 does not exist / exists twice
],
)
def test_dst_changes_stay_continuous_and_monotonic(day, plateau_end, descent_end):
settings = make_settings(plateau_end, descent_end)
start = dt.datetime.combine(day, dt.time(12), PARIS)
noon, _ = settings.sun.noon_and_midnight(start)
end = settings.sun.sunrise(day + dt.timedelta(days=1))
values = []
when = noon
while when <= end:
values.append(kelvin(settings, when))
when += dt.timedelta(minutes=1)
assert values[0] == 4000
assert values[-1] == 2700
assert all(a >= b for a, b in pairwise(values))
assert max(a - b for a, b in pairwise(values)) <= 25
if descent_end == dt.time(23, 0):
assert kelvin(settings, local(day, 22, 0)) == 3350
def test_dst_day_wall_clock_times_hold():
# on both DST days the evening is still 4000 K at 20:59 and 2700 K at 23:00
for day in (dt.date(2026, 3, 29), dt.date(2026, 10, 25)):
settings = kitchen()
assert kelvin(settings, local(day, 20, 59)) == 4000
assert kelvin(settings, local(day, 23, 0)) == 2700
def test_times_are_interpreted_in_the_configured_time_zone():
day = dt.date(2026, 3, 20)
paris = kitchen()
utc = make_settings(
dt.time(21, 0),
dt.time(23, 0),
timezone=zoneinfo.ZoneInfo("UTC"),
)
# 21:00 in Paris (UTC+1 in March) is 20:00 UTC: Paris is already descending
# one hour before UTC is
moment = dt.datetime(2026, 3, 20, 21, 30, tzinfo=dt.UTC)
assert kelvin(utc, moment) > kelvin(paris, moment)
assert kelvin(paris, dt.datetime(2026, 3, 20, 20, 0, tzinfo=dt.UTC)) == 4000
assert kelvin(paris, dt.datetime(2026, 3, 20, 22, 0, tzinfo=dt.UTC)) == 2700
assert kelvin(paris, local(day, 22, 0)) == 3350
# the same instant gives the same answer whatever tz the datetime carries
instant = local(day, 22, 0)
assert kelvin(paris, instant) == kelvin(paris, instant.astimezone(dt.UTC))
assert kelvin(paris, instant) == kelvin(
paris,
instant.astimezone(zoneinfo.ZoneInfo("Asia/Tokyo")),
)
# --- sleep mode, rgb, adapt_until_sleep, polar -------------------------------
def test_sleep_mode_keeps_priority():
day = dt.date(2026, 12, 21)
default = make_settings()
settings = kitchen()
for hour in (9, 14, 19, 22, 23):
when = local(day, hour)
assert settings.brightness_and_color(
when,
True,
) == default.brightness_and_color(
when,
True,
)
def test_rgb_sleep_color_after_sunset_does_not_override_the_fixed_evening():
day = dt.date(2026, 12, 21)
settings = kitchen(adapt_until_sleep=True, sleep_rgb_or_color_temp="rgb_color")
default = make_settings(adapt_until_sleep=True, sleep_rgb_or_color_temp="rgb_color")
when = local(day, 19, 0) # after sunset, still in the plateau
result = settings.brightness_and_color(when, False)
assert result["color_temp_kelvin"] == 4000
assert result["force_rgb_color"] is False
assert default.brightness_and_color(when, False)["force_rgb_color"] is True
# after the descent the night behavior is the legacy one
late = local(day, 23, 30)
assert settings.brightness_and_color(late, False) == default.brightness_and_color(
late,
False,
)
def test_adapt_until_sleep_night_resumes_after_the_descent():
day = dt.date(2026, 12, 21)
settings = kitchen(adapt_until_sleep=True)
default = make_settings(adapt_until_sleep=True)
# once the descent is over, the night is the regular one again
for hour, minute in [(23, 30), (2, 0), (5, 0)]:
when = local(day if hour > 12 else day + dt.timedelta(days=1), hour, minute)
assert kelvin(settings, when) == kelvin(default, when)
# ... with a step from 2700 K at the end of the descent (documented)
assert kelvin(settings, local(day, 23, 0)) == 2700
assert kelvin(default, local(day, 23, 0)) < 2700
# in the plateau/descent the fixed curve is used, not the night gradient
assert kelvin(settings, local(day, 20, 0)) == 4000
assert kelvin(settings, local(day, 22, 0)) == 3350
@pytest.mark.parametrize("day", [dt.date(2026, 6, 21), dt.date(2026, 12, 21)])
def test_polar_days(day):
settings = make_settings(
dt.time(21, 0),
dt.time(23, 0),
location=TROMSO_LOCATION,
timezone=zoneinfo.ZoneInfo("Europe/Oslo"),
)
tz = zoneinfo.ZoneInfo("Europe/Oslo")
for when in grid(day, 10, days=2, tz=tz):
assert 2700 <= kelvin(settings, when) <= 4000
# --- validation ----------------------------------------------------------------
@pytest.mark.parametrize(
("plateau_end", "descent_end"),
[
(None, None),
("None", "None"),
("21:00:00", "23:00:00"),
("21:00", "23:00"),
(dt.time(21, 0), dt.time(23, 0)),
("12:00:00", "13:00:00"),
("12:00:00", "00:00:00"), # exactly 12 hours
("23:00:00", "01:00:00"), # across midnight
("23:59:59", "00:00:00"),
("21:00:00", "21:00:01"),
],
)
def test_valid_pairs(plateau_end, descent_end):
validate_fixed_evening(plateau_end, descent_end)
@pytest.mark.parametrize(
("plateau_end", "descent_end"),
[
("21:00:00", None), # only one
(None, "23:00:00"),
("21:00:00", "None"),
("21:00:00", "21:00:00"), # no descent
("23:00:00", "21:00:00"), # 22 hours, i.e. the descent would be inverted
("21:00:00", "20:59:59"),
("11:59:59", "13:00:00"), # plateau end in the morning
("08:00:00", "10:00:00"),
("12:00:00", "00:00:01"), # more than 12 hours
("nonsense", "23:00:00"),
("25:00:00", "23:00:00"),
],
)
def test_invalid_pairs(plateau_end, descent_end):
with pytest.raises(vol.Invalid):
validate_fixed_evening(plateau_end, descent_end)
def test_yaml_schema():
schema = _DOMAIN_SCHEMA
config = schema({})
assert "color_temp_fixed_plateau_end" not in config
assert "color_temp_fixed_descent_end" not in config
config = schema(
{
"color_temp_fixed_plateau_end": "21:00:00",
"color_temp_fixed_descent_end": "23:00:00",
},
)
assert config["color_temp_fixed_plateau_end"] == "21:00:00"
assert config["color_temp_fixed_descent_end"] == "23:00:00"
with pytest.raises(vol.Invalid):
schema({"color_temp_fixed_plateau_end": "21:00:00"})
with pytest.raises(vol.Invalid):
schema(
{
"color_temp_fixed_plateau_end": "22:00:00",
"color_temp_fixed_descent_end": "22:00:00",
},
)

View file

@ -37,6 +37,8 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
CONF_COLOR_TEMP_FIXED_DESCENT_END,
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
CONF_COLOR_TEMP_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_DETECT_NON_HA_CHANGES,
@ -129,7 +131,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import Context, CoreState, Event, HomeAssistant, State
from homeassistant.exceptions import Unauthorized
from homeassistant.exceptions import ServiceValidationError, Unauthorized
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import entity_registry
from homeassistant.helpers.entity_platform import async_get_platforms
@ -2972,6 +2974,140 @@ def test_color_temp_plateau_yaml_validation(invalid, key):
_DOMAIN_SCHEMA({key: invalid})
async def test_fixed_evening_defaults_and_service(hass):
"""The fixed evening times are unset by default and settable at runtime."""
switch, _ = await setup_lights_and_switch(hass)
settings = switch._sun_light_settings
assert settings.color_temp_fixed_plateau_end is None
assert settings.color_temp_fixed_descent_end is None
async def change_switch_settings(**kwargs):
await hass.services.async_call(
DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
{ATTR_ENTITY_ID: switch.entity_id, **kwargs},
blocking=True,
)
await hass.async_block_till_done()
await change_switch_settings(
**{
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
},
)
settings = switch._sun_light_settings
assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0)
assert settings.color_temp_fixed_descent_end == datetime.time(23, 0)
# unrelated changes keep the times ("current" defaults)
await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 80})
settings = switch._sun_light_settings
assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0)
# an incoherent pair is rejected and leaves the settings untouched
for bad in (
{CONF_COLOR_TEMP_FIXED_DESCENT_END: "21:00:00"}, # no descent
{CONF_COLOR_TEMP_FIXED_PLATEAU_END: "11:00:00"}, # not an evening time
{CONF_COLOR_TEMP_FIXED_PLATEAU_END: "nonsense"},
):
with pytest.raises((ServiceValidationError, voluptuous.error.Invalid)):
await change_switch_settings(**bad)
settings = switch._sun_light_settings
assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0)
assert settings.color_temp_fixed_descent_end == datetime.time(23, 0)
# one time alone is rejected
with pytest.raises(ServiceValidationError):
await change_switch_settings(
**{CONF_COLOR_TEMP_FIXED_DESCENT_END: "None"},
)
# both can be cleared together
await change_switch_settings(
**{
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "None",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "None",
},
)
settings = switch._sun_light_settings
assert settings.color_temp_fixed_plateau_end is None
assert settings.color_temp_fixed_descent_end is None
await change_switch_settings(
**{
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
},
)
await change_switch_settings(**{CONF_USE_DEFAULTS: "factory"})
assert switch._sun_light_settings.color_temp_fixed_plateau_end is None
assert switch._sun_light_settings.color_temp_fixed_descent_end is None
async def test_fixed_evening_from_configuration(hass):
"""The options from the config entry reach the sun light settings."""
_, switch = await setup_switch(
hass,
{
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
},
)
settings = switch._sun_light_settings
assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0)
assert settings.color_temp_fixed_descent_end == datetime.time(23, 0)
# the brightness is not influenced by the evening times
default = switch._sun_light_settings.__class__(
**{
**{
f.name: getattr(settings, f.name)
for f in settings.__dataclass_fields__.values()
},
"color_temp_fixed_plateau_end": None,
"color_temp_fixed_descent_end": None,
},
)
now = dt_util.utcnow()
assert (
settings.brightness_and_color(now, False)["brightness_pct"]
== default.brightness_and_color(now, False)["brightness_pct"]
)
async def test_fixed_evening_incoherent_stored_configuration_is_ignored(
hass,
caplog,
):
"""A hand edited stored pair does not break the setup: it is ignored and logged."""
_, switch = await setup_switch(
hass,
{
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "21:00:00",
},
)
settings = switch._sun_light_settings
assert settings.color_temp_fixed_plateau_end is None
assert settings.color_temp_fixed_descent_end is None
assert "ignoring the incoherent fixed evening color temperature" in caplog.text
@pytest.mark.parametrize(
("plateau_end", "descent_end"),
[("21:00:00", None), ("21:00:00", "21:00:00"), ("08:00:00", "10:00:00")],
)
def test_fixed_evening_yaml_validation(plateau_end, descent_end):
"""Incoherent pairs are rejected by the YAML schema."""
from homeassistant.components.adaptive_lighting.const import _DOMAIN_SCHEMA
config = {CONF_COLOR_TEMP_FIXED_PLATEAU_END: plateau_end}
if descent_end is not None:
config[CONF_COLOR_TEMP_FIXED_DESCENT_END] = descent_end
with pytest.raises(voluptuous.error.Invalid):
_DOMAIN_SCHEMA(config)
@pytest.mark.parametrize("target", ["entity", "area", "device", "all"])
async def test_change_switch_settings_entity_targets(hass, device_registry, target):
"""Test settings changes through Home Assistant entity targets."""