mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-28 22:27:06 +01:00
feat: add fixed evening color temperature schedule
This commit is contained in:
parent
919b4c7975
commit
90d9bbc201
13 changed files with 957 additions and 8 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ except ImportError:
|
|||
from voluptuous_serialize import convert as to_field_list
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
BASIC_OPTIONS,
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
|
||||
CONF_COLOR_TEMP_PLATEAU_EVENING,
|
||||
CONF_COLOR_TEMP_PLATEAU_MORNING,
|
||||
CONF_EXPAND_LIGHT_GROUPS,
|
||||
|
|
@ -114,6 +116,8 @@ async def test_options(hass):
|
|||
advanced_data[CONF_COLOR_TEMP_PLATEAU_EVENING] = 10800
|
||||
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
|
||||
advanced_data[CONF_SUNSET_TIME] = NONE_STR
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = "21:00:00"
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = "23:00:00"
|
||||
basic_data = {**BASIC_DATA, "min_brightness": 12}
|
||||
user_input = {
|
||||
**basic_data,
|
||||
|
|
@ -224,6 +228,41 @@ async def test_incorrect_options(hass, lights):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plateau_end", "descent_end"),
|
||||
[
|
||||
("21:00:00", NONE_STR), # only one of the two
|
||||
("21:00:00", "21:00:00"), # no descent
|
||||
("23:00:00", "21:00:00"), # more than 12 hours
|
||||
("09:00:00", "10:00:00"), # not an evening time
|
||||
],
|
||||
)
|
||||
async def test_options_flow_rejects_incoherent_fixed_evening(
|
||||
hass,
|
||||
plateau_end,
|
||||
descent_end,
|
||||
):
|
||||
"""An incoherent pair of fixed evening times is an error in the options flow."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=DEFAULT_NAME,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
options={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
advanced_data = ADVANCED_DATA.copy()
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = plateau_end
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = descent_end
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={**BASIC_DATA, "advanced": advanced_data},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "option_error"}
|
||||
|
||||
|
||||
async def test_import_twice(hass):
|
||||
"""Test importing twice."""
|
||||
data = DEFAULT_DATA.copy()
|
||||
|
|
|
|||
518
tests/test_fixed_evening.py
Normal file
518
tests/test_fixed_evening.py
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
"""Tests for the fixed local-time evening color temperature."""
|
||||
|
||||
import datetime as dt
|
||||
import zoneinfo
|
||||
from itertools import pairwise
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
from astral import LocationInfo
|
||||
from astral.location import Location
|
||||
from homeassistant.components.adaptive_lighting.color_and_brightness import (
|
||||
SunLightSettings,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
_DOMAIN_SCHEMA,
|
||||
validate_fixed_evening,
|
||||
)
|
||||
|
||||
H = dt.timedelta(hours=1)
|
||||
PARIS = zoneinfo.ZoneInfo("Europe/Paris")
|
||||
PARIS_LOCATION = Location(
|
||||
LocationInfo(
|
||||
name="Paris",
|
||||
region="France",
|
||||
timezone="Europe/Paris",
|
||||
latitude=48.8566,
|
||||
longitude=2.3522,
|
||||
),
|
||||
)
|
||||
TROMSO_LOCATION = Location(
|
||||
LocationInfo(
|
||||
name="Tromso",
|
||||
region="Norway",
|
||||
timezone="Europe/Oslo",
|
||||
latitude=69.6492,
|
||||
longitude=18.9553,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def make_settings(
|
||||
plateau_end=None,
|
||||
descent_end=None,
|
||||
*,
|
||||
location=PARIS_LOCATION,
|
||||
timezone=PARIS,
|
||||
fixed_times=False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Astral based settings, or with fixed sunrise 06:00 / sunset 18:00 (local)."""
|
||||
params = {
|
||||
"name": "kitchen",
|
||||
"astral_observer": location.observer,
|
||||
"adapt_until_sleep": False,
|
||||
"max_brightness": 100,
|
||||
"max_color_temp": 4000,
|
||||
"min_brightness": 30,
|
||||
"min_color_temp": 2700,
|
||||
"sleep_brightness": 1,
|
||||
"sleep_rgb_or_color_temp": "color_temp",
|
||||
"sleep_color_temp": 1000,
|
||||
"sleep_rgb_color": (255, 56, 0),
|
||||
"sunrise_time": dt.time(6, 0) if fixed_times else None,
|
||||
"min_sunrise_time": None,
|
||||
"max_sunrise_time": None,
|
||||
"sunset_time": dt.time(18, 0) if fixed_times else None,
|
||||
"min_sunset_time": None,
|
||||
"max_sunset_time": None,
|
||||
"brightness_mode_time_dark": dt.timedelta(minutes=30),
|
||||
"brightness_mode_time_light": dt.timedelta(minutes=30),
|
||||
"timezone": timezone,
|
||||
"color_temp_fixed_plateau_end": plateau_end,
|
||||
"color_temp_fixed_descent_end": descent_end,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return SunLightSettings(**params)
|
||||
|
||||
|
||||
def kitchen(**kwargs):
|
||||
return make_settings(dt.time(21, 0), dt.time(23, 0), **kwargs)
|
||||
|
||||
|
||||
def local(day, hour, minute=0, *, tz=PARIS):
|
||||
return dt.datetime.combine(day, dt.time(hour, minute), tz)
|
||||
|
||||
|
||||
def kelvin(settings, when, is_sleep=False):
|
||||
return settings.brightness_and_color(when, is_sleep)["color_temp_kelvin"]
|
||||
|
||||
|
||||
def grid(day, step_minutes=5, days=1, tz=PARIS):
|
||||
start = dt.datetime.combine(day, dt.time(0), tz)
|
||||
return [
|
||||
start + dt.timedelta(minutes=m) for m in range(0, days * 24 * 60, step_minutes)
|
||||
]
|
||||
|
||||
|
||||
# One day per season, plus the days around the DST changes and the solstices.
|
||||
SEASON_DAYS = [
|
||||
dt.date(2026, 1, 15),
|
||||
dt.date(2026, 3, 20),
|
||||
dt.date(2026, 6, 21),
|
||||
dt.date(2026, 9, 22),
|
||||
dt.date(2026, 12, 21),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
@pytest.mark.parametrize("adapt_until_sleep", [True, False])
|
||||
@pytest.mark.parametrize("rgb", ["color_temp", "rgb_color"])
|
||||
def test_unset_is_identical_to_before(day, adapt_until_sleep, rgb):
|
||||
"""No option set: the result is bit-for-bit the same as without the fields."""
|
||||
kwargs = {"adapt_until_sleep": adapt_until_sleep, "sleep_rgb_or_color_temp": rgb}
|
||||
default = make_settings(**kwargs)
|
||||
explicit_none = make_settings(None, None, **kwargs)
|
||||
for when in grid(day, 7):
|
||||
for is_sleep in (False, True):
|
||||
assert default.brightness_and_color(
|
||||
when,
|
||||
is_sleep,
|
||||
) == explicit_none.brightness_and_color(when, is_sleep)
|
||||
assert default.sun.sun_position(when) == explicit_none.sun.sun_position(when)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_kitchen_curve_all_year(day):
|
||||
"""4000 K until 21:00, smooth descent, 2700 K from 23:00 whatever the season."""
|
||||
settings = kitchen()
|
||||
assert kelvin(settings, local(day, 21, 0)) == 4000
|
||||
assert kelvin(settings, local(day, 20, 59)) == 4000
|
||||
assert kelvin(settings, local(day, 21, 0)) == 4000
|
||||
# cosine easing: exactly halfway at 22:00
|
||||
assert kelvin(settings, local(day, 22, 0)) == 3350
|
||||
assert 3350 < kelvin(settings, local(day, 21, 30)) < 4000
|
||||
assert 2700 < kelvin(settings, local(day, 22, 30)) < 3350
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700
|
||||
assert kelvin(settings, local(day, 23, 30)) == 2700
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_morning_and_brightness_are_unchanged(day):
|
||||
default = make_settings()
|
||||
settings = kitchen()
|
||||
for when in grid(day, 5, days=2):
|
||||
# brightness is never touched, sleep mode keeps priority
|
||||
assert settings.brightness_pct(when, False) == default.brightness_pct(
|
||||
when,
|
||||
False,
|
||||
)
|
||||
assert settings.brightness_and_color(
|
||||
when,
|
||||
True,
|
||||
) == default.brightness_and_color(
|
||||
when,
|
||||
True,
|
||||
)
|
||||
brightness = settings.brightness_and_color(when, False)
|
||||
reference = default.brightness_and_color(when, False)
|
||||
assert brightness["brightness_pct"] == reference["brightness_pct"]
|
||||
assert brightness["sun_position"] == reference["sun_position"]
|
||||
# morning: from the end of the previous descent to solar noon
|
||||
for when in grid(day, 5):
|
||||
noon, _ = settings.sun.noon_and_midnight(when)
|
||||
if when.hour < 12 and when < noon:
|
||||
assert kelvin(settings, when) == kelvin(default, when)
|
||||
|
||||
|
||||
def test_unrelated_brightness_modes_are_unchanged():
|
||||
day = dt.date(2026, 1, 15)
|
||||
for mode in ("default", "linear", "tanh"):
|
||||
default = make_settings(brightness_mode=mode)
|
||||
settings = kitchen(brightness_mode=mode)
|
||||
for when in grid(day, 11):
|
||||
assert settings.brightness_pct(when, False) == default.brightness_pct(
|
||||
when,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_monotonic_and_continuous_from_noon_to_the_morning(day):
|
||||
settings = kitchen()
|
||||
noon, _ = settings.sun.noon_and_midnight(local(day, 12))
|
||||
sunrise = settings.sun.sunrise(day + dt.timedelta(days=1))
|
||||
values = []
|
||||
when = noon
|
||||
while when <= sunrise:
|
||||
values.append(settings.brightness_and_color(when, False)["color_temp_kelvin"])
|
||||
when += dt.timedelta(minutes=1)
|
||||
assert values[0] == 4000
|
||||
assert values[-1] == 2700
|
||||
assert all(a >= b for a, b in pairwise(values))
|
||||
# cosine slope is at most pi/2 * 1300 K / 120 min = 17 K/min (rounded to 5 K)
|
||||
assert max(a - b for a, b in pairwise(values)) <= 20
|
||||
|
||||
|
||||
def test_winter_evening_stays_at_max_after_sunset():
|
||||
settings = kitchen()
|
||||
day = dt.date(2026, 12, 21)
|
||||
sunset = settings.sun.sunset(day)
|
||||
assert sunset.astimezone(PARIS).hour < 18
|
||||
assert kelvin(make_settings(), sunset + 2 * H) == 2700 # legacy
|
||||
assert kelvin(settings, sunset + 2 * H) == 4000
|
||||
assert kelvin(settings, local(day, 20, 30)) == 4000
|
||||
|
||||
|
||||
def test_summer_sun_still_up_at_the_plateau_end():
|
||||
settings = kitchen()
|
||||
day = dt.date(2026, 6, 21)
|
||||
sunset = settings.sun.sunset(day).astimezone(PARIS)
|
||||
assert sunset.hour == 21
|
||||
# the default curve is already near the minimum at sunset
|
||||
assert kelvin(make_settings(), local(day, 21, 0)) < 3000
|
||||
assert kelvin(settings, local(day, 21, 0)) == 4000
|
||||
assert kelvin(settings, local(day, 22, 0)) == 3350
|
||||
|
||||
|
||||
def test_descent_ending_before_sunset_does_not_jump_back_up():
|
||||
# sunset 18:00, plateau until 14:00 and descent to 15:00: stays at 2700 until sunset
|
||||
settings = make_settings(dt.time(14, 0), dt.time(15, 0), fixed_times=True)
|
||||
day = dt.date(2026, 6, 1)
|
||||
assert kelvin(settings, local(day, 14, 0)) == 4000
|
||||
assert kelvin(settings, local(day, 15, 0)) == 2700
|
||||
assert kelvin(settings, local(day, 16, 30)) == 2700
|
||||
assert kelvin(settings, local(day, 17, 59)) == 2700
|
||||
assert kelvin(settings, local(day, 18, 30)) == 2700
|
||||
|
||||
|
||||
def test_before_the_plateau_end_it_is_the_regular_afternoon_value_at_noon():
|
||||
settings = make_settings(dt.time(18, 0), dt.time(20, 0), fixed_times=True)
|
||||
day = dt.date(2026, 6, 1)
|
||||
noon, _ = settings.sun.noon_and_midnight(local(day, 12))
|
||||
assert kelvin(settings, noon) == 4000
|
||||
assert kelvin(settings, noon + 3 * H) == 4000 # default curve would be lower
|
||||
assert kelvin(make_settings(fixed_times=True), noon + 3 * H) < 4000
|
||||
|
||||
|
||||
# --- midnight ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_descent_across_midnight(day):
|
||||
settings = make_settings(dt.time(23, 0), dt.time(1, 0))
|
||||
assert kelvin(settings, local(day, 22, 59)) == 4000
|
||||
assert kelvin(settings, local(day, 23, 0)) == 4000
|
||||
next_day = day + dt.timedelta(days=1)
|
||||
assert kelvin(settings, local(day, 23, 30)) > 3350
|
||||
assert kelvin(settings, local(next_day, 0, 0)) == 3350
|
||||
assert kelvin(settings, local(next_day, 0, 30)) < 3350
|
||||
assert kelvin(settings, local(next_day, 1, 0)) == 2700
|
||||
assert kelvin(settings, local(next_day, 3, 0)) == 2700
|
||||
# the day after, the same thing happens again at the same wall-clock times
|
||||
assert kelvin(settings, local(next_day, 23, 0)) == 4000
|
||||
|
||||
|
||||
def test_pre_midnight_times_do_not_leak_into_the_morning():
|
||||
settings = kitchen()
|
||||
default = make_settings()
|
||||
day = dt.date(2026, 3, 20)
|
||||
for hour in range(12):
|
||||
when = local(day, hour, 30)
|
||||
noon, _ = settings.sun.noon_and_midnight(when)
|
||||
if when < noon:
|
||||
assert kelvin(settings, when) == kelvin(default, when)
|
||||
|
||||
|
||||
def test_descent_end_clamped_at_the_next_sunrise():
|
||||
# plateau 20:00, descent end 08:00 (12h) but sunrise is 06:00
|
||||
settings = make_settings(dt.time(20, 0), dt.time(8, 0), fixed_times=True)
|
||||
day = dt.date(2026, 6, 1)
|
||||
nxt = day + dt.timedelta(days=1)
|
||||
assert kelvin(settings, local(day, 20, 0)) == 4000
|
||||
assert kelvin(settings, local(nxt, 6, 0)) == 2700
|
||||
assert kelvin(settings, local(nxt, 5, 59)) <= 2705
|
||||
assert kelvin(settings, local(nxt, 6, 30)) == kelvin(
|
||||
make_settings(fixed_times=True),
|
||||
local(nxt, 6, 30),
|
||||
)
|
||||
|
||||
|
||||
def test_window_that_does_not_fit_before_the_next_sunrise_is_ignored():
|
||||
# plateau 12:00-12:30 but noon is 15:00: nothing to do
|
||||
settings = make_settings(
|
||||
dt.time(12, 0),
|
||||
dt.time(12, 30),
|
||||
fixed_times=True,
|
||||
sunrise_time=dt.time(8, 0),
|
||||
sunset_time=dt.time(22, 0),
|
||||
)
|
||||
default = make_settings(
|
||||
fixed_times=True,
|
||||
sunrise_time=dt.time(8, 0),
|
||||
sunset_time=dt.time(22, 0),
|
||||
)
|
||||
for when in grid(dt.date(2026, 6, 1), 10):
|
||||
assert settings.brightness_and_color(
|
||||
when,
|
||||
False,
|
||||
) == default.brightness_and_color(
|
||||
when,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_plateau_end_before_solar_noon_starts_the_descent_at_noon():
|
||||
# noon is 15:00, plateau_end 12:00, descent end 17:00: 2h descent from noon
|
||||
settings = make_settings(
|
||||
dt.time(12, 0),
|
||||
dt.time(17, 0),
|
||||
fixed_times=True,
|
||||
sunrise_time=dt.time(8, 0),
|
||||
sunset_time=dt.time(22, 0),
|
||||
)
|
||||
day = dt.date(2026, 6, 1)
|
||||
assert kelvin(settings, local(day, 15, 0)) == 4000
|
||||
assert kelvin(settings, local(day, 16, 0)) == 3350
|
||||
assert kelvin(settings, local(day, 17, 0)) == 2700
|
||||
|
||||
|
||||
# --- DST and time zones --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"day",
|
||||
[
|
||||
dt.date(2026, 3, 28), # night to the spring-forward day
|
||||
dt.date(2026, 3, 29),
|
||||
dt.date(2026, 10, 24), # night to the fall-back day
|
||||
dt.date(2026, 10, 25),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("plateau_end", "descent_end"),
|
||||
[
|
||||
(dt.time(21, 0), dt.time(23, 0)),
|
||||
(dt.time(23, 0), dt.time(3, 30)), # contains the 02:00-03:00 DST change
|
||||
(dt.time(23, 0), dt.time(2, 30)), # 02:30 does not exist / exists twice
|
||||
],
|
||||
)
|
||||
def test_dst_changes_stay_continuous_and_monotonic(day, plateau_end, descent_end):
|
||||
settings = make_settings(plateau_end, descent_end)
|
||||
start = dt.datetime.combine(day, dt.time(12), PARIS)
|
||||
noon, _ = settings.sun.noon_and_midnight(start)
|
||||
end = settings.sun.sunrise(day + dt.timedelta(days=1))
|
||||
values = []
|
||||
when = noon
|
||||
while when <= end:
|
||||
values.append(kelvin(settings, when))
|
||||
when += dt.timedelta(minutes=1)
|
||||
assert values[0] == 4000
|
||||
assert values[-1] == 2700
|
||||
assert all(a >= b for a, b in pairwise(values))
|
||||
assert max(a - b for a, b in pairwise(values)) <= 25
|
||||
if descent_end == dt.time(23, 0):
|
||||
assert kelvin(settings, local(day, 22, 0)) == 3350
|
||||
|
||||
|
||||
def test_dst_day_wall_clock_times_hold():
|
||||
# on both DST days the evening is still 4000 K at 20:59 and 2700 K at 23:00
|
||||
for day in (dt.date(2026, 3, 29), dt.date(2026, 10, 25)):
|
||||
settings = kitchen()
|
||||
assert kelvin(settings, local(day, 20, 59)) == 4000
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700
|
||||
|
||||
|
||||
def test_times_are_interpreted_in_the_configured_time_zone():
|
||||
day = dt.date(2026, 3, 20)
|
||||
paris = kitchen()
|
||||
utc = make_settings(
|
||||
dt.time(21, 0),
|
||||
dt.time(23, 0),
|
||||
timezone=zoneinfo.ZoneInfo("UTC"),
|
||||
)
|
||||
# 21:00 in Paris (UTC+1 in March) is 20:00 UTC: Paris is already descending
|
||||
# one hour before UTC is
|
||||
moment = dt.datetime(2026, 3, 20, 21, 30, tzinfo=dt.UTC)
|
||||
assert kelvin(utc, moment) > kelvin(paris, moment)
|
||||
assert kelvin(paris, dt.datetime(2026, 3, 20, 20, 0, tzinfo=dt.UTC)) == 4000
|
||||
assert kelvin(paris, dt.datetime(2026, 3, 20, 22, 0, tzinfo=dt.UTC)) == 2700
|
||||
assert kelvin(paris, local(day, 22, 0)) == 3350
|
||||
# the same instant gives the same answer whatever tz the datetime carries
|
||||
instant = local(day, 22, 0)
|
||||
assert kelvin(paris, instant) == kelvin(paris, instant.astimezone(dt.UTC))
|
||||
assert kelvin(paris, instant) == kelvin(
|
||||
paris,
|
||||
instant.astimezone(zoneinfo.ZoneInfo("Asia/Tokyo")),
|
||||
)
|
||||
|
||||
|
||||
# --- sleep mode, rgb, adapt_until_sleep, polar -------------------------------
|
||||
|
||||
|
||||
def test_sleep_mode_keeps_priority():
|
||||
day = dt.date(2026, 12, 21)
|
||||
default = make_settings()
|
||||
settings = kitchen()
|
||||
for hour in (9, 14, 19, 22, 23):
|
||||
when = local(day, hour)
|
||||
assert settings.brightness_and_color(
|
||||
when,
|
||||
True,
|
||||
) == default.brightness_and_color(
|
||||
when,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_rgb_sleep_color_after_sunset_does_not_override_the_fixed_evening():
|
||||
day = dt.date(2026, 12, 21)
|
||||
settings = kitchen(adapt_until_sleep=True, sleep_rgb_or_color_temp="rgb_color")
|
||||
default = make_settings(adapt_until_sleep=True, sleep_rgb_or_color_temp="rgb_color")
|
||||
when = local(day, 19, 0) # after sunset, still in the plateau
|
||||
result = settings.brightness_and_color(when, False)
|
||||
assert result["color_temp_kelvin"] == 4000
|
||||
assert result["force_rgb_color"] is False
|
||||
assert default.brightness_and_color(when, False)["force_rgb_color"] is True
|
||||
# after the descent the night behavior is the legacy one
|
||||
late = local(day, 23, 30)
|
||||
assert settings.brightness_and_color(late, False) == default.brightness_and_color(
|
||||
late,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_adapt_until_sleep_night_resumes_after_the_descent():
|
||||
day = dt.date(2026, 12, 21)
|
||||
settings = kitchen(adapt_until_sleep=True)
|
||||
default = make_settings(adapt_until_sleep=True)
|
||||
# once the descent is over, the night is the regular one again
|
||||
for hour, minute in [(23, 30), (2, 0), (5, 0)]:
|
||||
when = local(day if hour > 12 else day + dt.timedelta(days=1), hour, minute)
|
||||
assert kelvin(settings, when) == kelvin(default, when)
|
||||
# ... with a step from 2700 K at the end of the descent (documented)
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700
|
||||
assert kelvin(default, local(day, 23, 0)) < 2700
|
||||
# in the plateau/descent the fixed curve is used, not the night gradient
|
||||
assert kelvin(settings, local(day, 20, 0)) == 4000
|
||||
assert kelvin(settings, local(day, 22, 0)) == 3350
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", [dt.date(2026, 6, 21), dt.date(2026, 12, 21)])
|
||||
def test_polar_days(day):
|
||||
settings = make_settings(
|
||||
dt.time(21, 0),
|
||||
dt.time(23, 0),
|
||||
location=TROMSO_LOCATION,
|
||||
timezone=zoneinfo.ZoneInfo("Europe/Oslo"),
|
||||
)
|
||||
tz = zoneinfo.ZoneInfo("Europe/Oslo")
|
||||
for when in grid(day, 10, days=2, tz=tz):
|
||||
assert 2700 <= kelvin(settings, when) <= 4000
|
||||
|
||||
|
||||
# --- validation ----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plateau_end", "descent_end"),
|
||||
[
|
||||
(None, None),
|
||||
("None", "None"),
|
||||
("21:00:00", "23:00:00"),
|
||||
("21:00", "23:00"),
|
||||
(dt.time(21, 0), dt.time(23, 0)),
|
||||
("12:00:00", "13:00:00"),
|
||||
("12:00:00", "00:00:00"), # exactly 12 hours
|
||||
("23:00:00", "01:00:00"), # across midnight
|
||||
("23:59:59", "00:00:00"),
|
||||
("21:00:00", "21:00:01"),
|
||||
],
|
||||
)
|
||||
def test_valid_pairs(plateau_end, descent_end):
|
||||
validate_fixed_evening(plateau_end, descent_end)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plateau_end", "descent_end"),
|
||||
[
|
||||
("21:00:00", None), # only one
|
||||
(None, "23:00:00"),
|
||||
("21:00:00", "None"),
|
||||
("21:00:00", "21:00:00"), # no descent
|
||||
("23:00:00", "21:00:00"), # 22 hours, i.e. the descent would be inverted
|
||||
("21:00:00", "20:59:59"),
|
||||
("11:59:59", "13:00:00"), # plateau end in the morning
|
||||
("08:00:00", "10:00:00"),
|
||||
("12:00:00", "00:00:01"), # more than 12 hours
|
||||
("nonsense", "23:00:00"),
|
||||
("25:00:00", "23:00:00"),
|
||||
],
|
||||
)
|
||||
def test_invalid_pairs(plateau_end, descent_end):
|
||||
with pytest.raises(vol.Invalid):
|
||||
validate_fixed_evening(plateau_end, descent_end)
|
||||
|
||||
|
||||
def test_yaml_schema():
|
||||
schema = _DOMAIN_SCHEMA
|
||||
config = schema({})
|
||||
assert "color_temp_fixed_plateau_end" not in config
|
||||
assert "color_temp_fixed_descent_end" not in config
|
||||
config = schema(
|
||||
{
|
||||
"color_temp_fixed_plateau_end": "21:00:00",
|
||||
"color_temp_fixed_descent_end": "23:00:00",
|
||||
},
|
||||
)
|
||||
assert config["color_temp_fixed_plateau_end"] == "21:00:00"
|
||||
assert config["color_temp_fixed_descent_end"] == "23:00:00"
|
||||
with pytest.raises(vol.Invalid):
|
||||
schema({"color_temp_fixed_plateau_end": "21:00:00"})
|
||||
with pytest.raises(vol.Invalid):
|
||||
schema(
|
||||
{
|
||||
"color_temp_fixed_plateau_end": "22:00:00",
|
||||
"color_temp_fixed_descent_end": "22:00:00",
|
||||
},
|
||||
)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue