feat: add custom evening plateau color temperature

Add the optional color_temp_fixed_plateau_kelvin setting (between
min_color_temp and max_color_temp) for the fixed evening schedule. The
daytime maximum is kept; after it the color temperature descends along the
regular afternoon curve, reaches the target exactly and holds it for the last
hour before color_temp_fixed_plateau_end, whatever the season, then eases to
min_color_temp as before. The curve is sped up when the regular one would be
too late (summer), without any step. Unset keeps the previous behavior.

Validated in the options flow, YAML, the stored configuration and the
change_switch_settings service, with services, translations and docs.
This commit is contained in:
Charles-HL 2026-10-19 17:50:23 +02:00
commit 238d189f40
13 changed files with 809 additions and 6 deletions

View file

@ -160,6 +160,7 @@ The YAML and frontend configuration methods support all of the options listed be
| `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

@ -44,6 +44,12 @@ _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."
@ -252,6 +258,8 @@ class SunEvents:
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.
@ -262,6 +270,17 @@ class SunEvents:
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
@ -298,15 +317,76 @@ class SunEvents:
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 1.0
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 0.5 * (1 + math.cos(math.pi * (target_ts - start) / (end - start)))
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,
@ -396,6 +476,7 @@ class SunLightSettings:
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
@ -494,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:
@ -534,6 +623,8 @@ class SunLightSettings:
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

View file

@ -13,12 +13,18 @@ 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
@ -135,6 +141,17 @@ def validate_options(user_input: dict[str, Any], errors: dict[str, str]) -> None
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

@ -242,6 +242,14 @@ DOCS[CONF_COLOR_TEMP_FIXED_DESCENT_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)
@ -463,6 +471,7 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
),
(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,
@ -542,6 +551,7 @@ EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = {
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),
}
@ -588,6 +598,37 @@ def validate_fixed_evening(plateau_end: Any, descent_end: Any) -> None:
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))
@ -612,6 +653,13 @@ def _validate_yaml_fixed_evening(config: dict[str, Any]) -> dict[str, Any]:
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
@ -644,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] = {
@ -653,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

@ -235,6 +235,15 @@ change_switch_settings:
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

@ -68,6 +68,7 @@
"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.",
@ -105,6 +106,7 @@
"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. ⏲️",
@ -276,6 +278,10 @@
"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

@ -103,6 +103,7 @@ from .const import (
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,
@ -157,6 +158,7 @@ from .const import (
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 (
@ -630,6 +632,22 @@ def validate(
)
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
@ -1065,6 +1083,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
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

@ -69,6 +69,7 @@
"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.",
@ -106,6 +107,7 @@
"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. ⏲️",
@ -277,6 +279,10 @@
"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

@ -43,6 +43,7 @@ The plateau options above are measured from solar noon, so the evening moves wit
|--------|---------|-------------|
| `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:
@ -53,10 +54,30 @@ adaptive_lighting:
color_temp_fixed_descent_end: "23:00:00" # smooth descent to 2700 K at 23:00
```
Both options are available in the UI (advanced section), in YAML and in `adaptive_lighting.change_switch_settings`. When both are unset, which is the default, nothing changes.
### Evening plateau lower than the daytime maximum
- Only the color temperature is affected. The brightness, the sleep mode (which keeps priority) and the morning curve are unchanged. `color_temp_plateau_evening` is superseded by these times while they are set.
- The color temperature follows the default curve until solar noon (and stays at `max_color_temp` from then until `color_temp_fixed_plateau_end`), then descends with a smooth cosine easing, then stays at `min_color_temp` until sunset or later.
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.

View file

@ -64,6 +64,7 @@ All configuration options are listed below with their default values. These opti
| `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

@ -13,6 +13,7 @@ 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,
@ -118,6 +119,7 @@ async def test_options(hass):
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,
@ -263,6 +265,44 @@ async def test_options_flow_rejects_incoherent_fixed_evening(
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()

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

@ -39,6 +39,7 @@ from homeassistant.components.adaptive_lighting.const import (
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,
@ -3108,6 +3109,103 @@ def test_fixed_evening_yaml_validation(plateau_end, descent_end):
_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."""