From 90d9bbc2014fa8214a25d8f61d5e7255a419e31b Mon Sep 17 00:00:00 2001 From: Charles-HL Date: Mon, 19 Oct 2026 00:21:34 +0200 Subject: [PATCH] feat: add fixed evening color temperature schedule --- README.md | 2 + .../adaptive_lighting/color_and_brightness.py | 74 +++ .../adaptive_lighting/config_flow.py | 11 + custom_components/adaptive_lighting/const.py | 92 +++- .../adaptive_lighting/services.yaml | 12 + .../adaptive_lighting/strings.json | 12 + custom_components/adaptive_lighting/switch.py | 26 +- .../adaptive_lighting/translations/en.json | 12 + docs/advanced/color-temperature-plateau.md | 27 + docs/configuration.md | 2 + tests/test_config_flow.py | 39 ++ tests/test_fixed_evening.py | 518 ++++++++++++++++++ tests/test_switch.py | 138 ++++- 13 files changed, 957 insertions(+), 8 deletions(-) create mode 100644 tests/test_fixed_evening.py diff --git a/README.md b/README.md index 36cc7365..0837c221 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ The YAML and frontend configuration methods support all of the options listed be | `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` | | `color_temp_plateau_morning` | The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️ | `0` | `int` 0-43200 | | `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 | +| `color_temp_fixed_plateau_end` | Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year, instead of following the sun. Needs `color_temp_fixed_descent_end`; unset keeps the default curve. Only affects the color temperature. 🌙 | `None` | `str` (HH:MM:SS) | +| `color_temp_fixed_descent_end` | Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight, at most 12 hours later. Needs `color_temp_fixed_plateau_end`; unset keeps the default curve. 🌙 | `None` | `str` (HH:MM:SS) | | `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` | | `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` | | `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` | diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index c5f2e42b..3868e178 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -247,6 +247,66 @@ class SunEvents: i_now = bisect.bisect([ts for _, ts in events], dt.timestamp()) return events[i_now - 1 : i_now + 1] + def fixed_evening_position( + self, + dt: datetime.datetime, + plateau_end: datetime.time, + descent_end: datetime.time, + ) -> float | None: + """Position in [0, 1] for the fixed evening color temperature, or None. + + `plateau_end` and `descent_end` are local wall-clock times. Starting at + the last solar noon before `dt`, the position is 1 until the first + `plateau_end` after that noon (or the noon itself if it is later), then + eases from 1 to 0 (cosine, so without kinks) until the `descent_end` + following it, and stays 0 until sunset if that is later. After that + (or before the noon) None is returned and the regular curve applies. + + The window is cut at the next sunrise, so the position reaches 0 no + later than the morning curve starts at 0, and it is ignored for a day + where it does not fit between noon and the next sunrise (None). The + times are wall-clock: across a DST change the real duration changes + by the shift, but the position stays continuous and monotonic. + """ + target_ts = dt.timestamp() + events = sorted( + (event, ts) + for days in (-1, 0, 1) + for event, ts in self.sun_events(dt + timedelta(days=days)) + ) + noons = [ts for event, ts in events if event == SunEvent.NOON] + noon_ts = max((ts for ts in noons if ts <= target_ts), default=None) + if noon_ts is None: + return None + sunrise_ts = min( + (ts for e, ts in events if e == SunEvent.SUNRISE and ts > noon_ts), + default=None, + ) + sunset_ts = min( + (ts for e, ts in events if e == SunEvent.SUNSET and ts > noon_ts), + default=None, + ) + if sunrise_ts is None or sunset_ts is None: # pragma: no cover + return None + local_day = datetime.datetime.fromtimestamp(noon_ts, self.timezone).date() + plateau_ts = self._replace_time(local_day, plateau_end).timestamp() + descent_day = local_day + if descent_end <= plateau_end: # the descent ends after midnight + descent_day += timedelta(days=1) + descent_ts = self._replace_time(descent_day, descent_end).timestamp() + start = max(plateau_ts, noon_ts) + end = min(descent_ts, sunrise_ts) + if end <= start: + return None + if target_ts <= start: + return 1.0 + if target_ts <= end: + return 0.5 * (1 + math.cos(math.pi * (target_ts - start) / (end - start))) + # Past the descent: hold the minimum until sunset, where the regular + # curve (which is 0 at sunset) continues, so a descent finishing + # before sunset does not jump back up. + return 0.0 if target_ts <= sunset_ts else None + def sun_position( self, dt: datetime.datetime, @@ -334,6 +394,8 @@ class SunLightSettings: brightness_mode: Literal["default", "linear", "tanh"] = "default" color_temp_plateau_morning: datetime.timedelta = datetime.timedelta() color_temp_plateau_evening: datetime.timedelta = datetime.timedelta() + color_temp_fixed_plateau_end: datetime.time | None = None + color_temp_fixed_descent_end: datetime.time | None = None sunrise_offset: datetime.timedelta = datetime.timedelta() sunset_offset: datetime.timedelta = datetime.timedelta() timezone: datetime.tzinfo = UTC @@ -464,6 +526,17 @@ class SunLightSettings: if self.color_temp_plateau_morning or self.color_temp_plateau_evening else sun_position ) + if ( + self.color_temp_fixed_plateau_end is not None + and self.color_temp_fixed_descent_end is not None + ): + fixed_position = self.sun.fixed_evening_position( + dt, + self.color_temp_fixed_plateau_end, + self.color_temp_fixed_descent_end, + ) + if fixed_position is not None: + color_sun_position = fixed_position rgb_color: tuple[int, int, int] # Variable `force_rgb_color` is needed for RGB color after sunset (if enabled) force_rgb_color = False @@ -475,6 +548,7 @@ class SunLightSettings: self.sleep_rgb_or_color_temp == "rgb_color" and self.adapt_until_sleep and sun_position < 0 + and color_sun_position < 0 ): # Feature requested in # https://github.com/basnijholt/adaptive-lighting/issues/624 diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py index 3ffe5400..76806851 100644 --- a/custom_components/adaptive_lighting/config_flow.py +++ b/custom_components/adaptive_lighting/config_flow.py @@ -11,11 +11,14 @@ from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig from .const import ( # pylint: disable=unused-import BASIC_OPTIONS, + CONF_COLOR_TEMP_FIXED_DESCENT_END, + CONF_COLOR_TEMP_FIXED_PLATEAU_END, CONF_LIGHTS, DOMAIN, EXTRA_VALIDATION, NONE_STR, VALIDATION_TUPLES, + validate_fixed_evening, ) from .switch import validate @@ -124,6 +127,14 @@ def validate_options(user_input: dict[str, Any], errors: dict[str, str]) -> None except vol.Invalid: _LOGGER.exception("Configuration option %s=%s is incorrect", key, value) errors["base"] = "option_error" + try: + validate_fixed_evening( + user_input.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END), + user_input.get(CONF_COLOR_TEMP_FIXED_DESCENT_END), + ) + except vol.Invalid: + _LOGGER.exception("The fixed evening color temperature times are incorrect") + errors["base"] = "option_error" class OptionsFlowHandler(config_entries.OptionsFlow): diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index f840b868..2afc0d82 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 @@ -228,6 +228,24 @@ DOCS[CONF_COLOR_TEMP_PLATEAU_EVENING] = ( # Each side of the plateau is limited to half a day. MAX_COLOR_TEMP_PLATEAU = 12 * 60 * 60 +CONF_COLOR_TEMP_FIXED_PLATEAU_END = "color_temp_fixed_plateau_end" +DOCS[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = ( + "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at " + "`max_color_temp` in the evening, every day of the year. Needs " + "`color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. " + "Only affects the color temperature. 🌙" +) +CONF_COLOR_TEMP_FIXED_DESCENT_END = "color_temp_fixed_descent_end" +DOCS[CONF_COLOR_TEMP_FIXED_DESCENT_END] = ( + "Local time (HH:MM:SS) at which the color temperature has smoothly descended from " + "`max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. " + "May be after midnight but at most 12 hours after the plateau end. Needs " + "`color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙" +) +# The descent from the plateau end to the descent end lasts at most this long. +MAX_COLOR_TEMP_FIXED_DESCENT = timedelta(hours=12) +MIN_COLOR_TEMP_FIXED_PLATEAU_END = time(12, 0) + CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True DOCS[CONF_TAKE_OVER_CONTROL] = ( "Pause adaptation of individual lights and hand over (manual) control to other sources that " @@ -443,6 +461,8 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [ DEFAULT_COLOR_TEMP_PLATEAU_EVENING, int_between(0, MAX_COLOR_TEMP_PLATEAU), ), + (CONF_COLOR_TEMP_FIXED_PLATEAU_END, NONE_STR, str), + (CONF_COLOR_TEMP_FIXED_DESCENT_END, NONE_STR, str), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), ( CONF_TAKE_OVER_CONTROL_MODE, @@ -520,9 +540,54 @@ EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = { CONF_BRIGHTNESS_MODE_TIME_DARK: (cv.time_period, timedelta_as_int), CONF_COLOR_TEMP_PLATEAU_MORNING: (_PLATEAU_PERIOD, timedelta_as_int), CONF_COLOR_TEMP_PLATEAU_EVENING: (_PLATEAU_PERIOD, timedelta_as_int), + CONF_COLOR_TEMP_FIXED_PLATEAU_END: (cv.time, str), + CONF_COLOR_TEMP_FIXED_DESCENT_END: (cv.time, str), } +def validate_fixed_evening(plateau_end: Any, descent_end: Any) -> None: + """Check the pair of fixed evening color temperature times. + + Both are local times of day (strings or `datetime.time`), or unset + (`None` or "None"). Raises `vol.Invalid` for an incoherent pair. + """ + plateau_end, descent_end = ( + None if value in (None, NONE_STR) else cv.time(value) + for value in (plateau_end, descent_end) + ) + if plateau_end is None and descent_end is None: + return + if plateau_end is None or descent_end is None: + msg = ( + f"{CONF_COLOR_TEMP_FIXED_PLATEAU_END} and" + f" {CONF_COLOR_TEMP_FIXED_DESCENT_END} must be set together" + ) + raise vol.Invalid(msg) + if plateau_end < MIN_COLOR_TEMP_FIXED_PLATEAU_END: + msg = ( + f"{CONF_COLOR_TEMP_FIXED_PLATEAU_END} must be" + f" {MIN_COLOR_TEMP_FIXED_PLATEAU_END:%H:%M} or later (it is an evening time)" + ) + raise vol.Invalid(msg) + if plateau_end == descent_end: + msg = ( + f"{CONF_COLOR_TEMP_FIXED_DESCENT_END} must differ from" + f" {CONF_COLOR_TEMP_FIXED_PLATEAU_END}" + ) + raise vol.Invalid(msg) + # A descent end that is not after the plateau end is on the next day. + duration = ( + datetime.combine(date(2000, 1, 1), descent_end) + - datetime.combine(date(2000, 1, 1), plateau_end) + ) % timedelta(days=1) + if duration > MAX_COLOR_TEMP_FIXED_DESCENT: + msg = ( + f"{CONF_COLOR_TEMP_FIXED_DESCENT_END} must be at most" + f" {MAX_COLOR_TEMP_FIXED_DESCENT} after {CONF_COLOR_TEMP_FIXED_PLATEAU_END}" + ) + raise vol.Invalid(msg) + + def maybe_coerce(key: str, validation: Any) -> vol.All | Any: """Coerce the validation into a json serializable type.""" validation, coerce = EXTRA_VALIDATION.get(key, (validation, None)) @@ -541,11 +606,26 @@ _yaml_validation_tuples = [ for key, default, validation in VALIDATION_TUPLES ] + [(CONF_NAME, DEFAULT_NAME, cv.string)] -_DOMAIN_SCHEMA = vol.Schema( - { - vol.Optional(key, default=replace_none_str(default, vol.UNDEFINED)): validation - for key, default, validation in _yaml_validation_tuples - }, + +def _validate_yaml_fixed_evening(config: dict[str, Any]) -> dict[str, Any]: + validate_fixed_evening( + config.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END), + config.get(CONF_COLOR_TEMP_FIXED_DESCENT_END), + ) + return config + + +_DOMAIN_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional( + key, + default=replace_none_str(default, vol.UNDEFINED), + ): validation + for key, default, validation in _yaml_validation_tuples + }, + ), + _validate_yaml_fixed_evening, ) diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index b434b1be..6c786a5e 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -223,6 +223,18 @@ change_switch_settings: min: 0 max: 43200 unit_of_measurement: s + color_temp_fixed_plateau_end: + description: Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙 + example: '' + required: false + selector: + time: null + color_temp_fixed_descent_end: + description: Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙 + example: '' + required: false + selector: + time: null sunset_time: description: Set a fixed time (HH:MM:SS) for sunset. 🌇 example: '' diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 958d85e4..1c8b98ae 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -66,6 +66,8 @@ "brightness_mode_time_light": "brightness_mode_time_light", "color_temp_plateau_morning": "color_temp_plateau_morning", "color_temp_plateau_evening": "color_temp_plateau_evening", + "color_temp_fixed_plateau_end": "color_temp_fixed_plateau_end", + "color_temp_fixed_descent_end": "color_temp_fixed_descent_end", "take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒", "take_over_control_mode": "take_over_control_mode", "detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.", @@ -101,6 +103,8 @@ "brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.", "color_temp_plateau_morning": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️", "color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇", + "color_temp_fixed_plateau_end": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙", + "color_temp_fixed_descent_end": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙", "take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.", "autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", "send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", @@ -264,6 +268,14 @@ "description": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇", "name": "color_temp_plateau_evening" }, + "color_temp_fixed_plateau_end": { + "description": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙", + "name": "color_temp_fixed_plateau_end" + }, + "color_temp_fixed_descent_end": { + "description": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙", + "name": "color_temp_fixed_descent_end" + }, "sunset_time": { "description": "Set a fixed time (HH:MM:SS) for sunset. 🌇", "name": "sunset_time" diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3bfe881f..c8fe8e99 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,8 @@ from .const import ( CONF_BRIGHTNESS_MODE, CONF_BRIGHTNESS_MODE_TIME_DARK, CONF_BRIGHTNESS_MODE_TIME_LIGHT, + CONF_COLOR_TEMP_FIXED_DESCENT_END, + CONF_COLOR_TEMP_FIXED_PLATEAU_END, CONF_COLOR_TEMP_PLATEAU_EVENING, CONF_COLOR_TEMP_PLATEAU_MORNING, CONF_DETECT_NON_HA_CHANGES, @@ -153,6 +156,7 @@ from .const import ( TakeOverControlMode, change_switch_settings_schema, replace_none_str, + validate_fixed_evening, ) from .hass_utils import setup_service_call_interceptor, target_entities from .helpers import ( @@ -396,7 +400,11 @@ async def handle_change_switch_settings( defaults = None # deep copy the defaults so we don't modify the original dicts - switch._set_changeable_settings(data=data, defaults=deepcopy(defaults)) + try: + switch._set_changeable_settings(data=data, defaults=deepcopy(defaults)) + except vol.Invalid as err: + msg = f"adaptive-lighting: Incorrect data provided in service call: {err}" + raise ServiceValidationError(msg) from err if switch.is_on: switch._update_time_interval_listener() @@ -608,6 +616,20 @@ def validate( value = data.get(key) if value is not None: data[key] = validate_value(value) # Fix the types of the inputs + try: + validate_fixed_evening( + data[CONF_COLOR_TEMP_FIXED_PLATEAU_END], + data[CONF_COLOR_TEMP_FIXED_DESCENT_END], + ) + except vol.Invalid: + if config_entry is None: # service call: report it to the caller + raise + _LOGGER.exception( + "%s: ignoring the incoherent fixed evening color temperature times", + data.get(CONF_NAME), + ) + data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = None + data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = None return data @@ -1041,6 +1063,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT], color_temp_plateau_morning=data[CONF_COLOR_TEMP_PLATEAU_MORNING], color_temp_plateau_evening=data[CONF_COLOR_TEMP_PLATEAU_EVENING], + color_temp_fixed_plateau_end=data[CONF_COLOR_TEMP_FIXED_PLATEAU_END], + color_temp_fixed_descent_end=data[CONF_COLOR_TEMP_FIXED_DESCENT_END], timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone), ) _LOGGER.debug( diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index f2b6a416..5ba92405 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -67,6 +67,8 @@ "brightness_mode_time_light": "brightness_mode_time_light", "color_temp_plateau_morning": "color_temp_plateau_morning", "color_temp_plateau_evening": "color_temp_plateau_evening", + "color_temp_fixed_plateau_end": "color_temp_fixed_plateau_end", + "color_temp_fixed_descent_end": "color_temp_fixed_descent_end", "take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒", "take_over_control_mode": "take_over_control_mode", "detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.", @@ -102,6 +104,8 @@ "brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.", "color_temp_plateau_morning": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️", "color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇", + "color_temp_fixed_plateau_end": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙", + "color_temp_fixed_descent_end": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙", "take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.", "autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", "send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", @@ -265,6 +269,14 @@ "description": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇", "name": "color_temp_plateau_evening" }, + "color_temp_fixed_plateau_end": { + "description": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙", + "name": "color_temp_fixed_plateau_end" + }, + "color_temp_fixed_descent_end": { + "description": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙", + "name": "color_temp_fixed_descent_end" + }, "sunset_time": { "description": "Set a fixed time (HH:MM:SS) for sunset. 🌇", "name": "sunset_time" diff --git a/docs/advanced/color-temperature-plateau.md b/docs/advanced/color-temperature-plateau.md index 9c5c1b4a..588e7e60 100644 --- a/docs/advanced/color-temperature-plateau.md +++ b/docs/advanced/color-temperature-plateau.md @@ -34,3 +34,30 @@ adaptive_lighting: ``` The same options can be changed at runtime with `adaptive_lighting.change_switch_settings`. + +## Fixed evening times + +The plateau options above are measured from solar noon, so the evening moves with the seasons. To get the same evening every day of the year, set two optional **local** times instead: + +| Option | Default | Description | +|--------|---------|-------------| +| `color_temp_fixed_plateau_end` | unset | Local time until which the color temperature stays at `max_color_temp` | +| `color_temp_fixed_descent_end` | unset | Local time at which the color temperature has descended to `min_color_temp` | + +```yaml +adaptive_lighting: + - name: kitchen + min_color_temp: 2700 + max_color_temp: 4000 + color_temp_fixed_plateau_end: "21:00:00" # 4000 K until 21:00 + color_temp_fixed_descent_end: "23:00:00" # smooth descent to 2700 K at 23:00 +``` + +Both options are available in the UI (advanced section), in YAML and in `adaptive_lighting.change_switch_settings`. When both are unset, which is the default, nothing changes. + +- Only the color temperature is affected. The brightness, the sleep mode (which keeps priority) and the morning curve are unchanged. `color_temp_plateau_evening` is superseded by these times while they are set. +- The color temperature follows the default curve until solar noon (and stays at `max_color_temp` from then until `color_temp_fixed_plateau_end`), then descends with a smooth cosine easing, then stays at `min_color_temp` until sunset or later. +- Validation: both times must be set together, they must differ, `color_temp_fixed_plateau_end` must be 12:00 or later, and the descent must last at most 12 hours. `color_temp_fixed_descent_end` may be after midnight (for example 23:00 → 01:00). In YAML or the UI an invalid pair is rejected; through the service it raises an error; in a stored configuration it is ignored with an error in the log. +- The times are wall-clock times of the Home Assistant time zone. On a day with a DST change inside the window, the real duration of the descent is one hour shorter or longer, but the curve stays continuous. +- The window is anchored on the last solar noon and cut at the next sunrise: it ends at `color_temp_fixed_descent_end` or at that sunrise, whichever comes first, so the color temperature is never higher than the morning curve at sunrise. If solar noon is later than `color_temp_fixed_plateau_end`, the descent starts at noon; if the window does not fit between noon and the next sunrise, the default curve is used for that day. +- Between sunset and `color_temp_fixed_descent_end` the fixed curve replaces the night value (so winter evenings stay at `max_color_temp` after sunset). After `color_temp_fixed_descent_end` the default night behavior resumes. With `adapt_until_sleep` this means a step from `min_color_temp` to the night gradient at that moment. diff --git a/docs/configuration.md b/docs/configuration.md index d298eaa9..51cd2a64 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,6 +62,8 @@ All configuration options are listed below with their default values. These opti | `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` | | `color_temp_plateau_morning` | The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️ | `0` | `int` 0-43200 | | `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 | +| `color_temp_fixed_plateau_end` | Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year, instead of following the sun. Needs `color_temp_fixed_descent_end`; unset keeps the default curve. Only affects the color temperature. 🌙 | `None` | `str` (HH:MM:SS) | +| `color_temp_fixed_descent_end` | Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight, at most 12 hours later. Needs `color_temp_fixed_plateau_end`; unset keeps the default curve. 🌙 | `None` | `str` (HH:MM:SS) | | `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` | | `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` | | `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` | diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 1603e245..0c767a46 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -11,6 +11,8 @@ except ImportError: from voluptuous_serialize import convert as to_field_list from homeassistant.components.adaptive_lighting.const import ( BASIC_OPTIONS, + CONF_COLOR_TEMP_FIXED_DESCENT_END, + CONF_COLOR_TEMP_FIXED_PLATEAU_END, CONF_COLOR_TEMP_PLATEAU_EVENING, CONF_COLOR_TEMP_PLATEAU_MORNING, CONF_EXPAND_LIGHT_GROUPS, @@ -114,6 +116,8 @@ async def test_options(hass): advanced_data[CONF_COLOR_TEMP_PLATEAU_EVENING] = 10800 advanced_data[CONF_SUNRISE_TIME] = NONE_STR advanced_data[CONF_SUNSET_TIME] = NONE_STR + advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = "21:00:00" + advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = "23:00:00" basic_data = {**BASIC_DATA, "min_brightness": 12} user_input = { **basic_data, @@ -224,6 +228,41 @@ async def test_incorrect_options(hass, lights): ) +@pytest.mark.parametrize( + ("plateau_end", "descent_end"), + [ + ("21:00:00", NONE_STR), # only one of the two + ("21:00:00", "21:00:00"), # no descent + ("23:00:00", "21:00:00"), # more than 12 hours + ("09:00:00", "10:00:00"), # not an evening time + ], +) +async def test_options_flow_rejects_incoherent_fixed_evening( + hass, + plateau_end, + descent_end, +): + """An incoherent pair of fixed evening times is an error in the options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME}, + options={}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) + advanced_data = ADVANCED_DATA.copy() + advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = plateau_end + advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = descent_end + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={**BASIC_DATA, "advanced": advanced_data}, + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "option_error"} + + async def test_import_twice(hass): """Test importing twice.""" data = DEFAULT_DATA.copy() 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_switch.py b/tests/test_switch.py index 68b6a816..aa1a1cde 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -37,6 +37,8 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_BRIGHTNESS_MODE, CONF_BRIGHTNESS_MODE_TIME_DARK, CONF_BRIGHTNESS_MODE_TIME_LIGHT, + CONF_COLOR_TEMP_FIXED_DESCENT_END, + CONF_COLOR_TEMP_FIXED_PLATEAU_END, CONF_COLOR_TEMP_PLATEAU_EVENING, CONF_COLOR_TEMP_PLATEAU_MORNING, CONF_DETECT_NON_HA_CHANGES, @@ -129,7 +131,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, State -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import ServiceValidationError, Unauthorized from homeassistant.helpers import area_registry as ar from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import async_get_platforms @@ -2972,6 +2974,140 @@ def test_color_temp_plateau_yaml_validation(invalid, key): _DOMAIN_SCHEMA({key: invalid}) +async def test_fixed_evening_defaults_and_service(hass): + """The fixed evening times are unset by default and settable at runtime.""" + switch, _ = await setup_lights_and_switch(hass) + settings = switch._sun_light_settings + assert settings.color_temp_fixed_plateau_end is None + assert settings.color_temp_fixed_descent_end is None + + async def change_switch_settings(**kwargs): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {ATTR_ENTITY_ID: switch.entity_id, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + + await change_switch_settings( + **{ + CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00", + CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00", + }, + ) + settings = switch._sun_light_settings + assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0) + assert settings.color_temp_fixed_descent_end == datetime.time(23, 0) + + # unrelated changes keep the times ("current" defaults) + await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 80}) + settings = switch._sun_light_settings + assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0) + + # an incoherent pair is rejected and leaves the settings untouched + for bad in ( + {CONF_COLOR_TEMP_FIXED_DESCENT_END: "21:00:00"}, # no descent + {CONF_COLOR_TEMP_FIXED_PLATEAU_END: "11:00:00"}, # not an evening time + {CONF_COLOR_TEMP_FIXED_PLATEAU_END: "nonsense"}, + ): + with pytest.raises((ServiceValidationError, voluptuous.error.Invalid)): + await change_switch_settings(**bad) + settings = switch._sun_light_settings + assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0) + assert settings.color_temp_fixed_descent_end == datetime.time(23, 0) + + # one time alone is rejected + with pytest.raises(ServiceValidationError): + await change_switch_settings( + **{CONF_COLOR_TEMP_FIXED_DESCENT_END: "None"}, + ) + + # both can be cleared together + await change_switch_settings( + **{ + CONF_COLOR_TEMP_FIXED_PLATEAU_END: "None", + CONF_COLOR_TEMP_FIXED_DESCENT_END: "None", + }, + ) + settings = switch._sun_light_settings + assert settings.color_temp_fixed_plateau_end is None + assert settings.color_temp_fixed_descent_end is None + + await change_switch_settings( + **{ + CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00", + CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00", + }, + ) + await change_switch_settings(**{CONF_USE_DEFAULTS: "factory"}) + assert switch._sun_light_settings.color_temp_fixed_plateau_end is None + assert switch._sun_light_settings.color_temp_fixed_descent_end is None + + +async def test_fixed_evening_from_configuration(hass): + """The options from the config entry reach the sun light settings.""" + _, switch = await setup_switch( + hass, + { + CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00", + CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00", + }, + ) + settings = switch._sun_light_settings + assert settings.color_temp_fixed_plateau_end == datetime.time(21, 0) + assert settings.color_temp_fixed_descent_end == datetime.time(23, 0) + # the brightness is not influenced by the evening times + default = switch._sun_light_settings.__class__( + **{ + **{ + f.name: getattr(settings, f.name) + for f in settings.__dataclass_fields__.values() + }, + "color_temp_fixed_plateau_end": None, + "color_temp_fixed_descent_end": None, + }, + ) + now = dt_util.utcnow() + assert ( + settings.brightness_and_color(now, False)["brightness_pct"] + == default.brightness_and_color(now, False)["brightness_pct"] + ) + + +async def test_fixed_evening_incoherent_stored_configuration_is_ignored( + hass, + caplog, +): + """A hand edited stored pair does not break the setup: it is ignored and logged.""" + _, switch = await setup_switch( + hass, + { + CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00", + CONF_COLOR_TEMP_FIXED_DESCENT_END: "21:00:00", + }, + ) + settings = switch._sun_light_settings + assert settings.color_temp_fixed_plateau_end is None + assert settings.color_temp_fixed_descent_end is None + assert "ignoring the incoherent fixed evening color temperature" in caplog.text + + +@pytest.mark.parametrize( + ("plateau_end", "descent_end"), + [("21:00:00", None), ("21:00:00", "21:00:00"), ("08:00:00", "10:00:00")], +) +def test_fixed_evening_yaml_validation(plateau_end, descent_end): + """Incoherent pairs are rejected by the YAML schema.""" + from homeassistant.components.adaptive_lighting.const import _DOMAIN_SCHEMA + + config = {CONF_COLOR_TEMP_FIXED_PLATEAU_END: plateau_end} + if descent_end is not None: + config[CONF_COLOR_TEMP_FIXED_DESCENT_END] = descent_end + with pytest.raises(voluptuous.error.Invalid): + _DOMAIN_SCHEMA(config) + + @pytest.mark.parametrize("target", ["entity", "area", "device", "all"]) async def test_change_switch_settings_entity_targets(hass, device_registry, target): """Test settings changes through Home Assistant entity targets."""