This commit is contained in:
Charles HL 2026-10-19 15:50:28 +00:00 • committed by GitHub
commit 40ca852b58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2417 additions and 14 deletions

View file

@ -156,6 +156,11 @@ The YAML and frontend configuration methods support all of the options listed be
| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈 | `default` | one of `['default', 'linear', 'tanh']` |
| `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉 | `900` | `int` |
| `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) |
| `color_temp_fixed_plateau_kelvin` | Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙 | `None` | `str` |
| `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

@ -41,6 +41,16 @@ _ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))}
_POLAR_SUN_EVENT_OFFSET = timedelta(minutes=30)
_POLAR_SUN_EVENT_EPSILON = timedelta(seconds=1)
# The color temperature plateau is limited to this fraction of the time between
# solar noon and sunrise/sunset, such that the ramp never collapses.
_PLATEAU_MAX_FRACTION = 0.9
# A custom evening plateau temperature is held at least this long (on a long
# enough afternoon) before the end of the fixed evening plateau.
_FIXED_PLATEAU_MIN_HOLD = timedelta(hours=1)
# ... and it is ignored (the maximum is held instead) on a day where the plateau
# end is less than this after solar noon, as there is no room to ramp down.
_FIXED_PLATEAU_MIN_AFTERNOON = timedelta(hours=1)
utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC)
utcnow.__doc__ = "Get now in UTC time."
@ -243,15 +253,180 @@ class SunEvents:
i_now = bisect.bisect([ts for _, ts in events], dt.timestamp())
return events[i_now - 1 : i_now + 1]
def sun_position(self, dt: datetime.datetime) -> float:
"""Calculate the position of the sun, between [-1, 1]."""
def fixed_evening_position(
self,
dt: datetime.datetime,
plateau_end: datetime.time,
descent_end: datetime.time,
plateau_level: float = 1.0,
plateau_evening: datetime.timedelta = datetime.timedelta(),
) -> 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.
With a `plateau_level` below 1 (the position of a custom evening
temperature, needing `plateau_end` at least 1 hour after solar noon,
else it is ignored for that day) the position is
`plateau_level` for the last `_FIXED_PLATEAU_MIN_HOLD` before
`plateau_end` (less on a short afternoon) instead of 1. It reaches it
along the regular afternoon curve (which holds 1 for `plateau_evening`
after noon) as soon as that does, otherwise along the same curve sped
up so that it arrives in time. Hence the level is reached exactly and
held in every season, with no step, and the descent after `plateau_end`
starts from the level. Before the ramp nothing is capped.
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()
(_, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt)
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
# The custom level needs a long enough afternoon to ramp down to it
use_level = (
plateau_level < 1
and start - noon_ts >= _FIXED_PLATEAU_MIN_AFTERNOON.total_seconds()
)
if target_ts <= start:
return (
self._afternoon_to_level(
target_ts,
noon_ts,
sunset_ts,
start,
plateau_level,
plateau_evening,
)
if use_level
else 1.0
)
level = plateau_level if use_level else 1.0
if target_ts <= end:
return (
level
* 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
@staticmethod
def _afternoon_to_level(
target_ts: float,
noon_ts: float,
sunset_ts: float,
start: float,
level: float,
plateau_evening: datetime.timedelta,
) -> float:
"""Position from solar noon to `start`, holding `level` before `start`.
Follows the regular afternoon parabola (1 for the plateau after noon, then
falling to 0 at sunset) until it reaches `level`; when that would be too
late to hold `level` for `_FIXED_PLATEAU_MIN_HOLD` before `start`, the
same curve is traversed faster (and, if its own plateau is too long,
starting later within the afternoon) so that it reaches `level` in time.
"""
half_day = sunset_ts - noon_ts
hold = min(plateau_evening.total_seconds(), _PLATEAU_MAX_FRACTION * half_day)
ramp = half_day - hold
# time at which the regular curve gets to `level`
natural_end = noon_ts + hold + ramp * math.sqrt(1 - level)
ramp_end = start - min(
_FIXED_PLATEAU_MIN_HOLD.total_seconds(),
(start - noon_ts) / 2,
)
ramp_start = noon_ts + hold
if natural_end > ramp_end: # too late: compress the curve into the window
ramp_end = max(ramp_end, noon_ts)
ramp_start = min(ramp_start, (noon_ts + ramp_end) / 2)
else:
ramp_end = natural_end
if target_ts <= ramp_start:
return 1.0
if target_ts >= ramp_end:
return level
progress = (target_ts - ramp_start) / (ramp_end - ramp_start)
regular_ts = noon_ts + hold + progress * (natural_end - noon_ts - hold)
return 1 - ((regular_ts - noon_ts - hold) / ramp) ** 2
def sun_position(
self,
dt: datetime.datetime,
plateau_morning: datetime.timedelta = datetime.timedelta(),
plateau_evening: datetime.timedelta = datetime.timedelta(),
) -> float:
"""Calculate the position of the sun, between [-1, 1].
With a non-zero `plateau_morning` (`plateau_evening`) the position stays
at its maximum of 1 for that long before (after) solar noon, and the
parabola between sunrise (sunset) and the end of the plateau is
compressed to still start (end) at 0 at sunrise (sunset). This is a
monotonic and continuous remapping of the time axis that only applies
to the part of the day where the sun is up; the position below the
horizon is never changed. With both plateaus zero this is the
unmodified position.
"""
target_ts = dt.timestamp()
(prev_event, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt)
h, x = (
(prev_ts, next_ts)
if next_event in (SunEvent.SUNSET, SunEvent.SUNRISE)
else (next_ts, prev_ts)
)
plateau = None
if next_event == SunEvent.NOON and prev_event == SunEvent.SUNRISE:
plateau = plateau_morning
elif next_event == SunEvent.SUNSET and prev_event == SunEvent.NOON:
plateau = plateau_evening
if plateau:
# `h` is solar noon and `x` the sunrise or sunset bounding this half
half_day = abs(h - x)
# Always keep part of the half-day for the ramp so that it has a
# non-zero duration, which also prevents dividing by zero.
hold = min(plateau.total_seconds(), _PLATEAU_MAX_FRACTION * half_day)
distance = abs(target_ts - h)
if hold > 0 and half_day > 0:
if distance <= hold:
return 1.0
return 1 - ((distance - hold) / (half_day - hold)) ** 2
# k = -1 between sunset and sunrise (sun below horizon)
# k = 1 between sunrise and sunset (sun above horizon)
k = 1 if next_event in (SunEvent.SUNSET, SunEvent.NOON) else -1
@ -297,6 +472,11 @@ class SunLightSettings:
brightness_mode_time_dark: datetime.timedelta
brightness_mode_time_light: datetime.timedelta
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
color_temp_fixed_plateau_kelvin: int | None = None
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
@ -395,6 +575,14 @@ class SunLightSettings:
return self._brightness_pct_tanh(dt)
return None
def _fixed_plateau_level(self) -> float:
"""Position in [0, 1] of the custom evening temperature (1 if unset)."""
kelvin = self.color_temp_fixed_plateau_kelvin
delta = self.max_color_temp - self.min_color_temp
if kelvin is None or delta <= 0:
return 1.0
return min(max((kelvin - self.min_color_temp) / delta, 0.0), 1.0)
def color_temp_kelvin(self, sun_position: float) -> int:
"""Calculate the color temperature in Kelvin."""
if sun_position > 0:
@ -417,6 +605,29 @@ class SunLightSettings:
) -> dict[str, Any]:
"""Calculate the brightness and color."""
sun_position = self.sun.sun_position(dt)
# Only the color temperature (not the brightness) has the plateaus.
color_sun_position = (
self.sun.sun_position(
dt,
self.color_temp_plateau_morning,
self.color_temp_plateau_evening,
)
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,
self._fixed_plateau_level(),
self.color_temp_plateau_evening,
)
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
@ -428,6 +639,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
@ -439,10 +651,10 @@ class SunLightSettings:
self.sleep_rgb_color,
sun_position,
)
color_temp_kelvin = self.color_temp_kelvin(sun_position)
color_temp_kelvin = self.color_temp_kelvin(color_sun_position)
force_rgb_color = True
else:
color_temp_kelvin = self.color_temp_kelvin(sun_position)
color_temp_kelvin = self.color_temp_kelvin(color_sun_position)
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

View file

@ -11,11 +11,20 @@ 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_COLOR_TEMP_FIXED_PLATEAU_KELVIN,
CONF_LIGHTS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_COLOR_TEMP,
DEFAULT_MAX_COLOR_TEMP,
DEFAULT_MIN_COLOR_TEMP,
DOMAIN,
EXTRA_VALIDATION,
NONE_STR,
VALIDATION_TUPLES,
validate_fixed_evening,
validate_fixed_plateau_kelvin,
)
from .switch import validate
@ -124,6 +133,25 @@ 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"
try:
validate_fixed_plateau_kelvin(
user_input.get(CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN),
user_input.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END),
user_input.get(CONF_COLOR_TEMP_FIXED_DESCENT_END),
int(user_input.get(CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP)),
int(user_input.get(CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP)),
)
except vol.Invalid:
_LOGGER.exception("The fixed evening plateau color temperature is 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
@ -206,6 +206,54 @@ DOCS[CONF_BRIGHTNESS_MODE_TIME_LIGHT] = (
"the brightness after/before sunrise/sunset. 📈📉."
)
CONF_COLOR_TEMP_PLATEAU_MORNING, DEFAULT_COLOR_TEMP_PLATEAU_MORNING = (
"color_temp_plateau_morning",
0,
)
DOCS[CONF_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. 🌤️"
)
CONF_COLOR_TEMP_PLATEAU_EVENING, DEFAULT_COLOR_TEMP_PLATEAU_EVENING = (
"color_temp_plateau_evening",
0,
)
DOCS[CONF_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. 🌇"
)
# 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. 🌙"
)
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN = "color_temp_fixed_plateau_kelvin"
DOCS[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = (
"Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), "
"reached and held for the last hour before `color_temp_fixed_plateau_end`; the "
"daytime maximum is not capped. Needs both fixed times and is ignored when the "
"plateau end is less than 1 h after solar noon. Applied in steps of 5 K. "
"Unset holds `max_color_temp`. 🌙"
)
# 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 "
@ -411,6 +459,19 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
),
(CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int),
(CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
(
CONF_COLOR_TEMP_PLATEAU_MORNING,
DEFAULT_COLOR_TEMP_PLATEAU_MORNING,
int_between(0, MAX_COLOR_TEMP_PLATEAU),
),
(
CONF_COLOR_TEMP_PLATEAU_EVENING,
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_COLOR_TEMP_FIXED_PLATEAU_KELVIN, NONE_STR, str),
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
(
CONF_TAKE_OVER_CONTROL_MODE,
@ -467,6 +528,11 @@ def timedelta_as_int(value: timedelta) -> float:
return value.total_seconds()
_PLATEAU_PERIOD = vol.All(
cv.time_period,
vol.Range(min=timedelta(0), max=timedelta(seconds=MAX_COLOR_TEMP_PLATEAU)),
)
# conf_option: (validator, coerce) tuples
# these validators cannot be serialized but can be serialized when coerced by coerce.
EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = {
@ -481,9 +547,88 @@ EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = {
CONF_MAX_SUNSET_TIME: (cv.time, str),
CONF_BRIGHTNESS_MODE_TIME_LIGHT: (cv.time_period, timedelta_as_int),
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),
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: (int_between(1000, 10000), int),
}
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 validate_fixed_plateau_kelvin(
kelvin: Any,
plateau_end: Any,
descent_end: Any,
min_color_temp: int,
max_color_temp: int,
) -> None:
"""Check the custom evening plateau temperature, unset being `None`/"None".
It needs the fixed evening times and must lie between the minimum and
maximum color temperature. Raises `vol.Invalid` otherwise.
"""
if kelvin in (None, NONE_STR):
return
kelvin = int_between(1000, 10000)(kelvin)
if plateau_end in (None, NONE_STR) or descent_end in (None, NONE_STR):
msg = (
f"{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN} needs"
f" {CONF_COLOR_TEMP_FIXED_PLATEAU_END} and"
f" {CONF_COLOR_TEMP_FIXED_DESCENT_END}"
)
raise vol.Invalid(msg)
if not min_color_temp <= kelvin <= max_color_temp:
msg = (
f"{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN} ({kelvin}) must be between"
f" {CONF_MIN_COLOR_TEMP} ({min_color_temp}) and"
f" {CONF_MAX_COLOR_TEMP} ({max_color_temp})"
)
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))
@ -502,11 +647,33 @@ _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),
)
validate_fixed_plateau_kelvin(
config.get(CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN),
config.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END),
config.get(CONF_COLOR_TEMP_FIXED_DESCENT_END),
config.get(CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP),
config.get(CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP),
)
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,
)
@ -525,6 +692,13 @@ def apply_service_schema() -> vol.Schema:
)
# The service takes the numbers of its selectors, where the options flow only
# has a (serializable) string field; the strict check is made by `validate`.
_SERVICE_VALIDATION: dict[str, Any] = {
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: vol.Any(vol.Coerce(int), str),
}
def change_switch_settings_schema() -> dict[vol.Marker, Any]:
"""Return the schema for the change_switch_settings service."""
args: dict[vol.Marker, Any] = {
@ -534,7 +708,7 @@ def change_switch_settings_schema() -> dict[vol.Marker, Any]:
skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS)
for k, _, valid in VALIDATION_TUPLES:
if k not in skip:
args[vol.Optional(k)] = valid
args[vol.Optional(k)] = _SERVICE_VALIDATION.get(k, valid)
return args

View file

@ -205,6 +205,45 @@ change_switch_settings:
number:
min: -86400
max: 86300
color_temp_plateau_morning:
description: 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. 🌤️
required: false
example: ''
selector:
number:
min: 0
max: 43200
unit_of_measurement: s
color_temp_plateau_evening:
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. 🌇
required: false
example: ''
selector:
number:
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
color_temp_fixed_plateau_kelvin:
description: Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙
example: ''
required: false
selector:
number:
min: 1000
max: 10000
unit_of_measurement: K
sunset_time:
description: Set a fixed time (HH:MM:SS) for sunset. 🌇
example: ''

View file

@ -64,6 +64,11 @@
"brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"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",
"color_temp_fixed_plateau_kelvin": "color_temp_fixed_plateau_kelvin",
"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.",
@ -97,6 +102,11 @@
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"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. 🌙",
"color_temp_fixed_plateau_kelvin": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
"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. ⏲️",
@ -252,6 +262,26 @@
"description": "Adjust sunset time with a positive or negative offset in seconds. ⏰",
"name": "sunset_offset"
},
"color_temp_plateau_morning": {
"description": "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. 🌤️",
"name": "color_temp_plateau_morning"
},
"color_temp_plateau_evening": {
"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"
},
"color_temp_fixed_plateau_kelvin": {
"description": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
"name": "color_temp_fixed_plateau_kelvin"
},
"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,11 @@ 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_FIXED_PLATEAU_KELVIN,
CONF_COLOR_TEMP_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_DETECT_NON_HA_CHANGES,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
@ -151,6 +157,8 @@ from .const import (
TakeOverControlMode,
change_switch_settings_schema,
replace_none_str,
validate_fixed_evening,
validate_fixed_plateau_kelvin,
)
from .hass_utils import setup_service_call_interceptor, target_entities
from .helpers import (
@ -394,7 +402,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()
@ -606,6 +618,36 @@ 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
try:
validate_fixed_plateau_kelvin(
data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN],
data[CONF_COLOR_TEMP_FIXED_PLATEAU_END],
data[CONF_COLOR_TEMP_FIXED_DESCENT_END],
data[CONF_MIN_COLOR_TEMP],
data[CONF_MAX_COLOR_TEMP],
)
except vol.Invalid:
if config_entry is None: # service call: report it to the caller
raise
_LOGGER.exception(
"%s: ignoring the incoherent fixed evening plateau color temperature",
data.get(CONF_NAME),
)
data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = None
return data
@ -1037,6 +1079,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
brightness_mode=data[CONF_BRIGHTNESS_MODE],
brightness_mode_time_dark=data[CONF_BRIGHTNESS_MODE_TIME_DARK],
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],
color_temp_fixed_plateau_kelvin=data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN],
timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone),
)
_LOGGER.debug(

View file

@ -65,6 +65,11 @@
"brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"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",
"color_temp_fixed_plateau_kelvin": "color_temp_fixed_plateau_kelvin",
"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.",
@ -98,6 +103,11 @@
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"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. 🌙",
"color_temp_fixed_plateau_kelvin": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
"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. ⏲️",
@ -253,6 +263,26 @@
"description": "Adjust sunset time with a positive or negative offset in seconds. ⏰",
"name": "sunset_offset"
},
"color_temp_plateau_morning": {
"description": "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. 🌤️",
"name": "color_temp_plateau_morning"
},
"color_temp_plateau_evening": {
"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"
},
"color_temp_fixed_plateau_kelvin": {
"description": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
"name": "color_temp_fixed_plateau_kelvin"
},
"sunset_time": {
"description": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
"name": "sunset_time"

View file

@ -0,0 +1,84 @@
---
icon: lucide/thermometer-sun
---
# Color Temperature Plateau
By default the color temperature follows the sun position: it rises from `min_color_temp` at sunrise to `max_color_temp` at solar noon, and falls back to `min_color_temp` at sunset. The light therefore only *touches* `max_color_temp` at a single instant.
`color_temp_plateau_morning` and `color_temp_plateau_evening` hold `max_color_temp` for a while around solar noon, while still ramping smoothly from and to `min_color_temp` at sunrise and sunset. Both are independent, in seconds, and default to `0` (the unchanged default curve).
| Option | Default | Description |
|--------|---------|-------------|
| `color_temp_plateau_morning` | `0` | Seconds *before* solar noon that the color temperature already stays at `max_color_temp` |
| `color_temp_plateau_evening` | `0` | Seconds *after* solar noon that the color temperature stays at `max_color_temp` |
## How it works
The ramp is the same curve as without a plateau, but compressed into the time between sunrise (or sunset) and the start (or end) of the plateau. The color temperature is therefore continuous, has no jump at the edge of the plateau, and still equals `min_color_temp` exactly at the (offset) sunrise and sunset.
- A plateau is measured from the same "noon" as the rest of the integration: solar noon, or the middle between sunrise and sunset when `sunrise_time`/`sunset_time` (or their min/max variants) are set. Sunrise and sunset offsets move the far end of the ramp; without fixed times, the anchor stays at solar noon.
- Configuration accepts durations from 0 to 12 hours (43200 seconds); values outside that range are rejected. At runtime, each plateau is further limited to 90% of the time between noon and sunrise/sunset, so that a ramp of at least 10% of that time always remains.
- Only the color temperature is affected. The brightness, the sleep mode, and the color after sunset (including `adapt_until_sleep`) are unchanged.
## Example
```yaml
adaptive_lighting:
- name: kitchen
min_color_temp: 2700
max_color_temp: 4000
sunset_offset: 7200 # 2 hours
color_temp_plateau_morning: 3600 # at 4000 K from 1 hour before noon
color_temp_plateau_evening: 14400 # stays at 4000 K until 4 hours after noon
```
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` |
| `color_temp_fixed_plateau_kelvin` | unset | Optional temperature (K) held until `color_temp_fixed_plateau_end` instead of `max_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
```
### Evening plateau lower than the daytime maximum
By default the evening holds `max_color_temp`. To keep a high daytime maximum but a calmer evening, set `color_temp_fixed_plateau_kelvin` (between `min_color_temp` and `max_color_temp`):
```yaml
adaptive_lighting:
- name: kitchen
min_color_temp: 2700
max_color_temp: 5500
color_temp_fixed_plateau_end: "21:00:00"
color_temp_fixed_descent_end: "23:00:00"
color_temp_fixed_plateau_kelvin: 4000 # 5500 K by day, 4000 K until 21:00
```
The morning and the middle of the day are unchanged and still reach `max_color_temp` (`color_temp_plateau_morning` and `color_temp_plateau_evening` keep their meaning, see the next points). After the daytime maximum the color temperature descends along the regular afternoon curve to `color_temp_fixed_plateau_kelvin`, **reaches it exactly and holds it for the last hour before `color_temp_fixed_plateau_end`** whatever the season, then descends to `min_color_temp` as described below.
- When the regular curve gets to the target by itself at least one hour before the plateau end (typically in winter), it is used unchanged. Otherwise (summer, or a late `sunset_offset`) the same curve is traversed faster so that the target is reached one hour before the plateau end; there is no step anywhere and the slope is zero where the descent starts.
- On a short afternoon the hold is shortened to half of the time between solar noon and the plateau end. If the plateau end is less than one hour after solar noon the target is ignored for that day (the maximum is held until the plateau end, as without the option), because there is no room to ramp down. In summer a long `color_temp_plateau_evening` that would not leave room for the descent may be shortened (the maximum is then held for less than that duration).
- The option needs both times, must lie between `min_color_temp` and `max_color_temp` (also checked when those change), and a value equal to `max_color_temp` is the same as leaving it unset. Unset keeps the exact previous behavior. Like every color temperature, the value is applied in steps of 5 K (4003 gives 4005).
The options are available in the UI (advanced section), in YAML and in `adaptive_lighting.change_switch_settings`. When they 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, except that it still shapes the afternoon curve towards `color_temp_fixed_plateau_kelvin`.
- The color temperature follows the default curve until solar noon (and stays at `max_color_temp`, or `color_temp_fixed_plateau_kelvin` if set, 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

@ -60,6 +60,11 @@ All configuration options are listed below with their default values. These opti
| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈 | `default` | one of `['default', 'linear', 'tanh']` |
| `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉 | `900` | `int` |
| `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) |
| `color_temp_fixed_plateau_kelvin` | Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙 | `None` | `str` |
| `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

@ -547,3 +547,409 @@ def test_brightness_and_color_on_polar_days(date):
at_midnight = settings.brightness_and_color(midnight, is_sleep=False)
assert at_midnight["brightness_pct"] == 30
assert at_midnight["color_temp_kelvin"] == 2000
# --- Color temperature plateau --------------------------------------------
PLATEAU_DAY = dt.date(2022, 6, 1)
H = dt.timedelta(hours=1)
def _plateau_settings(
morning=dt.timedelta(),
evening=dt.timedelta(),
*,
sunset_offset=dt.timedelta(),
sunrise_offset=dt.timedelta(),
adapt_until_sleep=False,
brightness_mode="default",
sleep_rgb_or_color_temp="color_temp",
min_color_temp=2700,
max_color_temp=4000,
):
"""Settings with fixed sunrise 06:00 and sunset 18:00 UTC (noon is 12:00)."""
return SunLightSettings(
name="test",
astral_observer=location.observer,
adapt_until_sleep=adapt_until_sleep,
max_brightness=100,
max_color_temp=max_color_temp,
min_brightness=30,
min_color_temp=min_color_temp,
sleep_brightness=1,
sleep_rgb_or_color_temp=sleep_rgb_or_color_temp,
sleep_color_temp=1000,
sleep_rgb_color=(255, 56, 0),
sunrise_time=dt.time(6, 0),
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=dt.time(18, 0),
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,
sunrise_offset=sunrise_offset,
sunset_offset=sunset_offset,
color_temp_plateau_morning=morning,
color_temp_plateau_evening=evening,
timezone=dt.UTC,
)
def _at(hour, minute=0, second=0):
return dt.datetime.combine(PLATEAU_DAY, dt.time(hour, minute, second), dt.UTC)
def _kelvin(settings, when, is_sleep=False):
return settings.brightness_and_color(when, is_sleep)["color_temp_kelvin"]
def _day_grid(step_minutes=7):
start = _at(0)
return [start + dt.timedelta(minutes=m) for m in range(0, 24 * 60, step_minutes)]
def _reference_sun_position(sun, when):
"""The original (pre-plateau) formula, kept here as the reference."""
target_ts = when.timestamp()
(_, prev_ts), (next_event, next_ts) = sun.prev_and_next_events(when)
h, x = (
(prev_ts, next_ts)
if next_event in (SunEvent.SUNSET, SunEvent.SUNRISE)
else (next_ts, prev_ts)
)
k = 1 if next_event in (SunEvent.SUNSET, SunEvent.NOON) else -1
return k * (1 - ((target_ts - h) / (h - x)) ** 2)
@pytest.mark.parametrize("sunset_offset", [dt.timedelta(), 2 * H, -2 * H])
@pytest.mark.parametrize("adapt_until_sleep", [True, False])
def test_plateau_defaults_are_bit_for_bit_identical(sunset_offset, adapt_until_sleep):
default = _plateau_settings(
sunset_offset=sunset_offset,
adapt_until_sleep=adapt_until_sleep,
)
explicit_zero = _plateau_settings(
dt.timedelta(0),
dt.timedelta(seconds=0),
sunset_offset=sunset_offset,
adapt_until_sleep=adapt_until_sleep,
)
for when in _day_grid():
reference = _reference_sun_position(default.sun, when)
assert default.sun.sun_position(when) == reference
assert default.sun.sun_position(when, dt.timedelta(), dt.timedelta()) == (
reference
)
for is_sleep in (False, True):
assert default.brightness_and_color(
when,
is_sleep,
) == explicit_zero.brightness_and_color(when, is_sleep)
assert default.color_temp_kelvin(reference) == _kelvin(default, when)
def test_plateau_morning_shape():
settings = _plateau_settings(morning=3 * H)
assert _kelvin(settings, _at(6)) == 2700 # sunrise: still the minimum
assert _kelvin(settings, _at(9)) == 4000 # plateau starts 3h before noon
assert _kelvin(settings, _at(9, 0, 1)) == 4000
assert _kelvin(settings, _at(10, 30)) == 4000
assert _kelvin(settings, _at(12)) == 4000
# the ramp is compressed: reaching 4000 earlier than without plateau
default = _plateau_settings()
assert _kelvin(settings, _at(8)) > _kelvin(default, _at(8))
# and is monotonic
values = [_kelvin(settings, _at(6) + dt.timedelta(minutes=m)) for m in range(361)]
assert values == sorted(values)
def test_plateau_evening_shape():
settings = _plateau_settings(evening=4 * H)
assert _kelvin(settings, _at(12)) == 4000
assert _kelvin(settings, _at(16)) == 4000 # plateau ends 4h after noon
assert _kelvin(settings, _at(16, 0, 1)) >= 3995
assert _kelvin(settings, _at(18)) == 2700 # sunset: the minimum again
default = _plateau_settings()
assert _kelvin(settings, _at(15)) > _kelvin(default, _at(15))
values = [_kelvin(settings, _at(12) + dt.timedelta(minutes=m)) for m in range(361)]
assert values == sorted(values, reverse=True)
def test_plateau_sides_are_independent():
default = _plateau_settings()
morning = _plateau_settings(morning=3 * H)
evening = _plateau_settings(evening=3 * H)
both = _plateau_settings(morning=3 * H, evening=3 * H)
noon = _at(12)
for when in _day_grid(5):
before_noon = when < noon
after_noon = when > noon
if after_noon:
# a morning plateau never changes the evening
assert morning.brightness_and_color(
when,
False,
) == default.brightness_and_color(when, False)
assert both.brightness_and_color(when, False) == (
evening.brightness_and_color(when, False)
)
if before_noon:
# an evening plateau never changes the morning
assert evening.brightness_and_color(
when,
False,
) == default.brightness_and_color(when, False)
assert both.brightness_and_color(when, False) == (
morning.brightness_and_color(when, False)
)
def test_plateau_is_continuous_at_its_boundaries():
settings = _plateau_settings(morning=2 * H, evening=2 * H)
eps = dt.timedelta(milliseconds=1)
for boundary in (_at(10), _at(14)):
before = settings.sun.sun_position(boundary - eps, 2 * H, 2 * H)
after = settings.sun.sun_position(boundary + eps, 2 * H, 2 * H)
assert abs(before - after) < 1e-6
assert max(before, after) == pytest.approx(1, abs=1e-6)
# sunrise and sunset still map to exactly 0
for event in (_at(6), _at(18)):
assert settings.sun.sun_position(event, 2 * H, 2 * H) == pytest.approx(
0,
abs=1e-9,
)
def test_plateau_never_decreases_color_temp():
default = _plateau_settings()
plateau = _plateau_settings(morning=5 * H, evening=3 * H)
for when in _day_grid(3):
assert _kelvin(plateau, when) >= _kelvin(default, when)
@pytest.mark.parametrize("plateau", [dt.timedelta(hours=12), dt.timedelta(days=3)])
def test_plateau_larger_than_the_half_day_is_clamped(plateau):
settings = _plateau_settings(plateau, plateau)
sun = settings.sun
assert _kelvin(settings, _at(6)) == 2700
assert _kelvin(settings, _at(18)) == 2700
assert _kelvin(settings, _at(12)) == 4000
previous = None
for when in _day_grid(1):
position = sun.sun_position(when, plateau, plateau)
assert -1 <= position <= 1
if previous is not None and _at(6) <= when <= _at(18):
# no jump bigger than what a smooth ramp over 10% of the half-day
# (36 minutes) allows between consecutive minutes
assert abs(position - previous) < 0.1
previous = position
# a ramp of 10% of the half-day (36 minutes) remains at both ends
assert _kelvin(settings, _at(17, 59)) < 4000
assert _kelvin(settings, _at(17, 30)) < 4000
assert _kelvin(settings, _at(6, 30)) < 4000
def test_plateau_with_a_collapsed_day():
# sunrise and sunset only a few seconds apart around noon
settings = SunLightSettings(
name="test",
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(11, 59, 58),
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=dt.time(12, 0, 2),
min_sunset_time=None,
max_sunset_time=None,
brightness_mode_time_dark=dt.timedelta(minutes=30),
brightness_mode_time_light=dt.timedelta(minutes=30),
color_temp_plateau_morning=12 * H,
color_temp_plateau_evening=12 * H,
timezone=dt.UTC,
)
for seconds in range(-5, 6):
when = _at(12) + dt.timedelta(seconds=seconds)
assert 2700 <= _kelvin(settings, when) <= 4000
assert _kelvin(settings, _at(12)) == 4000
assert _kelvin(settings, _at(3)) == 2700
def test_plateau_does_not_touch_the_night_or_sleep():
default = _plateau_settings(adapt_until_sleep=True)
plateau = _plateau_settings(5 * H, 5 * H, adapt_until_sleep=True)
for when in _day_grid(11):
if when < _at(6) or when > _at(18):
# below the horizon (including the transition to the sleep color)
assert plateau.brightness_and_color(when, False) == (
default.brightness_and_color(when, False)
)
# sleep mode ignores the color temperature curve entirely
sleep = plateau.brightness_and_color(when, True)
assert sleep == default.brightness_and_color(when, True)
assert sleep["color_temp_kelvin"] == 1000
assert sleep["brightness_pct"] == 1
def test_plateau_with_rgb_sleep_color_after_sunset():
default = _plateau_settings(
adapt_until_sleep=True,
sleep_rgb_or_color_temp="rgb_color",
)
plateau = _plateau_settings(
4 * H,
4 * H,
adapt_until_sleep=True,
sleep_rgb_or_color_temp="rgb_color",
)
for when in (_at(19), _at(22), _at(1), _at(5, 30)):
assert plateau.brightness_and_color(when, False) == (
default.brightness_and_color(when, False)
)
@pytest.mark.parametrize("brightness_mode", ["default", "linear", "tanh"])
def test_plateau_does_not_change_brightness(brightness_mode):
default = _plateau_settings(brightness_mode=brightness_mode)
plateau = _plateau_settings(
5 * H,
5 * H,
brightness_mode=brightness_mode,
adapt_until_sleep=True,
)
reference = _plateau_settings(
brightness_mode=brightness_mode,
adapt_until_sleep=True,
)
for when in _day_grid(9):
for is_sleep in (False, True):
assert plateau.brightness_pct(when, is_sleep) == (
reference.brightness_pct(when, is_sleep)
)
result = plateau.brightness_and_color(when, is_sleep)
ref = reference.brightness_and_color(when, is_sleep)
assert result["brightness_pct"] == ref["brightness_pct"]
# the (brightness-driving) sun position itself is left untouched
assert result["sun_position"] == ref["sun_position"]
assert default.brightness_pct(when, False) == reference.brightness_pct(
when,
False,
)
def test_plateau_with_sunset_offset_is_anchored_on_noon():
# sunrise 06:00 and sunset 18:00 + 2h offset = 20:00, so the (adjusted) noon,
# the middle of the two, is 13:00 and the evening ramp spans 13:00 -> 20:00
settings = _plateau_settings(evening=4 * H, sunset_offset=2 * H)
default = _plateau_settings(sunset_offset=2 * H)
assert _kelvin(settings, _at(11)) == _kelvin(default, _at(11))
assert _kelvin(settings, _at(13)) == 4000
assert _kelvin(settings, _at(17)) == 4000 # plateau ends 4h after noon
assert _kelvin(settings, _at(18)) < 4000
assert _kelvin(settings, _at(18)) > _kelvin(default, _at(18))
assert _kelvin(settings, _at(20)) == 2700 # the offset sunset is respected
# the morning is not affected by the sunset offset nor the other way around
morning = _plateau_settings(morning=2 * H, sunset_offset=2 * H)
assert _kelvin(morning, _at(11)) == 4000
assert _kelvin(morning, _at(8)) > _kelvin(default, _at(8))
assert _kelvin(morning, _at(14)) == _kelvin(default, _at(14))
def test_plateau_with_sunrise_and_sunset_times_and_limits():
settings = _plateau_settings(morning=2 * H, evening=2 * H, sunrise_offset=-1 * H)
# sunrise is 05:00 and sunset 18:00: noon is their midpoint (11:30)
sunrise = settings.sun.sunrise(PLATEAU_DAY)
sunset = settings.sun.sunset(PLATEAU_DAY)
noon, _ = settings.sun.noon_and_midnight(_at(12))
assert noon == sunrise + (sunset - sunrise) / 2
assert _kelvin(settings, noon) == 4000
assert _kelvin(settings, noon - 2 * H) == 4000
assert _kelvin(settings, noon + 2 * H) == 4000
assert _kelvin(settings, sunrise) == 2700
assert _kelvin(settings, sunset) == 2700
def test_plateau_astral_sunrise_and_sunset(tzinfo_and_location):
tzinfo, loc = tzinfo_and_location
kwargs = {
"name": "test",
"astral_observer": loc.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": 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),
"sunset_offset": 2 * H,
"timezone": tzinfo,
}
default = SunLightSettings(**kwargs)
plateau = SunLightSettings(
**kwargs,
color_temp_plateau_morning=2 * H,
color_temp_plateau_evening=4 * H,
)
day = dt.datetime(2022, 3, 20, tzinfo=dt.UTC)
noon, _ = default.sun.noon_and_midnight(day)
assert _kelvin(plateau, noon) == 4000
assert _kelvin(plateau, noon - 2 * H) == 4000
assert _kelvin(plateau, noon + 4 * H) == 4000
assert _kelvin(plateau, default.sun.sunset(day.date())) == 2700
assert _kelvin(plateau, default.sun.sunrise(day.date())) == 2700
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
def test_plateau_on_polar_days(date):
kwargs = {
"name": "test",
"astral_observer": TROMSO.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": 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"),
}
plateau = SunLightSettings(
**kwargs,
color_temp_plateau_morning=12 * H,
color_temp_plateau_evening=12 * H,
)
start = dt.datetime(date.year, date.month, date.day, tzinfo=dt.UTC)
noon, _ = plateau.sun.noon_and_midnight(start)
assert _kelvin(plateau, noon) == 4000
for minutes in range(0, 24 * 60, 5):
when = start + dt.timedelta(minutes=minutes)
assert 2700 <= _kelvin(plateau, when) <= 4000

View file

@ -11,6 +11,11 @@ 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_FIXED_PLATEAU_KELVIN,
CONF_COLOR_TEMP_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
@ -108,8 +113,13 @@ async def test_options(hass):
advanced_data = ADVANCED_DATA.copy()
advanced_data[CONF_INITIAL_TRANSITION] = 23
advanced_data[CONF_EXPAND_LIGHT_GROUPS] = False
advanced_data[CONF_COLOR_TEMP_PLATEAU_MORNING] = 1800
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"
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = "4000"
basic_data = {**BASIC_DATA, "min_brightness": 12}
user_input = {
**basic_data,
@ -220,6 +230,79 @@ 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"}
@pytest.mark.parametrize(
("plateau_end", "descent_end", "plateau_kelvin"),
[
(NONE_STR, NONE_STR, "4000"), # needs the times
("21:00:00", "23:00:00", "1999"), # below the default minimum (2000 K)
("21:00:00", "23:00:00", "5501"), # above the default maximum (5500 K)
("21:00:00", "23:00:00", "warm"),
("21:00:00", "23:00:00", ""),
],
)
async def test_options_flow_rejects_incoherent_plateau_kelvin(
hass,
plateau_end,
descent_end,
plateau_kelvin,
):
"""An unusable custom evening plateau temperature is an options flow error."""
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
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = plateau_kelvin
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

@ -0,0 +1,439 @@
"""Tests for the custom temperature of the fixed evening color temperature plateau."""
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.const import (
_DOMAIN_SCHEMA,
validate_fixed_plateau_kelvin,
)
from .test_fixed_evening import (
PARIS,
SEASON_DAYS,
TROMSO_LOCATION,
grid,
kelvin,
local,
make_settings,
)
TOULOUSE = Location(
LocationInfo(
name="Toulouse",
region="France",
timezone="Europe/Paris",
latitude=43.57,
longitude=1.48,
),
)
OSLO = zoneinfo.ZoneInfo("Europe/Oslo")
MINUTE = dt.timedelta(minutes=1)
def user_case(plateau_kelvin=4000, **kwargs):
"""Charles' kitchen: 5500 K at noon, held at 4000 K until 21:00, 2700 K at 23:00."""
params = {
"location": TOULOUSE,
"max_color_temp": 5500,
"sunset_offset": dt.timedelta(hours=2),
"color_temp_plateau_morning": dt.timedelta(seconds=3600),
"color_temp_plateau_evening": dt.timedelta(seconds=14400),
"color_temp_fixed_plateau_kelvin": plateau_kelvin,
}
params.update(kwargs)
return make_settings(dt.time(21, 0), dt.time(23, 0), **params)
def without_fixed(**kwargs):
"""The same settings without any evening option."""
kwargs.setdefault("location", TOULOUSE)
kwargs.setdefault("color_temp_plateau_morning", dt.timedelta(seconds=3600))
kwargs.setdefault("color_temp_plateau_evening", dt.timedelta(seconds=14400))
return make_settings(
max_color_temp=5500,
sunset_offset=dt.timedelta(hours=2),
**kwargs,
)
def solar_noon(settings, day):
when = local(day, 12, 0)
return min(
(ts for ev, ts in settings.sun.sun_events(when) if ev.name == "NOON"),
)
ALL_DAYS = [dt.date(2026, 1, 1) + dt.timedelta(days=n) for n in range(365)]
def test_target_is_reached_and_held_until_21h_every_day_of_the_year():
"""Exactly 4000 K from 20:00 to 21:00 (Cuisine options), 365 days, DST included."""
settings = user_case()
for day in ALL_DAYS:
for hour, minute in [(20, 0), (20, 15), (20, 30), (20, 45), (20, 59), (21, 0)]:
assert kelvin(settings, local(day, hour, minute)) == 4000, (day, hour)
assert kelvin(settings, local(day, 22, 0)) == 3350, day
assert kelvin(settings, local(day, 23, 0)) == 2700, day
assert kelvin(settings, local(day, 23, 30)) == 2700, day
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_user_case_all_year(day):
"""Daytime maximum kept, target held, then easing to the minimum."""
settings = user_case()
peak = max(kelvin(settings, when) for when in grid(day, 5))
assert peak >= 5490 # the daytime maximum is not capped by the plateau
assert kelvin(settings, local(day, 14, 0)) >= 5490
held = [kelvin(settings, when) for when in grid(day, 1)]
start = 20 * 60
assert held[start : 21 * 60 + 1] == [4000] * 61
assert kelvin(settings, local(day, 22, 30)) == 2890
assert kelvin(settings, local(day, 21, 30)) == 3810
@pytest.mark.parametrize("day", SEASON_DAYS)
@pytest.mark.parametrize("plateau_evening", [0, 3600, 4 * 3600, 8 * 3600, 43200])
@pytest.mark.parametrize("plateau_morning", [0, 3600])
def test_target_is_held_for_any_evening_plateau_duration(
day,
plateau_evening,
plateau_morning,
):
"""Between the regular curve and the target, the target is always reached."""
extra = {
"color_temp_plateau_evening": dt.timedelta(seconds=plateau_evening),
"color_temp_plateau_morning": dt.timedelta(seconds=plateau_morning),
}
fixed = user_case(**extra)
regular = without_fixed(**extra)
noon = dt.datetime.fromtimestamp(solar_noon(fixed, day), PARIS)
for when in grid(day, 3):
if noon <= when <= local(day, 21, 0):
value = kelvin(fixed, when)
assert value >= 4000
# never above what the regular curve (or the target) would give
assert value <= max(kelvin(regular, when), 4000)
for minute in range(20 * 60, 21 * 60 + 1, 5):
assert kelvin(fixed, local(day, 0, 0) + dt.timedelta(minutes=minute)) == 4000
@pytest.mark.parametrize("day", [dt.date(2026, 1, 15), dt.date(2026, 12, 21)])
def test_when_the_regular_curve_gets_there_in_time_it_is_not_altered(day):
"""In winter the regular afternoon curve reaches the target by itself."""
fixed = user_case()
regular = without_fixed()
noon = dt.datetime.fromtimestamp(solar_noon(fixed, day), PARIS)
for when in grid(day, 3):
if noon <= when <= local(day, 21, 0):
assert kelvin(fixed, when) == max(kelvin(regular, when), 4000)
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_morning_brightness_and_noon_are_unchanged(day):
"""Everything before solar noon, and the brightness all day, is untouched."""
fixed = user_case()
regular = without_fixed()
noon = dt.datetime.fromtimestamp(solar_noon(fixed, day), PARIS)
for when in grid(day, 3):
a = fixed.brightness_and_color(when, False)
b = regular.brightness_and_color(when, False)
assert a["brightness_pct"] == b["brightness_pct"]
if when < noon:
assert a == b
# the sleep mode keeps priority
for when in grid(day, 30):
assert fixed.brightness_and_color(when, True) == regular.brightness_and_color(
when,
True,
)
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_unset_target_is_identical_to_before(day):
"""No target: exactly the behavior of the fixed times without the option."""
old = make_settings(dt.time(21, 0), dt.time(23, 0), location=TOULOUSE)
new = make_settings(
dt.time(21, 0),
dt.time(23, 0),
location=TOULOUSE,
color_temp_fixed_plateau_kelvin=None,
)
for when in grid(day, 7):
assert old.brightness_and_color(when, False) == new.brightness_and_color(
when,
False,
)
@pytest.mark.parametrize("day", SEASON_DAYS)
def test_target_equal_to_max_is_identical_to_unset(day):
"""A target at the maximum is the historical hold at the maximum."""
old = make_settings(dt.time(21, 0), dt.time(23, 0), location=TOULOUSE)
new = make_settings(
dt.time(21, 0),
dt.time(23, 0),
location=TOULOUSE,
color_temp_fixed_plateau_kelvin=4000,
)
for when in grid(day, 7):
assert old.brightness_and_color(when, False) == new.brightness_and_color(
when,
False,
)
@pytest.mark.parametrize("plateau_kelvin", [2700, 2705, 3000, 4000, 5000, 5495, 5500])
@pytest.mark.parametrize(
"day",
[*SEASON_DAYS, dt.date(2026, 3, 28), dt.date(2026, 3, 29), dt.date(2026, 10, 25)],
)
def test_continuous_monotonic_and_bounded(day, plateau_kelvin):
"""No jump anywhere, non-increasing from noon on, always within min and max."""
settings = user_case(plateau_kelvin)
values = [(when, kelvin(settings, when)) for when in grid(day, 1, days=2)]
noon = dt.datetime.fromtimestamp(solar_noon(settings, day), PARIS)
for _, value in values:
assert 2700 <= value <= 5500
for (_, a), (_, b) in pairwise(values):
assert abs(a - b) <= 40 # per minute; the steepest legit slope is ~37 K/min
after_noon = [value for when, value in values if noon <= when <= local(day, 23, 0)]
assert after_noon == sorted(after_noon, reverse=True)
@pytest.mark.parametrize("plateau_end_hour", [12, 14, 15, 17, 19, 21])
@pytest.mark.parametrize("day", [dt.date(2026, 6, 21), dt.date(2026, 12, 21)])
def test_no_step_when_the_plateau_ends_before_the_target_is_reached(
day,
plateau_end_hour,
):
"""An early plateau end squeezes the ramp (steeper) but never steps."""
settings = make_settings(
dt.time(plateau_end_hour, 0),
dt.time(23, 0),
location=TOULOUSE,
max_color_temp=5500,
sunset_offset=dt.timedelta(hours=2),
color_temp_fixed_plateau_kelvin=3000,
)
values = [kelvin(settings, when) for when in grid(day, 1, days=2)]
# a 30 minute ramp over 2500 K peaks at 167 K/min; a step would be far larger
assert max(abs(a - b) for a, b in pairwise(values)) <= 170
assert kelvin(settings, local(day, 23, 0)) == 2700
noon = dt.datetime.fromtimestamp(solar_noon(settings, day), PARIS)
plateau_end = local(day, plateau_end_hour, 0)
if plateau_end >= noon + dt.timedelta(hours=1):
assert kelvin(settings, plateau_end) == 3000
assert kelvin(settings, plateau_end - dt.timedelta(minutes=29)) == 3000
def test_target_at_the_minimum():
"""A target equal to the minimum follows the curve down and then holds."""
settings = user_case(2700)
day = dt.date(2026, 10, 19)
values = [kelvin(settings, when) for when in grid(day, 1, days=2)]
assert max(abs(a - b) for a, b in pairwise(values)) <= 40
# the regular curve is still slightly above the minimum at 21:00 here
assert 2700 <= kelvin(settings, local(day, 21, 0)) <= 2760
assert kelvin(settings, local(day, 22, 0)) <= 2730
assert kelvin(settings, local(day, 23, 0)) == 2700
assert kelvin(settings, local(day, 14, 0)) > 5000
def test_max_equals_min_does_not_divide_by_zero():
settings = make_settings(
dt.time(21, 0),
dt.time(23, 0),
location=TOULOUSE,
min_color_temp=3000,
max_color_temp=3000,
color_temp_fixed_plateau_kelvin=3000,
)
for when in grid(dt.date(2026, 6, 21), 10):
assert kelvin(settings, when) == 3000
def test_exact_values_for_the_user_case_in_october():
"""Toulouse, 2026-10-19: max by day, 4000 K from 20:00, 2700 K at 23:00."""
settings = user_case()
day = dt.date(2026, 10, 19)
assert kelvin(settings, local(day, 14, 0)) == 5500 # still reaches the maximum
assert kelvin(settings, local(day, 17, 0)) == 5495
assert kelvin(settings, local(day, 18, 0)) == 5295
assert kelvin(settings, local(day, 19, 0)) == 4795
assert kelvin(settings, local(day, 19, 30)) == 4435
assert kelvin(settings, local(day, 20, 0)) == 4000
assert kelvin(settings, local(day, 21, 0)) == 4000
assert kelvin(settings, local(day, 21, 30)) == 3810
assert kelvin(settings, local(day, 22, 0)) == 3350
assert kelvin(settings, local(day, 22, 30)) == 2890
assert kelvin(settings, local(day, 23, 0)) == 2700
@pytest.mark.parametrize(
"plateau_end",
[dt.time(12, 0), dt.time(13, 0), dt.time(14, 30), dt.time(15, 5), dt.time(15, 30)],
)
@pytest.mark.parametrize("day", [dt.date(2026, 6, 21), dt.date(2026, 12, 21)])
def test_plateau_end_close_to_solar_noon(day, plateau_end):
"""A short afternoon squeezes the ramp but stays continuous, bounded, monotonic."""
settings = make_settings(
plateau_end,
dt.time(23, 0),
location=TOULOUSE,
max_color_temp=5500,
sunset_offset=dt.timedelta(hours=2),
color_temp_fixed_plateau_kelvin=4000,
)
noon = dt.datetime.fromtimestamp(solar_noon(settings, day), PARIS)
values = [(w, kelvin(settings, w)) for w in grid(day, 1, days=2)]
assert all(2700 <= v <= 5500 for _, v in values)
after = [v for w, v in values if noon <= w <= local(day, 23, 0)]
assert after == sorted(after, reverse=True)
# when the plateau end is after solar noon the target is held until then
plateau_dt = dt.datetime.combine(day, plateau_end, PARIS)
if plateau_dt >= noon + dt.timedelta(hours=1):
assert kelvin(settings, plateau_dt) == 4000
assert kelvin(settings, plateau_dt - dt.timedelta(minutes=1)) == 4000
assert max(abs(a - b) for (_, a), (_, b) in pairwise(values)) <= 150
else: # less than 1 h after noon: ignored, the historical behavior
old = make_settings(
plateau_end,
dt.time(23, 0),
location=TOULOUSE,
max_color_temp=5500,
sunset_offset=dt.timedelta(hours=2),
)
for when in grid(day, 7):
assert kelvin(settings, when) == kelvin(old, when)
@pytest.mark.parametrize("day", [dt.date(2026, 3, 29), dt.date(2026, 10, 25)])
def test_dst_wall_clock_times_hold(day):
settings = user_case()
assert kelvin(settings, local(day, 23, 0)) == 2700
assert kelvin(settings, local(day, 22, 0)) == 3350
def test_polar_days():
for day in (dt.date(2026, 6, 21), dt.date(2026, 12, 21)):
settings = make_settings(
dt.time(21, 0),
dt.time(23, 0),
location=TROMSO_LOCATION,
timezone=OSLO,
max_color_temp=5500,
color_temp_fixed_plateau_kelvin=4000,
)
regular = make_settings(
dt.time(21, 0),
dt.time(23, 0),
location=TROMSO_LOCATION,
timezone=OSLO,
max_color_temp=5500,
)
when = grid(day, 5, days=2, tz=OSLO)
values = [kelvin(settings, w) for w in when]
baseline = [kelvin(regular, w) for w in when]
assert all(2700 <= value <= 5500 for value in values)
def steepest(series):
return max(abs(a - b) for a, b in pairwise(series))
# the (very short, synthetic) polar days are steep by themselves; the
# option must not add a step on top of the historical fixed evening
assert steepest(values) <= steepest(baseline)
@pytest.mark.parametrize("rgb", ["color_temp", "rgb_color"])
@pytest.mark.parametrize("adapt_until_sleep", [True, False])
def test_sleep_and_night_options_keep_working(rgb, adapt_until_sleep):
day = dt.date(2026, 10, 19)
fixed = user_case(adapt_until_sleep=adapt_until_sleep, sleep_rgb_or_color_temp=rgb)
regular = without_fixed(
adapt_until_sleep=adapt_until_sleep,
sleep_rgb_or_color_temp=rgb,
)
for when in grid(day, 15, days=2):
assert fixed.brightness_and_color(when, True) == regular.brightness_and_color(
when,
True,
)
assert (
fixed.brightness_and_color(when, False)["brightness_pct"]
== regular.brightness_and_color(when, False)["brightness_pct"]
)
# --- validation ----------------------------------------------------------------
@pytest.mark.parametrize("kelvin_value", [None, "None"])
def test_unset_is_always_valid(kelvin_value):
validate_fixed_plateau_kelvin(kelvin_value, None, None, 2700, 5500)
validate_fixed_plateau_kelvin(kelvin_value, "21:00:00", "23:00:00", 2700, 5500)
@pytest.mark.parametrize("kelvin_value", [2700, 4000, 5500, "4000"])
def test_valid_targets(kelvin_value):
validate_fixed_plateau_kelvin(kelvin_value, "21:00:00", "23:00:00", 2700, 5500)
@pytest.mark.parametrize(
("kelvin_value", "plateau_end", "descent_end"),
[
(4000, None, None), # needs the times
(4000, "21:00:00", None),
(4000, None, "23:00:00"),
(4000, "None", "None"),
(2699, "21:00:00", "23:00:00"), # below min
(5501, "21:00:00", "23:00:00"), # above max
(999, "21:00:00", "23:00:00"),
(10001, "21:00:00", "23:00:00"),
(0, "21:00:00", "23:00:00"),
(-4000, "21:00:00", "23:00:00"),
("warm", "21:00:00", "23:00:00"),
("", "21:00:00", "23:00:00"),
],
)
def test_invalid_targets(kelvin_value, plateau_end, descent_end):
with pytest.raises(vol.Invalid):
validate_fixed_plateau_kelvin(
kelvin_value,
plateau_end,
descent_end,
2700,
5500,
)
def test_yaml_schema():
times = {
"color_temp_fixed_plateau_end": "21:00:00",
"color_temp_fixed_descent_end": "23:00:00",
}
assert "color_temp_fixed_plateau_kelvin" not in _DOMAIN_SCHEMA({})
assert "color_temp_fixed_plateau_kelvin" not in _DOMAIN_SCHEMA(times)
config = _DOMAIN_SCHEMA(
{**times, "color_temp_fixed_plateau_kelvin": 4000, "max_color_temp": 5500},
)
assert config["color_temp_fixed_plateau_kelvin"] == 4000
for bad in (
{"color_temp_fixed_plateau_kelvin": 4000}, # no times
{**times, "color_temp_fixed_plateau_kelvin": 6000}, # above the default max
{**times, "color_temp_fixed_plateau_kelvin": 1999}, # below the default min
{**times, "color_temp_fixed_plateau_kelvin": 500},
{**times, "color_temp_fixed_plateau_kelvin": "warm"},
{
**times,
"color_temp_fixed_plateau_kelvin": 4100,
"max_color_temp": 4000,
},
):
with pytest.raises(vol.Invalid):
_DOMAIN_SCHEMA(bad)

View file

@ -37,6 +37,11 @@ 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_FIXED_PLATEAU_KELVIN,
CONF_COLOR_TEMP_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_DETECT_NON_HA_CHANGES,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
@ -127,7 +132,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
@ -2904,6 +2909,303 @@ async def test_change_switch_settings_service(hass):
assert switch._sun_light_settings.min_color_temp == 2500
async def test_color_temp_plateau_defaults_and_service(hass):
"""The plateau options default to 0 and can be changed at runtime."""
switch, _ = await setup_lights_and_switch(hass)
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(0)
assert settings.color_temp_plateau_evening == datetime.timedelta(0)
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_PLATEAU_EVENING: 7200})
settings = switch._sun_light_settings
assert settings.color_temp_plateau_evening == datetime.timedelta(hours=2)
# the other side is untouched
assert settings.color_temp_plateau_morning == datetime.timedelta(0)
await change_switch_settings(**{CONF_COLOR_TEMP_PLATEAU_MORNING: 3600})
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(hours=1)
assert settings.color_temp_plateau_evening == datetime.timedelta(hours=2)
for key in (CONF_COLOR_TEMP_PLATEAU_MORNING, CONF_COLOR_TEMP_PLATEAU_EVENING):
for invalid in (-1, 43201):
with pytest.raises(voluptuous.error.MultipleInvalid):
await change_switch_settings(**{key: invalid})
await change_switch_settings(**{CONF_USE_DEFAULTS: "factory"})
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(0)
assert settings.color_temp_plateau_evening == datetime.timedelta(0)
async def test_color_temp_plateau_from_configuration(hass):
"""The options from the config entry reach the sun light settings."""
_, switch = await setup_switch(
hass,
{
CONF_COLOR_TEMP_PLATEAU_MORNING: 1800,
CONF_COLOR_TEMP_PLATEAU_EVENING: datetime.timedelta(hours=3),
},
)
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(minutes=30)
assert settings.color_temp_plateau_evening == datetime.timedelta(hours=3)
@pytest.mark.parametrize("invalid", [-1, 43201])
@pytest.mark.parametrize(
"key",
[CONF_COLOR_TEMP_PLATEAU_MORNING, CONF_COLOR_TEMP_PLATEAU_EVENING],
)
def test_color_temp_plateau_yaml_validation(invalid, key):
"""Out of range plateaus are rejected in the YAML schema."""
from homeassistant.components.adaptive_lighting.const import _DOMAIN_SCHEMA
assert _DOMAIN_SCHEMA({key: 3600})[key] == 3600
with pytest.raises(voluptuous.error.MultipleInvalid):
_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)
async def test_fixed_plateau_kelvin_service(hass):
"""The custom evening plateau temperature is settable at runtime."""
switch, _ = await setup_lights_and_switch(hass)
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin 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()
times = {
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
}
# needs the times
with pytest.raises(ServiceValidationError):
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000})
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
await change_switch_settings(
**{CONF_MAX_COLOR_TEMP: 5500, CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000},
**times,
)
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
# out of range or mistyped: rejected, settings untouched
for bad in (1999, 5501, 500, 10001, "warm"):
with pytest.raises((ServiceValidationError, voluptuous.error.Invalid)):
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: bad})
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
# lowering the maximum below the target is rejected too
with pytest.raises(ServiceValidationError):
await change_switch_settings(**{CONF_MAX_COLOR_TEMP: 3500})
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
# unrelated changes keep it ("current" defaults)
await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 80})
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
# clearing the times alone is rejected because the target needs them
with pytest.raises(ServiceValidationError):
await change_switch_settings(
**{
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "None",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "None",
},
)
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: "None"})
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000})
await change_switch_settings(**{CONF_USE_DEFAULTS: "factory"})
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
async def test_fixed_plateau_kelvin_from_configuration(hass):
"""The option from the config entry reaches the sun light settings."""
_, switch = await setup_switch(
hass,
{
CONF_MAX_COLOR_TEMP: 5500,
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: "4000", # as stored by the UI
},
)
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
@pytest.mark.parametrize(
"options",
[
{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000}, # no times
{
CONF_MAX_COLOR_TEMP: 3500,
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000, # above max
},
],
)
async def test_fixed_plateau_kelvin_incoherent_stored_configuration_is_ignored(
hass,
caplog,
options,
):
"""A hand edited stored target is ignored and logged, never fatal."""
_, switch = await setup_switch(hass, options)
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
assert "ignoring the incoherent fixed evening plateau" in caplog.text
@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."""

View file

@ -21,6 +21,7 @@ nav = [
{ "Troubleshooting" = "troubleshooting.md" },
{ "Advanced" = [
{ "Brightness Modes" = "advanced/brightness-modes.md" },
{ "Color Temperature Plateau" = "advanced/color-temperature-plateau.md" },
{ "Manual Control" = "advanced/manual-control.md" },
{ "Sleep Mode" = "advanced/sleep-mode.md" },
] },