diff --git a/README.md b/README.md index 4f1bac64..88b68459 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 4645f994..c16fc656 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -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 diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py index 3ffe5400..0e0ecd4e 100644 --- a/custom_components/adaptive_lighting/config_flow.py +++ b/custom_components/adaptive_lighting/config_flow.py @@ -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): diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index afead25c..f91e2aba 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -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 diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index c95e31b5..1c09a9e1 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -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: '' diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 9e22ff57..592a3dfa 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -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" diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index db1c2d0b..a5b1cb5e 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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( diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index cc589156..17401767 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -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" diff --git a/docs/advanced/color-temperature-plateau.md b/docs/advanced/color-temperature-plateau.md new file mode 100644 index 00000000..200c016e --- /dev/null +++ b/docs/advanced/color-temperature-plateau.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index afd05721..bf32600a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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` | diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 8598b121..3be0105c 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -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 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index addc460c..c8121405 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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() diff --git a/tests/test_fixed_evening.py b/tests/test_fixed_evening.py new file mode 100644 index 00000000..0c9ac63a --- /dev/null +++ b/tests/test_fixed_evening.py @@ -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", + }, + ) diff --git a/tests/test_fixed_plateau_kelvin.py b/tests/test_fixed_plateau_kelvin.py new file mode 100644 index 00000000..23970693 --- /dev/null +++ b/tests/test_fixed_plateau_kelvin.py @@ -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) diff --git a/tests/test_switch.py b/tests/test_switch.py index a3f5b689..abf76251 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -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.""" diff --git a/zensical.toml b/zensical.toml index dfa1b309..305bb9f7 100644 --- a/zensical.toml +++ b/zensical.toml @@ -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" }, ] },