mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-11-02 00:17:46 +01:00
feat: add custom evening plateau color temperature
Add the optional color_temp_fixed_plateau_kelvin setting (between min_color_temp and max_color_temp) for the fixed evening schedule. The daytime maximum is kept; after it the color temperature descends along the regular afternoon curve, reaches the target exactly and holds it for the last hour before color_temp_fixed_plateau_end, whatever the season, then eases to min_color_temp as before. The curve is sped up when the regular one would be too late (summer), without any step. Unset keeps the previous behavior. Validated in the options flow, YAML, the stored configuration and the change_switch_settings service, with services, translations and docs.
This commit is contained in:
parent
90d9bbc201
commit
238d189f40
13 changed files with 809 additions and 6 deletions
|
|
@ -160,6 +160,7 @@ The YAML and frontend configuration methods support all of the options listed be
|
|||
| `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 |
|
||||
| `color_temp_fixed_plateau_end` | Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year, instead of following the sun. Needs `color_temp_fixed_descent_end`; unset keeps the default curve. Only affects the color temperature. 🌙 | `None` | `str` (HH:MM:SS) |
|
||||
| `color_temp_fixed_descent_end` | Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight, at most 12 hours later. Needs `color_temp_fixed_plateau_end`; unset keeps the default curve. 🌙 | `None` | `str` (HH:MM:SS) |
|
||||
| `color_temp_fixed_plateau_kelvin` | Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙 | `None` | `str` |
|
||||
| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` |
|
||||
| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` |
|
||||
| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` |
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ _POLAR_SUN_EVENT_EPSILON = timedelta(seconds=1)
|
|||
# The color temperature plateau is limited to this fraction of the time between
|
||||
# solar noon and sunrise/sunset, such that the ramp never collapses.
|
||||
_PLATEAU_MAX_FRACTION = 0.9
|
||||
# A custom evening plateau temperature is held at least this long (on a long
|
||||
# enough afternoon) before the end of the fixed evening plateau.
|
||||
_FIXED_PLATEAU_MIN_HOLD = timedelta(hours=1)
|
||||
# ... and it is ignored (the maximum is held instead) on a day where the plateau
|
||||
# end is less than this after solar noon, as there is no room to ramp down.
|
||||
_FIXED_PLATEAU_MIN_AFTERNOON = timedelta(hours=1)
|
||||
|
||||
utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC)
|
||||
utcnow.__doc__ = "Get now in UTC time."
|
||||
|
|
@ -252,6 +258,8 @@ class SunEvents:
|
|||
dt: datetime.datetime,
|
||||
plateau_end: datetime.time,
|
||||
descent_end: datetime.time,
|
||||
plateau_level: float = 1.0,
|
||||
plateau_evening: datetime.timedelta = datetime.timedelta(),
|
||||
) -> float | None:
|
||||
"""Position in [0, 1] for the fixed evening color temperature, or None.
|
||||
|
||||
|
|
@ -262,6 +270,17 @@ class SunEvents:
|
|||
following it, and stays 0 until sunset if that is later. After that
|
||||
(or before the noon) None is returned and the regular curve applies.
|
||||
|
||||
With a `plateau_level` below 1 (the position of a custom evening
|
||||
temperature, needing `plateau_end` at least 1 hour after solar noon,
|
||||
else it is ignored for that day) the position is
|
||||
`plateau_level` for the last `_FIXED_PLATEAU_MIN_HOLD` before
|
||||
`plateau_end` (less on a short afternoon) instead of 1. It reaches it
|
||||
along the regular afternoon curve (which holds 1 for `plateau_evening`
|
||||
after noon) as soon as that does, otherwise along the same curve sped
|
||||
up so that it arrives in time. Hence the level is reached exactly and
|
||||
held in every season, with no step, and the descent after `plateau_end`
|
||||
starts from the level. Before the ramp nothing is capped.
|
||||
|
||||
The window is cut at the next sunrise, so the position reaches 0 no
|
||||
later than the morning curve starts at 0, and it is ignored for a day
|
||||
where it does not fit between noon and the next sunrise (None). The
|
||||
|
|
@ -298,15 +317,76 @@ class SunEvents:
|
|||
end = min(descent_ts, sunrise_ts)
|
||||
if end <= start:
|
||||
return None
|
||||
# The custom level needs a long enough afternoon to ramp down to it
|
||||
use_level = (
|
||||
plateau_level < 1
|
||||
and start - noon_ts >= _FIXED_PLATEAU_MIN_AFTERNOON.total_seconds()
|
||||
)
|
||||
if target_ts <= start:
|
||||
return 1.0
|
||||
return (
|
||||
self._afternoon_to_level(
|
||||
target_ts,
|
||||
noon_ts,
|
||||
sunset_ts,
|
||||
start,
|
||||
plateau_level,
|
||||
plateau_evening,
|
||||
)
|
||||
if use_level
|
||||
else 1.0
|
||||
)
|
||||
level = plateau_level if use_level else 1.0
|
||||
if target_ts <= end:
|
||||
return 0.5 * (1 + math.cos(math.pi * (target_ts - start) / (end - start)))
|
||||
return (
|
||||
level
|
||||
* 0.5
|
||||
* (1 + math.cos(math.pi * (target_ts - start) / (end - start)))
|
||||
)
|
||||
# Past the descent: hold the minimum until sunset, where the regular
|
||||
# curve (which is 0 at sunset) continues, so a descent finishing
|
||||
# before sunset does not jump back up.
|
||||
return 0.0 if target_ts <= sunset_ts else None
|
||||
|
||||
@staticmethod
|
||||
def _afternoon_to_level(
|
||||
target_ts: float,
|
||||
noon_ts: float,
|
||||
sunset_ts: float,
|
||||
start: float,
|
||||
level: float,
|
||||
plateau_evening: datetime.timedelta,
|
||||
) -> float:
|
||||
"""Position from solar noon to `start`, holding `level` before `start`.
|
||||
|
||||
Follows the regular afternoon parabola (1 for the plateau after noon, then
|
||||
falling to 0 at sunset) until it reaches `level`; when that would be too
|
||||
late to hold `level` for `_FIXED_PLATEAU_MIN_HOLD` before `start`, the
|
||||
same curve is traversed faster (and, if its own plateau is too long,
|
||||
starting later within the afternoon) so that it reaches `level` in time.
|
||||
"""
|
||||
half_day = sunset_ts - noon_ts
|
||||
hold = min(plateau_evening.total_seconds(), _PLATEAU_MAX_FRACTION * half_day)
|
||||
ramp = half_day - hold
|
||||
# time at which the regular curve gets to `level`
|
||||
natural_end = noon_ts + hold + ramp * math.sqrt(1 - level)
|
||||
ramp_end = start - min(
|
||||
_FIXED_PLATEAU_MIN_HOLD.total_seconds(),
|
||||
(start - noon_ts) / 2,
|
||||
)
|
||||
ramp_start = noon_ts + hold
|
||||
if natural_end > ramp_end: # too late: compress the curve into the window
|
||||
ramp_end = max(ramp_end, noon_ts)
|
||||
ramp_start = min(ramp_start, (noon_ts + ramp_end) / 2)
|
||||
else:
|
||||
ramp_end = natural_end
|
||||
if target_ts <= ramp_start:
|
||||
return 1.0
|
||||
if target_ts >= ramp_end:
|
||||
return level
|
||||
progress = (target_ts - ramp_start) / (ramp_end - ramp_start)
|
||||
regular_ts = noon_ts + hold + progress * (natural_end - noon_ts - hold)
|
||||
return 1 - ((regular_ts - noon_ts - hold) / ramp) ** 2
|
||||
|
||||
def sun_position(
|
||||
self,
|
||||
dt: datetime.datetime,
|
||||
|
|
@ -396,6 +476,7 @@ class SunLightSettings:
|
|||
color_temp_plateau_evening: datetime.timedelta = datetime.timedelta()
|
||||
color_temp_fixed_plateau_end: datetime.time | None = None
|
||||
color_temp_fixed_descent_end: datetime.time | None = None
|
||||
color_temp_fixed_plateau_kelvin: int | None = None
|
||||
sunrise_offset: datetime.timedelta = datetime.timedelta()
|
||||
sunset_offset: datetime.timedelta = datetime.timedelta()
|
||||
timezone: datetime.tzinfo = UTC
|
||||
|
|
@ -494,6 +575,14 @@ class SunLightSettings:
|
|||
return self._brightness_pct_tanh(dt)
|
||||
return None
|
||||
|
||||
def _fixed_plateau_level(self) -> float:
|
||||
"""Position in [0, 1] of the custom evening temperature (1 if unset)."""
|
||||
kelvin = self.color_temp_fixed_plateau_kelvin
|
||||
delta = self.max_color_temp - self.min_color_temp
|
||||
if kelvin is None or delta <= 0:
|
||||
return 1.0
|
||||
return min(max((kelvin - self.min_color_temp) / delta, 0.0), 1.0)
|
||||
|
||||
def color_temp_kelvin(self, sun_position: float) -> int:
|
||||
"""Calculate the color temperature in Kelvin."""
|
||||
if sun_position > 0:
|
||||
|
|
@ -534,6 +623,8 @@ class SunLightSettings:
|
|||
dt,
|
||||
self.color_temp_fixed_plateau_end,
|
||||
self.color_temp_fixed_descent_end,
|
||||
self._fixed_plateau_level(),
|
||||
self.color_temp_plateau_evening,
|
||||
)
|
||||
if fixed_position is not None:
|
||||
color_sun_position = fixed_position
|
||||
|
|
|
|||
|
|
@ -13,12 +13,18 @@ from .const import ( # pylint: disable=unused-import
|
|||
BASIC_OPTIONS,
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN,
|
||||
CONF_LIGHTS,
|
||||
CONF_MAX_COLOR_TEMP,
|
||||
CONF_MIN_COLOR_TEMP,
|
||||
DEFAULT_MAX_COLOR_TEMP,
|
||||
DEFAULT_MIN_COLOR_TEMP,
|
||||
DOMAIN,
|
||||
EXTRA_VALIDATION,
|
||||
NONE_STR,
|
||||
VALIDATION_TUPLES,
|
||||
validate_fixed_evening,
|
||||
validate_fixed_plateau_kelvin,
|
||||
)
|
||||
from .switch import validate
|
||||
|
||||
|
|
@ -135,6 +141,17 @@ def validate_options(user_input: dict[str, Any], errors: dict[str, str]) -> None
|
|||
except vol.Invalid:
|
||||
_LOGGER.exception("The fixed evening color temperature times are incorrect")
|
||||
errors["base"] = "option_error"
|
||||
try:
|
||||
validate_fixed_plateau_kelvin(
|
||||
user_input.get(CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN),
|
||||
user_input.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END),
|
||||
user_input.get(CONF_COLOR_TEMP_FIXED_DESCENT_END),
|
||||
int(user_input.get(CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP)),
|
||||
int(user_input.get(CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP)),
|
||||
)
|
||||
except vol.Invalid:
|
||||
_LOGGER.exception("The fixed evening plateau color temperature is incorrect")
|
||||
errors["base"] = "option_error"
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
|
|
|
|||
|
|
@ -242,6 +242,14 @@ DOCS[CONF_COLOR_TEMP_FIXED_DESCENT_END] = (
|
|||
"May be after midnight but at most 12 hours after the plateau end. Needs "
|
||||
"`color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙"
|
||||
)
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN = "color_temp_fixed_plateau_kelvin"
|
||||
DOCS[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = (
|
||||
"Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), "
|
||||
"reached and held for the last hour before `color_temp_fixed_plateau_end`; the "
|
||||
"daytime maximum is not capped. Needs both fixed times and is ignored when the "
|
||||
"plateau end is less than 1 h after solar noon. Applied in steps of 5 K. "
|
||||
"Unset holds `max_color_temp`. 🌙"
|
||||
)
|
||||
# The descent from the plateau end to the descent end lasts at most this long.
|
||||
MAX_COLOR_TEMP_FIXED_DESCENT = timedelta(hours=12)
|
||||
MIN_COLOR_TEMP_FIXED_PLATEAU_END = time(12, 0)
|
||||
|
|
@ -463,6 +471,7 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
|
|||
),
|
||||
(CONF_COLOR_TEMP_FIXED_PLATEAU_END, NONE_STR, str),
|
||||
(CONF_COLOR_TEMP_FIXED_DESCENT_END, NONE_STR, str),
|
||||
(CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN, NONE_STR, str),
|
||||
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
|
||||
(
|
||||
CONF_TAKE_OVER_CONTROL_MODE,
|
||||
|
|
@ -542,6 +551,7 @@ EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = {
|
|||
CONF_COLOR_TEMP_PLATEAU_EVENING: (_PLATEAU_PERIOD, timedelta_as_int),
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END: (cv.time, str),
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END: (cv.time, str),
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: (int_between(1000, 10000), int),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -588,6 +598,37 @@ def validate_fixed_evening(plateau_end: Any, descent_end: Any) -> None:
|
|||
raise vol.Invalid(msg)
|
||||
|
||||
|
||||
def validate_fixed_plateau_kelvin(
|
||||
kelvin: Any,
|
||||
plateau_end: Any,
|
||||
descent_end: Any,
|
||||
min_color_temp: int,
|
||||
max_color_temp: int,
|
||||
) -> None:
|
||||
"""Check the custom evening plateau temperature, unset being `None`/"None".
|
||||
|
||||
It needs the fixed evening times and must lie between the minimum and
|
||||
maximum color temperature. Raises `vol.Invalid` otherwise.
|
||||
"""
|
||||
if kelvin in (None, NONE_STR):
|
||||
return
|
||||
kelvin = int_between(1000, 10000)(kelvin)
|
||||
if plateau_end in (None, NONE_STR) or descent_end in (None, NONE_STR):
|
||||
msg = (
|
||||
f"{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN} needs"
|
||||
f" {CONF_COLOR_TEMP_FIXED_PLATEAU_END} and"
|
||||
f" {CONF_COLOR_TEMP_FIXED_DESCENT_END}"
|
||||
)
|
||||
raise vol.Invalid(msg)
|
||||
if not min_color_temp <= kelvin <= max_color_temp:
|
||||
msg = (
|
||||
f"{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN} ({kelvin}) must be between"
|
||||
f" {CONF_MIN_COLOR_TEMP} ({min_color_temp}) and"
|
||||
f" {CONF_MAX_COLOR_TEMP} ({max_color_temp})"
|
||||
)
|
||||
raise vol.Invalid(msg)
|
||||
|
||||
|
||||
def maybe_coerce(key: str, validation: Any) -> vol.All | Any:
|
||||
"""Coerce the validation into a json serializable type."""
|
||||
validation, coerce = EXTRA_VALIDATION.get(key, (validation, None))
|
||||
|
|
@ -612,6 +653,13 @@ def _validate_yaml_fixed_evening(config: dict[str, Any]) -> dict[str, Any]:
|
|||
config.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END),
|
||||
config.get(CONF_COLOR_TEMP_FIXED_DESCENT_END),
|
||||
)
|
||||
validate_fixed_plateau_kelvin(
|
||||
config.get(CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN),
|
||||
config.get(CONF_COLOR_TEMP_FIXED_PLATEAU_END),
|
||||
config.get(CONF_COLOR_TEMP_FIXED_DESCENT_END),
|
||||
config.get(CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP),
|
||||
config.get(CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
|
|
@ -644,6 +692,13 @@ def apply_service_schema() -> vol.Schema:
|
|||
)
|
||||
|
||||
|
||||
# The service takes the numbers of its selectors, where the options flow only
|
||||
# has a (serializable) string field; the strict check is made by `validate`.
|
||||
_SERVICE_VALIDATION: dict[str, Any] = {
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: vol.Any(vol.Coerce(int), str),
|
||||
}
|
||||
|
||||
|
||||
def change_switch_settings_schema() -> dict[vol.Marker, Any]:
|
||||
"""Return the schema for the change_switch_settings service."""
|
||||
args: dict[vol.Marker, Any] = {
|
||||
|
|
@ -653,7 +708,7 @@ def change_switch_settings_schema() -> dict[vol.Marker, Any]:
|
|||
skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS)
|
||||
for k, _, valid in VALIDATION_TUPLES:
|
||||
if k not in skip:
|
||||
args[vol.Optional(k)] = valid
|
||||
args[vol.Optional(k)] = _SERVICE_VALIDATION.get(k, valid)
|
||||
return args
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -235,6 +235,15 @@ change_switch_settings:
|
|||
required: false
|
||||
selector:
|
||||
time: null
|
||||
color_temp_fixed_plateau_kelvin:
|
||||
description: Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙
|
||||
example: ''
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1000
|
||||
max: 10000
|
||||
unit_of_measurement: K
|
||||
sunset_time:
|
||||
description: Set a fixed time (HH:MM:SS) for sunset. 🌇
|
||||
example: ''
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
"color_temp_plateau_evening": "color_temp_plateau_evening",
|
||||
"color_temp_fixed_plateau_end": "color_temp_fixed_plateau_end",
|
||||
"color_temp_fixed_descent_end": "color_temp_fixed_descent_end",
|
||||
"color_temp_fixed_plateau_kelvin": "color_temp_fixed_plateau_kelvin",
|
||||
"take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
|
||||
"take_over_control_mode": "take_over_control_mode",
|
||||
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
|
||||
|
|
@ -105,6 +106,7 @@
|
|||
"color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
|
||||
"color_temp_fixed_plateau_end": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙",
|
||||
"color_temp_fixed_descent_end": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
|
||||
"color_temp_fixed_plateau_kelvin": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
|
||||
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
|
||||
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
|
||||
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
|
||||
|
|
@ -276,6 +278,10 @@
|
|||
"description": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
|
||||
"name": "color_temp_fixed_descent_end"
|
||||
},
|
||||
"color_temp_fixed_plateau_kelvin": {
|
||||
"description": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
|
||||
"name": "color_temp_fixed_plateau_kelvin"
|
||||
},
|
||||
"sunset_time": {
|
||||
"description": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
|
||||
"name": "sunset_time"
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ from .const import (
|
|||
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN,
|
||||
CONF_COLOR_TEMP_PLATEAU_EVENING,
|
||||
CONF_COLOR_TEMP_PLATEAU_MORNING,
|
||||
CONF_DETECT_NON_HA_CHANGES,
|
||||
|
|
@ -157,6 +158,7 @@ from .const import (
|
|||
change_switch_settings_schema,
|
||||
replace_none_str,
|
||||
validate_fixed_evening,
|
||||
validate_fixed_plateau_kelvin,
|
||||
)
|
||||
from .hass_utils import setup_service_call_interceptor, target_entities
|
||||
from .helpers import (
|
||||
|
|
@ -630,6 +632,22 @@ def validate(
|
|||
)
|
||||
data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = None
|
||||
data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = None
|
||||
try:
|
||||
validate_fixed_plateau_kelvin(
|
||||
data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN],
|
||||
data[CONF_COLOR_TEMP_FIXED_PLATEAU_END],
|
||||
data[CONF_COLOR_TEMP_FIXED_DESCENT_END],
|
||||
data[CONF_MIN_COLOR_TEMP],
|
||||
data[CONF_MAX_COLOR_TEMP],
|
||||
)
|
||||
except vol.Invalid:
|
||||
if config_entry is None: # service call: report it to the caller
|
||||
raise
|
||||
_LOGGER.exception(
|
||||
"%s: ignoring the incoherent fixed evening plateau color temperature",
|
||||
data.get(CONF_NAME),
|
||||
)
|
||||
data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = None
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -1065,6 +1083,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
color_temp_plateau_evening=data[CONF_COLOR_TEMP_PLATEAU_EVENING],
|
||||
color_temp_fixed_plateau_end=data[CONF_COLOR_TEMP_FIXED_PLATEAU_END],
|
||||
color_temp_fixed_descent_end=data[CONF_COLOR_TEMP_FIXED_DESCENT_END],
|
||||
color_temp_fixed_plateau_kelvin=data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN],
|
||||
timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@
|
|||
"color_temp_plateau_evening": "color_temp_plateau_evening",
|
||||
"color_temp_fixed_plateau_end": "color_temp_fixed_plateau_end",
|
||||
"color_temp_fixed_descent_end": "color_temp_fixed_descent_end",
|
||||
"color_temp_fixed_plateau_kelvin": "color_temp_fixed_plateau_kelvin",
|
||||
"take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
|
||||
"take_over_control_mode": "take_over_control_mode",
|
||||
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
|
||||
|
|
@ -106,6 +107,7 @@
|
|||
"color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
|
||||
"color_temp_fixed_plateau_end": "Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year. Needs `color_temp_fixed_descent_end`. Leave unset to keep the default evening curve. Only affects the color temperature. 🌙",
|
||||
"color_temp_fixed_descent_end": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
|
||||
"color_temp_fixed_plateau_kelvin": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
|
||||
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
|
||||
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
|
||||
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
|
||||
|
|
@ -277,6 +279,10 @@
|
|||
"description": "Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight but at most 12 hours after the plateau end. Needs `color_temp_fixed_plateau_end`. Leave unset to keep the default evening curve. 🌙",
|
||||
"name": "color_temp_fixed_descent_end"
|
||||
},
|
||||
"color_temp_fixed_plateau_kelvin": {
|
||||
"description": "Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙",
|
||||
"name": "color_temp_fixed_plateau_kelvin"
|
||||
},
|
||||
"sunset_time": {
|
||||
"description": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
|
||||
"name": "sunset_time"
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ The plateau options above are measured from solar noon, so the evening moves wit
|
|||
|--------|---------|-------------|
|
||||
| `color_temp_fixed_plateau_end` | unset | Local time until which the color temperature stays at `max_color_temp` |
|
||||
| `color_temp_fixed_descent_end` | unset | Local time at which the color temperature has descended to `min_color_temp` |
|
||||
| `color_temp_fixed_plateau_kelvin` | unset | Optional temperature (K) held until `color_temp_fixed_plateau_end` instead of `max_color_temp` |
|
||||
|
||||
```yaml
|
||||
adaptive_lighting:
|
||||
|
|
@ -53,10 +54,30 @@ adaptive_lighting:
|
|||
color_temp_fixed_descent_end: "23:00:00" # smooth descent to 2700 K at 23:00
|
||||
```
|
||||
|
||||
Both options are available in the UI (advanced section), in YAML and in `adaptive_lighting.change_switch_settings`. When both are unset, which is the default, nothing changes.
|
||||
### Evening plateau lower than the daytime maximum
|
||||
|
||||
- Only the color temperature is affected. The brightness, the sleep mode (which keeps priority) and the morning curve are unchanged. `color_temp_plateau_evening` is superseded by these times while they are set.
|
||||
- The color temperature follows the default curve until solar noon (and stays at `max_color_temp` from then until `color_temp_fixed_plateau_end`), then descends with a smooth cosine easing, then stays at `min_color_temp` until sunset or later.
|
||||
By default the evening holds `max_color_temp`. To keep a high daytime maximum but a calmer evening, set `color_temp_fixed_plateau_kelvin` (between `min_color_temp` and `max_color_temp`):
|
||||
|
||||
```yaml
|
||||
adaptive_lighting:
|
||||
- name: kitchen
|
||||
min_color_temp: 2700
|
||||
max_color_temp: 5500
|
||||
color_temp_fixed_plateau_end: "21:00:00"
|
||||
color_temp_fixed_descent_end: "23:00:00"
|
||||
color_temp_fixed_plateau_kelvin: 4000 # 5500 K by day, 4000 K until 21:00
|
||||
```
|
||||
|
||||
The morning and the middle of the day are unchanged and still reach `max_color_temp` (`color_temp_plateau_morning` and `color_temp_plateau_evening` keep their meaning, see the next points). After the daytime maximum the color temperature descends along the regular afternoon curve to `color_temp_fixed_plateau_kelvin`, **reaches it exactly and holds it for the last hour before `color_temp_fixed_plateau_end`** whatever the season, then descends to `min_color_temp` as described below.
|
||||
|
||||
- When the regular curve gets to the target by itself at least one hour before the plateau end (typically in winter), it is used unchanged. Otherwise (summer, or a late `sunset_offset`) the same curve is traversed faster so that the target is reached one hour before the plateau end; there is no step anywhere and the slope is zero where the descent starts.
|
||||
- On a short afternoon the hold is shortened to half of the time between solar noon and the plateau end. If the plateau end is less than one hour after solar noon the target is ignored for that day (the maximum is held until the plateau end, as without the option), because there is no room to ramp down. In summer a long `color_temp_plateau_evening` that would not leave room for the descent may be shortened (the maximum is then held for less than that duration).
|
||||
- The option needs both times, must lie between `min_color_temp` and `max_color_temp` (also checked when those change), and a value equal to `max_color_temp` is the same as leaving it unset. Unset keeps the exact previous behavior. Like every color temperature, the value is applied in steps of 5 K (4003 gives 4005).
|
||||
|
||||
The options are available in the UI (advanced section), in YAML and in `adaptive_lighting.change_switch_settings`. When they are unset, which is the default, nothing changes.
|
||||
|
||||
- Only the color temperature is affected. The brightness, the sleep mode (which keeps priority) and the morning curve are unchanged. `color_temp_plateau_evening` is superseded by these times while they are set, except that it still shapes the afternoon curve towards `color_temp_fixed_plateau_kelvin`.
|
||||
- The color temperature follows the default curve until solar noon (and stays at `max_color_temp`, or `color_temp_fixed_plateau_kelvin` if set, from then until `color_temp_fixed_plateau_end`), then descends with a smooth cosine easing, then stays at `min_color_temp` until sunset or later.
|
||||
- Validation: both times must be set together, they must differ, `color_temp_fixed_plateau_end` must be 12:00 or later, and the descent must last at most 12 hours. `color_temp_fixed_descent_end` may be after midnight (for example 23:00 → 01:00). In YAML or the UI an invalid pair is rejected; through the service it raises an error; in a stored configuration it is ignored with an error in the log.
|
||||
- The times are wall-clock times of the Home Assistant time zone. On a day with a DST change inside the window, the real duration of the descent is one hour shorter or longer, but the curve stays continuous.
|
||||
- The window is anchored on the last solar noon and cut at the next sunrise: it ends at `color_temp_fixed_descent_end` or at that sunrise, whichever comes first, so the color temperature is never higher than the morning curve at sunrise. If solar noon is later than `color_temp_fixed_plateau_end`, the descent starts at noon; if the window does not fit between noon and the next sunrise, the default curve is used for that day.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ All configuration options are listed below with their default values. These opti
|
|||
| `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 |
|
||||
| `color_temp_fixed_plateau_end` | Local time (HH:MM:SS, 12:00 or later) until which the color temperature stays at `max_color_temp` in the evening, every day of the year, instead of following the sun. Needs `color_temp_fixed_descent_end`; unset keeps the default curve. Only affects the color temperature. 🌙 | `None` | `str` (HH:MM:SS) |
|
||||
| `color_temp_fixed_descent_end` | Local time (HH:MM:SS) at which the color temperature has smoothly descended from `max_color_temp` to `min_color_temp`, starting at `color_temp_fixed_plateau_end`. May be after midnight, at most 12 hours later. Needs `color_temp_fixed_plateau_end`; unset keeps the default curve. 🌙 | `None` | `str` (HH:MM:SS) |
|
||||
| `color_temp_fixed_plateau_kelvin` | Evening temperature in Kelvin (between `min_color_temp` and `max_color_temp`), reached and held for the last hour before `color_temp_fixed_plateau_end`; the daytime maximum is not capped. Needs both fixed times and is ignored when the plateau end is less than 1 h after solar noon. Applied in steps of 5 K. Unset holds `max_color_temp`. 🌙 | `None` | `str` |
|
||||
| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` |
|
||||
| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` |
|
||||
| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` |
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
BASIC_OPTIONS,
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN,
|
||||
CONF_COLOR_TEMP_PLATEAU_EVENING,
|
||||
CONF_COLOR_TEMP_PLATEAU_MORNING,
|
||||
CONF_EXPAND_LIGHT_GROUPS,
|
||||
|
|
@ -118,6 +119,7 @@ async def test_options(hass):
|
|||
advanced_data[CONF_SUNSET_TIME] = NONE_STR
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = "21:00:00"
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = "23:00:00"
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = "4000"
|
||||
basic_data = {**BASIC_DATA, "min_brightness": 12}
|
||||
user_input = {
|
||||
**basic_data,
|
||||
|
|
@ -263,6 +265,44 @@ async def test_options_flow_rejects_incoherent_fixed_evening(
|
|||
assert result["errors"] == {"base": "option_error"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plateau_end", "descent_end", "plateau_kelvin"),
|
||||
[
|
||||
(NONE_STR, NONE_STR, "4000"), # needs the times
|
||||
("21:00:00", "23:00:00", "1999"), # below the default minimum (2000 K)
|
||||
("21:00:00", "23:00:00", "5501"), # above the default maximum (5500 K)
|
||||
("21:00:00", "23:00:00", "warm"),
|
||||
("21:00:00", "23:00:00", ""),
|
||||
],
|
||||
)
|
||||
async def test_options_flow_rejects_incoherent_plateau_kelvin(
|
||||
hass,
|
||||
plateau_end,
|
||||
descent_end,
|
||||
plateau_kelvin,
|
||||
):
|
||||
"""An unusable custom evening plateau temperature is an options flow error."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=DEFAULT_NAME,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
options={},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
advanced_data = ADVANCED_DATA.copy()
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_END] = plateau_end
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_DESCENT_END] = descent_end
|
||||
advanced_data[CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN] = plateau_kelvin
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={**BASIC_DATA, "advanced": advanced_data},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "option_error"}
|
||||
|
||||
|
||||
async def test_import_twice(hass):
|
||||
"""Test importing twice."""
|
||||
data = DEFAULT_DATA.copy()
|
||||
|
|
|
|||
439
tests/test_fixed_plateau_kelvin.py
Normal file
439
tests/test_fixed_plateau_kelvin.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"""Tests for the custom temperature of the fixed evening color temperature plateau."""
|
||||
|
||||
import datetime as dt
|
||||
import zoneinfo
|
||||
from itertools import pairwise
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
from astral import LocationInfo
|
||||
from astral.location import Location
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
_DOMAIN_SCHEMA,
|
||||
validate_fixed_plateau_kelvin,
|
||||
)
|
||||
|
||||
from .test_fixed_evening import (
|
||||
PARIS,
|
||||
SEASON_DAYS,
|
||||
TROMSO_LOCATION,
|
||||
grid,
|
||||
kelvin,
|
||||
local,
|
||||
make_settings,
|
||||
)
|
||||
|
||||
TOULOUSE = Location(
|
||||
LocationInfo(
|
||||
name="Toulouse",
|
||||
region="France",
|
||||
timezone="Europe/Paris",
|
||||
latitude=43.57,
|
||||
longitude=1.48,
|
||||
),
|
||||
)
|
||||
OSLO = zoneinfo.ZoneInfo("Europe/Oslo")
|
||||
MINUTE = dt.timedelta(minutes=1)
|
||||
|
||||
|
||||
def user_case(plateau_kelvin=4000, **kwargs):
|
||||
"""Charles' kitchen: 5500 K at noon, held at 4000 K until 21:00, 2700 K at 23:00."""
|
||||
params = {
|
||||
"location": TOULOUSE,
|
||||
"max_color_temp": 5500,
|
||||
"sunset_offset": dt.timedelta(hours=2),
|
||||
"color_temp_plateau_morning": dt.timedelta(seconds=3600),
|
||||
"color_temp_plateau_evening": dt.timedelta(seconds=14400),
|
||||
"color_temp_fixed_plateau_kelvin": plateau_kelvin,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return make_settings(dt.time(21, 0), dt.time(23, 0), **params)
|
||||
|
||||
|
||||
def without_fixed(**kwargs):
|
||||
"""The same settings without any evening option."""
|
||||
kwargs.setdefault("location", TOULOUSE)
|
||||
kwargs.setdefault("color_temp_plateau_morning", dt.timedelta(seconds=3600))
|
||||
kwargs.setdefault("color_temp_plateau_evening", dt.timedelta(seconds=14400))
|
||||
return make_settings(
|
||||
max_color_temp=5500,
|
||||
sunset_offset=dt.timedelta(hours=2),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def solar_noon(settings, day):
|
||||
when = local(day, 12, 0)
|
||||
return min(
|
||||
(ts for ev, ts in settings.sun.sun_events(when) if ev.name == "NOON"),
|
||||
)
|
||||
|
||||
|
||||
ALL_DAYS = [dt.date(2026, 1, 1) + dt.timedelta(days=n) for n in range(365)]
|
||||
|
||||
|
||||
def test_target_is_reached_and_held_until_21h_every_day_of_the_year():
|
||||
"""Exactly 4000 K from 20:00 to 21:00 (Cuisine options), 365 days, DST included."""
|
||||
settings = user_case()
|
||||
for day in ALL_DAYS:
|
||||
for hour, minute in [(20, 0), (20, 15), (20, 30), (20, 45), (20, 59), (21, 0)]:
|
||||
assert kelvin(settings, local(day, hour, minute)) == 4000, (day, hour)
|
||||
assert kelvin(settings, local(day, 22, 0)) == 3350, day
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700, day
|
||||
assert kelvin(settings, local(day, 23, 30)) == 2700, day
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_user_case_all_year(day):
|
||||
"""Daytime maximum kept, target held, then easing to the minimum."""
|
||||
settings = user_case()
|
||||
peak = max(kelvin(settings, when) for when in grid(day, 5))
|
||||
assert peak >= 5490 # the daytime maximum is not capped by the plateau
|
||||
assert kelvin(settings, local(day, 14, 0)) >= 5490
|
||||
held = [kelvin(settings, when) for when in grid(day, 1)]
|
||||
start = 20 * 60
|
||||
assert held[start : 21 * 60 + 1] == [4000] * 61
|
||||
assert kelvin(settings, local(day, 22, 30)) == 2890
|
||||
assert kelvin(settings, local(day, 21, 30)) == 3810
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
@pytest.mark.parametrize("plateau_evening", [0, 3600, 4 * 3600, 8 * 3600, 43200])
|
||||
@pytest.mark.parametrize("plateau_morning", [0, 3600])
|
||||
def test_target_is_held_for_any_evening_plateau_duration(
|
||||
day,
|
||||
plateau_evening,
|
||||
plateau_morning,
|
||||
):
|
||||
"""Between the regular curve and the target, the target is always reached."""
|
||||
extra = {
|
||||
"color_temp_plateau_evening": dt.timedelta(seconds=plateau_evening),
|
||||
"color_temp_plateau_morning": dt.timedelta(seconds=plateau_morning),
|
||||
}
|
||||
fixed = user_case(**extra)
|
||||
regular = without_fixed(**extra)
|
||||
noon = dt.datetime.fromtimestamp(solar_noon(fixed, day), PARIS)
|
||||
for when in grid(day, 3):
|
||||
if noon <= when <= local(day, 21, 0):
|
||||
value = kelvin(fixed, when)
|
||||
assert value >= 4000
|
||||
# never above what the regular curve (or the target) would give
|
||||
assert value <= max(kelvin(regular, when), 4000)
|
||||
for minute in range(20 * 60, 21 * 60 + 1, 5):
|
||||
assert kelvin(fixed, local(day, 0, 0) + dt.timedelta(minutes=minute)) == 4000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", [dt.date(2026, 1, 15), dt.date(2026, 12, 21)])
|
||||
def test_when_the_regular_curve_gets_there_in_time_it_is_not_altered(day):
|
||||
"""In winter the regular afternoon curve reaches the target by itself."""
|
||||
fixed = user_case()
|
||||
regular = without_fixed()
|
||||
noon = dt.datetime.fromtimestamp(solar_noon(fixed, day), PARIS)
|
||||
for when in grid(day, 3):
|
||||
if noon <= when <= local(day, 21, 0):
|
||||
assert kelvin(fixed, when) == max(kelvin(regular, when), 4000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_morning_brightness_and_noon_are_unchanged(day):
|
||||
"""Everything before solar noon, and the brightness all day, is untouched."""
|
||||
fixed = user_case()
|
||||
regular = without_fixed()
|
||||
noon = dt.datetime.fromtimestamp(solar_noon(fixed, day), PARIS)
|
||||
for when in grid(day, 3):
|
||||
a = fixed.brightness_and_color(when, False)
|
||||
b = regular.brightness_and_color(when, False)
|
||||
assert a["brightness_pct"] == b["brightness_pct"]
|
||||
if when < noon:
|
||||
assert a == b
|
||||
# the sleep mode keeps priority
|
||||
for when in grid(day, 30):
|
||||
assert fixed.brightness_and_color(when, True) == regular.brightness_and_color(
|
||||
when,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_unset_target_is_identical_to_before(day):
|
||||
"""No target: exactly the behavior of the fixed times without the option."""
|
||||
old = make_settings(dt.time(21, 0), dt.time(23, 0), location=TOULOUSE)
|
||||
new = make_settings(
|
||||
dt.time(21, 0),
|
||||
dt.time(23, 0),
|
||||
location=TOULOUSE,
|
||||
color_temp_fixed_plateau_kelvin=None,
|
||||
)
|
||||
for when in grid(day, 7):
|
||||
assert old.brightness_and_color(when, False) == new.brightness_and_color(
|
||||
when,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", SEASON_DAYS)
|
||||
def test_target_equal_to_max_is_identical_to_unset(day):
|
||||
"""A target at the maximum is the historical hold at the maximum."""
|
||||
old = make_settings(dt.time(21, 0), dt.time(23, 0), location=TOULOUSE)
|
||||
new = make_settings(
|
||||
dt.time(21, 0),
|
||||
dt.time(23, 0),
|
||||
location=TOULOUSE,
|
||||
color_temp_fixed_plateau_kelvin=4000,
|
||||
)
|
||||
for when in grid(day, 7):
|
||||
assert old.brightness_and_color(when, False) == new.brightness_and_color(
|
||||
when,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plateau_kelvin", [2700, 2705, 3000, 4000, 5000, 5495, 5500])
|
||||
@pytest.mark.parametrize(
|
||||
"day",
|
||||
[*SEASON_DAYS, dt.date(2026, 3, 28), dt.date(2026, 3, 29), dt.date(2026, 10, 25)],
|
||||
)
|
||||
def test_continuous_monotonic_and_bounded(day, plateau_kelvin):
|
||||
"""No jump anywhere, non-increasing from noon on, always within min and max."""
|
||||
settings = user_case(plateau_kelvin)
|
||||
values = [(when, kelvin(settings, when)) for when in grid(day, 1, days=2)]
|
||||
noon = dt.datetime.fromtimestamp(solar_noon(settings, day), PARIS)
|
||||
for _, value in values:
|
||||
assert 2700 <= value <= 5500
|
||||
for (_, a), (_, b) in pairwise(values):
|
||||
assert abs(a - b) <= 40 # per minute; the steepest legit slope is ~37 K/min
|
||||
after_noon = [value for when, value in values if noon <= when <= local(day, 23, 0)]
|
||||
assert after_noon == sorted(after_noon, reverse=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plateau_end_hour", [12, 14, 15, 17, 19, 21])
|
||||
@pytest.mark.parametrize("day", [dt.date(2026, 6, 21), dt.date(2026, 12, 21)])
|
||||
def test_no_step_when_the_plateau_ends_before_the_target_is_reached(
|
||||
day,
|
||||
plateau_end_hour,
|
||||
):
|
||||
"""An early plateau end squeezes the ramp (steeper) but never steps."""
|
||||
settings = make_settings(
|
||||
dt.time(plateau_end_hour, 0),
|
||||
dt.time(23, 0),
|
||||
location=TOULOUSE,
|
||||
max_color_temp=5500,
|
||||
sunset_offset=dt.timedelta(hours=2),
|
||||
color_temp_fixed_plateau_kelvin=3000,
|
||||
)
|
||||
values = [kelvin(settings, when) for when in grid(day, 1, days=2)]
|
||||
# a 30 minute ramp over 2500 K peaks at 167 K/min; a step would be far larger
|
||||
assert max(abs(a - b) for a, b in pairwise(values)) <= 170
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700
|
||||
noon = dt.datetime.fromtimestamp(solar_noon(settings, day), PARIS)
|
||||
plateau_end = local(day, plateau_end_hour, 0)
|
||||
if plateau_end >= noon + dt.timedelta(hours=1):
|
||||
assert kelvin(settings, plateau_end) == 3000
|
||||
assert kelvin(settings, plateau_end - dt.timedelta(minutes=29)) == 3000
|
||||
|
||||
|
||||
def test_target_at_the_minimum():
|
||||
"""A target equal to the minimum follows the curve down and then holds."""
|
||||
settings = user_case(2700)
|
||||
day = dt.date(2026, 10, 19)
|
||||
values = [kelvin(settings, when) for when in grid(day, 1, days=2)]
|
||||
assert max(abs(a - b) for a, b in pairwise(values)) <= 40
|
||||
# the regular curve is still slightly above the minimum at 21:00 here
|
||||
assert 2700 <= kelvin(settings, local(day, 21, 0)) <= 2760
|
||||
assert kelvin(settings, local(day, 22, 0)) <= 2730
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700
|
||||
assert kelvin(settings, local(day, 14, 0)) > 5000
|
||||
|
||||
|
||||
def test_max_equals_min_does_not_divide_by_zero():
|
||||
settings = make_settings(
|
||||
dt.time(21, 0),
|
||||
dt.time(23, 0),
|
||||
location=TOULOUSE,
|
||||
min_color_temp=3000,
|
||||
max_color_temp=3000,
|
||||
color_temp_fixed_plateau_kelvin=3000,
|
||||
)
|
||||
for when in grid(dt.date(2026, 6, 21), 10):
|
||||
assert kelvin(settings, when) == 3000
|
||||
|
||||
|
||||
def test_exact_values_for_the_user_case_in_october():
|
||||
"""Toulouse, 2026-10-19: max by day, 4000 K from 20:00, 2700 K at 23:00."""
|
||||
settings = user_case()
|
||||
day = dt.date(2026, 10, 19)
|
||||
assert kelvin(settings, local(day, 14, 0)) == 5500 # still reaches the maximum
|
||||
assert kelvin(settings, local(day, 17, 0)) == 5495
|
||||
assert kelvin(settings, local(day, 18, 0)) == 5295
|
||||
assert kelvin(settings, local(day, 19, 0)) == 4795
|
||||
assert kelvin(settings, local(day, 19, 30)) == 4435
|
||||
assert kelvin(settings, local(day, 20, 0)) == 4000
|
||||
assert kelvin(settings, local(day, 21, 0)) == 4000
|
||||
assert kelvin(settings, local(day, 21, 30)) == 3810
|
||||
assert kelvin(settings, local(day, 22, 0)) == 3350
|
||||
assert kelvin(settings, local(day, 22, 30)) == 2890
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plateau_end",
|
||||
[dt.time(12, 0), dt.time(13, 0), dt.time(14, 30), dt.time(15, 5), dt.time(15, 30)],
|
||||
)
|
||||
@pytest.mark.parametrize("day", [dt.date(2026, 6, 21), dt.date(2026, 12, 21)])
|
||||
def test_plateau_end_close_to_solar_noon(day, plateau_end):
|
||||
"""A short afternoon squeezes the ramp but stays continuous, bounded, monotonic."""
|
||||
settings = make_settings(
|
||||
plateau_end,
|
||||
dt.time(23, 0),
|
||||
location=TOULOUSE,
|
||||
max_color_temp=5500,
|
||||
sunset_offset=dt.timedelta(hours=2),
|
||||
color_temp_fixed_plateau_kelvin=4000,
|
||||
)
|
||||
noon = dt.datetime.fromtimestamp(solar_noon(settings, day), PARIS)
|
||||
values = [(w, kelvin(settings, w)) for w in grid(day, 1, days=2)]
|
||||
assert all(2700 <= v <= 5500 for _, v in values)
|
||||
after = [v for w, v in values if noon <= w <= local(day, 23, 0)]
|
||||
assert after == sorted(after, reverse=True)
|
||||
# when the plateau end is after solar noon the target is held until then
|
||||
plateau_dt = dt.datetime.combine(day, plateau_end, PARIS)
|
||||
if plateau_dt >= noon + dt.timedelta(hours=1):
|
||||
assert kelvin(settings, plateau_dt) == 4000
|
||||
assert kelvin(settings, plateau_dt - dt.timedelta(minutes=1)) == 4000
|
||||
assert max(abs(a - b) for (_, a), (_, b) in pairwise(values)) <= 150
|
||||
else: # less than 1 h after noon: ignored, the historical behavior
|
||||
old = make_settings(
|
||||
plateau_end,
|
||||
dt.time(23, 0),
|
||||
location=TOULOUSE,
|
||||
max_color_temp=5500,
|
||||
sunset_offset=dt.timedelta(hours=2),
|
||||
)
|
||||
for when in grid(day, 7):
|
||||
assert kelvin(settings, when) == kelvin(old, when)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", [dt.date(2026, 3, 29), dt.date(2026, 10, 25)])
|
||||
def test_dst_wall_clock_times_hold(day):
|
||||
settings = user_case()
|
||||
assert kelvin(settings, local(day, 23, 0)) == 2700
|
||||
assert kelvin(settings, local(day, 22, 0)) == 3350
|
||||
|
||||
|
||||
def test_polar_days():
|
||||
for day in (dt.date(2026, 6, 21), dt.date(2026, 12, 21)):
|
||||
settings = make_settings(
|
||||
dt.time(21, 0),
|
||||
dt.time(23, 0),
|
||||
location=TROMSO_LOCATION,
|
||||
timezone=OSLO,
|
||||
max_color_temp=5500,
|
||||
color_temp_fixed_plateau_kelvin=4000,
|
||||
)
|
||||
regular = make_settings(
|
||||
dt.time(21, 0),
|
||||
dt.time(23, 0),
|
||||
location=TROMSO_LOCATION,
|
||||
timezone=OSLO,
|
||||
max_color_temp=5500,
|
||||
)
|
||||
when = grid(day, 5, days=2, tz=OSLO)
|
||||
values = [kelvin(settings, w) for w in when]
|
||||
baseline = [kelvin(regular, w) for w in when]
|
||||
assert all(2700 <= value <= 5500 for value in values)
|
||||
|
||||
def steepest(series):
|
||||
return max(abs(a - b) for a, b in pairwise(series))
|
||||
|
||||
# the (very short, synthetic) polar days are steep by themselves; the
|
||||
# option must not add a step on top of the historical fixed evening
|
||||
assert steepest(values) <= steepest(baseline)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rgb", ["color_temp", "rgb_color"])
|
||||
@pytest.mark.parametrize("adapt_until_sleep", [True, False])
|
||||
def test_sleep_and_night_options_keep_working(rgb, adapt_until_sleep):
|
||||
day = dt.date(2026, 10, 19)
|
||||
fixed = user_case(adapt_until_sleep=adapt_until_sleep, sleep_rgb_or_color_temp=rgb)
|
||||
regular = without_fixed(
|
||||
adapt_until_sleep=adapt_until_sleep,
|
||||
sleep_rgb_or_color_temp=rgb,
|
||||
)
|
||||
for when in grid(day, 15, days=2):
|
||||
assert fixed.brightness_and_color(when, True) == regular.brightness_and_color(
|
||||
when,
|
||||
True,
|
||||
)
|
||||
assert (
|
||||
fixed.brightness_and_color(when, False)["brightness_pct"]
|
||||
== regular.brightness_and_color(when, False)["brightness_pct"]
|
||||
)
|
||||
|
||||
|
||||
# --- validation ----------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kelvin_value", [None, "None"])
|
||||
def test_unset_is_always_valid(kelvin_value):
|
||||
validate_fixed_plateau_kelvin(kelvin_value, None, None, 2700, 5500)
|
||||
validate_fixed_plateau_kelvin(kelvin_value, "21:00:00", "23:00:00", 2700, 5500)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kelvin_value", [2700, 4000, 5500, "4000"])
|
||||
def test_valid_targets(kelvin_value):
|
||||
validate_fixed_plateau_kelvin(kelvin_value, "21:00:00", "23:00:00", 2700, 5500)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kelvin_value", "plateau_end", "descent_end"),
|
||||
[
|
||||
(4000, None, None), # needs the times
|
||||
(4000, "21:00:00", None),
|
||||
(4000, None, "23:00:00"),
|
||||
(4000, "None", "None"),
|
||||
(2699, "21:00:00", "23:00:00"), # below min
|
||||
(5501, "21:00:00", "23:00:00"), # above max
|
||||
(999, "21:00:00", "23:00:00"),
|
||||
(10001, "21:00:00", "23:00:00"),
|
||||
(0, "21:00:00", "23:00:00"),
|
||||
(-4000, "21:00:00", "23:00:00"),
|
||||
("warm", "21:00:00", "23:00:00"),
|
||||
("", "21:00:00", "23:00:00"),
|
||||
],
|
||||
)
|
||||
def test_invalid_targets(kelvin_value, plateau_end, descent_end):
|
||||
with pytest.raises(vol.Invalid):
|
||||
validate_fixed_plateau_kelvin(
|
||||
kelvin_value,
|
||||
plateau_end,
|
||||
descent_end,
|
||||
2700,
|
||||
5500,
|
||||
)
|
||||
|
||||
|
||||
def test_yaml_schema():
|
||||
times = {
|
||||
"color_temp_fixed_plateau_end": "21:00:00",
|
||||
"color_temp_fixed_descent_end": "23:00:00",
|
||||
}
|
||||
assert "color_temp_fixed_plateau_kelvin" not in _DOMAIN_SCHEMA({})
|
||||
assert "color_temp_fixed_plateau_kelvin" not in _DOMAIN_SCHEMA(times)
|
||||
config = _DOMAIN_SCHEMA(
|
||||
{**times, "color_temp_fixed_plateau_kelvin": 4000, "max_color_temp": 5500},
|
||||
)
|
||||
assert config["color_temp_fixed_plateau_kelvin"] == 4000
|
||||
for bad in (
|
||||
{"color_temp_fixed_plateau_kelvin": 4000}, # no times
|
||||
{**times, "color_temp_fixed_plateau_kelvin": 6000}, # above the default max
|
||||
{**times, "color_temp_fixed_plateau_kelvin": 1999}, # below the default min
|
||||
{**times, "color_temp_fixed_plateau_kelvin": 500},
|
||||
{**times, "color_temp_fixed_plateau_kelvin": "warm"},
|
||||
{
|
||||
**times,
|
||||
"color_temp_fixed_plateau_kelvin": 4100,
|
||||
"max_color_temp": 4000,
|
||||
},
|
||||
):
|
||||
with pytest.raises(vol.Invalid):
|
||||
_DOMAIN_SCHEMA(bad)
|
||||
|
|
@ -39,6 +39,7 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN,
|
||||
CONF_COLOR_TEMP_PLATEAU_EVENING,
|
||||
CONF_COLOR_TEMP_PLATEAU_MORNING,
|
||||
CONF_DETECT_NON_HA_CHANGES,
|
||||
|
|
@ -3108,6 +3109,103 @@ def test_fixed_evening_yaml_validation(plateau_end, descent_end):
|
|||
_DOMAIN_SCHEMA(config)
|
||||
|
||||
|
||||
async def test_fixed_plateau_kelvin_service(hass):
|
||||
"""The custom evening plateau temperature is settable at runtime."""
|
||||
switch, _ = await setup_lights_and_switch(hass)
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
|
||||
|
||||
async def change_switch_settings(**kwargs):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{ATTR_ENTITY_ID: switch.entity_id, **kwargs},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
times = {
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
|
||||
}
|
||||
# needs the times
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000})
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
|
||||
|
||||
await change_switch_settings(
|
||||
**{CONF_MAX_COLOR_TEMP: 5500, CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000},
|
||||
**times,
|
||||
)
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
|
||||
|
||||
# out of range or mistyped: rejected, settings untouched
|
||||
for bad in (1999, 5501, 500, 10001, "warm"):
|
||||
with pytest.raises((ServiceValidationError, voluptuous.error.Invalid)):
|
||||
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: bad})
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
|
||||
# lowering the maximum below the target is rejected too
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await change_switch_settings(**{CONF_MAX_COLOR_TEMP: 3500})
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
|
||||
|
||||
# unrelated changes keep it ("current" defaults)
|
||||
await change_switch_settings(**{CONF_MAX_BRIGHTNESS: 80})
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
|
||||
|
||||
# clearing the times alone is rejected because the target needs them
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await change_switch_settings(
|
||||
**{
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "None",
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END: "None",
|
||||
},
|
||||
)
|
||||
|
||||
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: "None"})
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
|
||||
|
||||
await change_switch_settings(**{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000})
|
||||
await change_switch_settings(**{CONF_USE_DEFAULTS: "factory"})
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
|
||||
|
||||
|
||||
async def test_fixed_plateau_kelvin_from_configuration(hass):
|
||||
"""The option from the config entry reaches the sun light settings."""
|
||||
_, switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_MAX_COLOR_TEMP: 5500,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: "4000", # as stored by the UI
|
||||
},
|
||||
)
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin == 4000
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"options",
|
||||
[
|
||||
{CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000}, # no times
|
||||
{
|
||||
CONF_MAX_COLOR_TEMP: 3500,
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_END: "21:00:00",
|
||||
CONF_COLOR_TEMP_FIXED_DESCENT_END: "23:00:00",
|
||||
CONF_COLOR_TEMP_FIXED_PLATEAU_KELVIN: 4000, # above max
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_fixed_plateau_kelvin_incoherent_stored_configuration_is_ignored(
|
||||
hass,
|
||||
caplog,
|
||||
options,
|
||||
):
|
||||
"""A hand edited stored target is ignored and logged, never fatal."""
|
||||
_, switch = await setup_switch(hass, options)
|
||||
assert switch._sun_light_settings.color_temp_fixed_plateau_kelvin is None
|
||||
assert "ignoring the incoherent fixed evening plateau" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["entity", "area", "device", "all"])
|
||||
async def test_change_switch_settings_entity_targets(hass, device_registry, target):
|
||||
"""Test settings changes through Home Assistant entity targets."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue