feat: add configurable color temperature plateau

Add `color_temp_plateau_morning` and `color_temp_plateau_evening` (seconds,
default 0, max 12h). They keep `max_color_temp` for that long before / after
solar noon, and compress the ramp from/to `min_color_temp` at sunrise/sunset so
the curve stays continuous and smooth. Each side is independent, only the
color temperature is affected (not brightness, sleep mode or the night), and the
default (0) is bit-for-bit identical to the previous behavior. Each plateau is
clamped to 90% of the half-day so the ramp never collapses.
This commit is contained in:
Charles-HL 2026-10-18 20:26:35 +00:00
commit 72a7c4875e
13 changed files with 656 additions and 5 deletions

View file

@ -156,6 +156,8 @@ The YAML and frontend configuration methods support all of the options listed be
| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈 | `default` | one of `['default', 'linear', 'tanh']` |
| `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉 | `900` | `int` |
| `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` |
| `color_temp_plateau_morning` | The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️ | `0` | `int` 0-43200 |
| `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 |
| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` |
| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` |
| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` |

View file

@ -41,6 +41,10 @@ _ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))}
_POLAR_SUN_EVENT_OFFSET = timedelta(minutes=30)
_POLAR_SUN_EVENT_EPSILON = timedelta(seconds=1)
# The color temperature plateau is limited to this fraction of the time between
# solar noon and sunrise/sunset, such that the ramp never collapses.
_PLATEAU_MAX_FRACTION = 0.9
utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC)
utcnow.__doc__ = "Get now in UTC time."
@ -243,15 +247,46 @@ class SunEvents:
i_now = bisect.bisect([ts for _, ts in events], dt.timestamp())
return events[i_now - 1 : i_now + 1]
def sun_position(self, dt: datetime.datetime) -> float:
"""Calculate the position of the sun, between [-1, 1]."""
def sun_position(
self,
dt: datetime.datetime,
plateau_morning: datetime.timedelta = datetime.timedelta(),
plateau_evening: datetime.timedelta = datetime.timedelta(),
) -> float:
"""Calculate the position of the sun, between [-1, 1].
With a non-zero `plateau_morning` (`plateau_evening`) the position stays
at its maximum of 1 for that long before (after) solar noon, and the
parabola between sunrise (sunset) and the end of the plateau is
compressed to still start (end) at 0 at sunrise (sunset). This is a
monotonic and continuous remapping of the time axis that only applies
to the part of the day where the sun is up; the position below the
horizon is never changed. With both plateaus zero this is the
unmodified position.
"""
target_ts = dt.timestamp()
(_, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt)
(prev_event, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt)
h, x = (
(prev_ts, next_ts)
if next_event in (SunEvent.SUNSET, SunEvent.SUNRISE)
else (next_ts, prev_ts)
)
plateau = None
if next_event == SunEvent.NOON and prev_event == SunEvent.SUNRISE:
plateau = plateau_morning
elif next_event == SunEvent.SUNSET and prev_event == SunEvent.NOON:
plateau = plateau_evening
if plateau:
# `h` is solar noon and `x` the sunrise or sunset bounding this half
half_day = abs(h - x)
# Always keep part of the half-day for the ramp so that it has a
# non-zero duration, which also prevents dividing by zero.
hold = min(plateau.total_seconds(), _PLATEAU_MAX_FRACTION * half_day)
distance = abs(target_ts - h)
if hold > 0 and half_day > 0:
if distance <= hold:
return 1.0
return 1 - ((distance - hold) / (half_day - hold)) ** 2
# k = -1 between sunset and sunrise (sun below horizon)
# k = 1 between sunrise and sunset (sun above horizon)
k = 1 if next_event in (SunEvent.SUNSET, SunEvent.NOON) else -1
@ -297,6 +332,8 @@ class SunLightSettings:
brightness_mode_time_dark: datetime.timedelta
brightness_mode_time_light: datetime.timedelta
brightness_mode: Literal["default", "linear", "tanh"] = "default"
color_temp_plateau_morning: datetime.timedelta = datetime.timedelta()
color_temp_plateau_evening: datetime.timedelta = datetime.timedelta()
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
@ -417,6 +454,16 @@ class SunLightSettings:
) -> dict[str, Any]:
"""Calculate the brightness and color."""
sun_position = self.sun.sun_position(dt)
# Only the color temperature (not the brightness) has the plateaus.
color_sun_position = (
self.sun.sun_position(
dt,
self.color_temp_plateau_morning,
self.color_temp_plateau_evening,
)
if self.color_temp_plateau_morning or self.color_temp_plateau_evening
else sun_position
)
rgb_color: tuple[int, int, int]
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
force_rgb_color = False
@ -439,10 +486,10 @@ class SunLightSettings:
self.sleep_rgb_color,
sun_position,
)
color_temp_kelvin = self.color_temp_kelvin(sun_position)
color_temp_kelvin = self.color_temp_kelvin(color_sun_position)
force_rgb_color = True
else:
color_temp_kelvin = self.color_temp_kelvin(sun_position)
color_temp_kelvin = self.color_temp_kelvin(color_sun_position)
r, g, b = color_temperature_to_rgb(color_temp_kelvin)
rgb_color = (round(r), round(g), round(b))
# backwards compatibility for versions < 1.3.1 - see #403

View file

@ -206,6 +206,28 @@ DOCS[CONF_BRIGHTNESS_MODE_TIME_LIGHT] = (
"the brightness after/before sunrise/sunset. 📈📉."
)
CONF_COLOR_TEMP_PLATEAU_MORNING, DEFAULT_COLOR_TEMP_PLATEAU_MORNING = (
"color_temp_plateau_morning",
0,
)
DOCS[CONF_COLOR_TEMP_PLATEAU_MORNING] = (
"The duration in seconds before solar noon during which the color temperature "
"stays at `max_color_temp` instead of only reaching it at noon. The ramp from "
"`min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. "
"`0` keeps the default curve. Only affects the morning. 🌤️"
)
CONF_COLOR_TEMP_PLATEAU_EVENING, DEFAULT_COLOR_TEMP_PLATEAU_EVENING = (
"color_temp_plateau_evening",
0,
)
DOCS[CONF_COLOR_TEMP_PLATEAU_EVENING] = (
"The duration in seconds after solar noon during which the color temperature "
"stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. "
"`0` keeps the default curve. Only affects the evening. 🌇"
)
# Each side of the plateau is limited to half a day.
MAX_COLOR_TEMP_PLATEAU = 12 * 60 * 60
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
DOCS[CONF_TAKE_OVER_CONTROL] = (
"Pause adaptation of individual lights and hand over (manual) control to other sources that "
@ -411,6 +433,16 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
),
(CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int),
(CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
(
CONF_COLOR_TEMP_PLATEAU_MORNING,
DEFAULT_COLOR_TEMP_PLATEAU_MORNING,
int_between(0, MAX_COLOR_TEMP_PLATEAU),
),
(
CONF_COLOR_TEMP_PLATEAU_EVENING,
DEFAULT_COLOR_TEMP_PLATEAU_EVENING,
int_between(0, MAX_COLOR_TEMP_PLATEAU),
),
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
(
CONF_TAKE_OVER_CONTROL_MODE,
@ -467,6 +499,11 @@ def timedelta_as_int(value: timedelta) -> float:
return value.total_seconds()
_PLATEAU_PERIOD = vol.All(
cv.time_period,
vol.Range(min=timedelta(0), max=timedelta(seconds=MAX_COLOR_TEMP_PLATEAU)),
)
# conf_option: (validator, coerce) tuples
# these validators cannot be serialized but can be serialized when coerced by coerce.
EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = {
@ -481,6 +518,8 @@ EXTRA_VALIDATION: dict[str, tuple[Any, Any]] = {
CONF_MAX_SUNSET_TIME: (cv.time, str),
CONF_BRIGHTNESS_MODE_TIME_LIGHT: (cv.time_period, timedelta_as_int),
CONF_BRIGHTNESS_MODE_TIME_DARK: (cv.time_period, timedelta_as_int),
CONF_COLOR_TEMP_PLATEAU_MORNING: (_PLATEAU_PERIOD, timedelta_as_int),
CONF_COLOR_TEMP_PLATEAU_EVENING: (_PLATEAU_PERIOD, timedelta_as_int),
}

View file

@ -205,6 +205,24 @@ change_switch_settings:
number:
min: -86400
max: 86300
color_temp_plateau_morning:
description: The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️
required: false
example: ''
selector:
number:
min: 0
max: 43200
unit_of_measurement: s
color_temp_plateau_evening:
description: The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇
required: false
example: ''
selector:
number:
min: 0
max: 43200
unit_of_measurement: s
sunset_time:
description: Set a fixed time (HH:MM:SS) for sunset. 🌇
example: ''

View file

@ -64,6 +64,8 @@
"brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"brightness_mode_time_light": "brightness_mode_time_light",
"color_temp_plateau_morning": "color_temp_plateau_morning",
"color_temp_plateau_evening": "color_temp_plateau_evening",
"take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"take_over_control_mode": "take_over_control_mode",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
@ -97,6 +99,8 @@
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"color_temp_plateau_morning": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️",
"color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
@ -252,6 +256,14 @@
"description": "Adjust sunset time with a positive or negative offset in seconds. ⏰",
"name": "sunset_offset"
},
"color_temp_plateau_morning": {
"description": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️",
"name": "color_temp_plateau_morning"
},
"color_temp_plateau_evening": {
"description": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"name": "color_temp_plateau_evening"
},
"sunset_time": {
"description": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
"name": "sunset_time"

View file

@ -100,6 +100,8 @@ from .const import (
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
CONF_COLOR_TEMP_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_DETECT_NON_HA_CHANGES,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
@ -1037,6 +1039,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
brightness_mode=data[CONF_BRIGHTNESS_MODE],
brightness_mode_time_dark=data[CONF_BRIGHTNESS_MODE_TIME_DARK],
brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT],
color_temp_plateau_morning=data[CONF_COLOR_TEMP_PLATEAU_MORNING],
color_temp_plateau_evening=data[CONF_COLOR_TEMP_PLATEAU_EVENING],
timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone),
)
_LOGGER.debug(

View file

@ -65,6 +65,8 @@
"brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"brightness_mode_time_light": "brightness_mode_time_light",
"color_temp_plateau_morning": "color_temp_plateau_morning",
"color_temp_plateau_evening": "color_temp_plateau_evening",
"take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"take_over_control_mode": "take_over_control_mode",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
@ -98,6 +100,8 @@
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"color_temp_plateau_morning": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️",
"color_temp_plateau_evening": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
@ -253,6 +257,14 @@
"description": "Adjust sunset time with a positive or negative offset in seconds. ⏰",
"name": "sunset_offset"
},
"color_temp_plateau_morning": {
"description": "The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️",
"name": "color_temp_plateau_morning"
},
"color_temp_plateau_evening": {
"description": "The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇",
"name": "color_temp_plateau_evening"
},
"sunset_time": {
"description": "Set a fixed time (HH:MM:SS) for sunset. 🌇",
"name": "sunset_time"

View file

@ -0,0 +1,36 @@
---
icon: lucide/thermometer-sun
---
# Color Temperature Plateau
By default the color temperature follows the sun position: it rises from `min_color_temp` at sunrise to `max_color_temp` at solar noon, and falls back to `min_color_temp` at sunset. The light therefore only *touches* `max_color_temp` at a single instant.
`color_temp_plateau_morning` and `color_temp_plateau_evening` hold `max_color_temp` for a while around solar noon, while still ramping smoothly from and to `min_color_temp` at sunrise and sunset. Both are independent, in seconds, and default to `0` (the unchanged default curve).
| Option | Default | Description |
|--------|---------|-------------|
| `color_temp_plateau_morning` | `0` | Seconds *before* solar noon that the color temperature already stays at `max_color_temp` |
| `color_temp_plateau_evening` | `0` | Seconds *after* solar noon that the color temperature stays at `max_color_temp` |
## How it works
The ramp is the same curve as without a plateau, but compressed into the time between sunrise (or sunset) and the start (or end) of the plateau. The color temperature is therefore continuous, has no jump at the edge of the plateau, and still equals `min_color_temp` exactly at the (offset) sunrise and sunset.
- A plateau is measured from the same "noon" as the rest of the integration: solar noon, or the middle between sunrise and sunset when `sunrise_time`/`sunset_time` (or their min/max variants) are set. Sunrise and sunset offsets move the far end of the ramp; without fixed times, the anchor stays at solar noon.
- Each plateau is limited to 12 hours (43200 seconds) and to 90% of the time between noon and sunrise/sunset, so that a ramp of at least 10% of that time always remains. Larger values are clamped, never an error at runtime.
- Only the color temperature is affected. The brightness, the sleep mode, and the color after sunset (including `adapt_until_sleep`) are unchanged.
## Example
```yaml
adaptive_lighting:
- name: kitchen
min_color_temp: 2700
max_color_temp: 4000
sunset_offset: 7200 # 2 hours
color_temp_plateau_morning: 3600 # at 4000 K from 1 hour before noon
color_temp_plateau_evening: 14400 # stays at 4000 K until 4 hours after noon
```
The same options can be changed at runtime with `adaptive_lighting.change_switch_settings`.

View file

@ -60,6 +60,8 @@ All configuration options are listed below with their default values. These opti
| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈 | `default` | one of `['default', 'linear', 'tanh']` |
| `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉 | `900` | `int` |
| `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` |
| `color_temp_plateau_morning` | The duration in seconds before solar noon during which the color temperature stays at `max_color_temp` instead of only reaching it at noon. The ramp from `min_color_temp` at sunrise to `max_color_temp` is compressed accordingly. `0` keeps the default curve. Only affects the morning. 🌤️ | `0` | `int` 0-43200 |
| `color_temp_plateau_evening` | The duration in seconds after solar noon during which the color temperature stays at `max_color_temp` before ramping down to `min_color_temp` at sunset. `0` keeps the default curve. Only affects the evening. 🌇 | `0` | `int` 0-43200 |
| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` |
| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` |
| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` |

View file

@ -547,3 +547,409 @@ def test_brightness_and_color_on_polar_days(date):
at_midnight = settings.brightness_and_color(midnight, is_sleep=False)
assert at_midnight["brightness_pct"] == 30
assert at_midnight["color_temp_kelvin"] == 2000
# --- Color temperature plateau --------------------------------------------
PLATEAU_DAY = dt.date(2022, 6, 1)
H = dt.timedelta(hours=1)
def _plateau_settings(
morning=dt.timedelta(),
evening=dt.timedelta(),
*,
sunset_offset=dt.timedelta(),
sunrise_offset=dt.timedelta(),
adapt_until_sleep=False,
brightness_mode="default",
sleep_rgb_or_color_temp="color_temp",
min_color_temp=2700,
max_color_temp=4000,
):
"""Settings with fixed sunrise 06:00 and sunset 18:00 UTC (noon is 12:00)."""
return SunLightSettings(
name="test",
astral_observer=location.observer,
adapt_until_sleep=adapt_until_sleep,
max_brightness=100,
max_color_temp=max_color_temp,
min_brightness=30,
min_color_temp=min_color_temp,
sleep_brightness=1,
sleep_rgb_or_color_temp=sleep_rgb_or_color_temp,
sleep_color_temp=1000,
sleep_rgb_color=(255, 56, 0),
sunrise_time=dt.time(6, 0),
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=dt.time(18, 0),
min_sunset_time=None,
max_sunset_time=None,
brightness_mode_time_dark=dt.timedelta(minutes=30),
brightness_mode_time_light=dt.timedelta(minutes=30),
brightness_mode=brightness_mode,
sunrise_offset=sunrise_offset,
sunset_offset=sunset_offset,
color_temp_plateau_morning=morning,
color_temp_plateau_evening=evening,
timezone=dt.UTC,
)
def _at(hour, minute=0, second=0):
return dt.datetime.combine(PLATEAU_DAY, dt.time(hour, minute, second), dt.UTC)
def _kelvin(settings, when, is_sleep=False):
return settings.brightness_and_color(when, is_sleep)["color_temp_kelvin"]
def _day_grid(step_minutes=7):
start = _at(0)
return [start + dt.timedelta(minutes=m) for m in range(0, 24 * 60, step_minutes)]
def _reference_sun_position(sun, when):
"""The original (pre-plateau) formula, kept here as the reference."""
target_ts = when.timestamp()
(_, prev_ts), (next_event, next_ts) = sun.prev_and_next_events(when)
h, x = (
(prev_ts, next_ts)
if next_event in (SunEvent.SUNSET, SunEvent.SUNRISE)
else (next_ts, prev_ts)
)
k = 1 if next_event in (SunEvent.SUNSET, SunEvent.NOON) else -1
return k * (1 - ((target_ts - h) / (h - x)) ** 2)
@pytest.mark.parametrize("sunset_offset", [dt.timedelta(), 2 * H, -2 * H])
@pytest.mark.parametrize("adapt_until_sleep", [True, False])
def test_plateau_defaults_are_bit_for_bit_identical(sunset_offset, adapt_until_sleep):
default = _plateau_settings(
sunset_offset=sunset_offset,
adapt_until_sleep=adapt_until_sleep,
)
explicit_zero = _plateau_settings(
dt.timedelta(0),
dt.timedelta(seconds=0),
sunset_offset=sunset_offset,
adapt_until_sleep=adapt_until_sleep,
)
for when in _day_grid():
reference = _reference_sun_position(default.sun, when)
assert default.sun.sun_position(when) == reference
assert default.sun.sun_position(when, dt.timedelta(), dt.timedelta()) == (
reference
)
for is_sleep in (False, True):
assert default.brightness_and_color(
when,
is_sleep,
) == explicit_zero.brightness_and_color(when, is_sleep)
assert default.color_temp_kelvin(reference) == _kelvin(default, when)
def test_plateau_morning_shape():
settings = _plateau_settings(morning=3 * H)
assert _kelvin(settings, _at(6)) == 2700 # sunrise: still the minimum
assert _kelvin(settings, _at(9)) == 4000 # plateau starts 3h before noon
assert _kelvin(settings, _at(9, 0, 1)) == 4000
assert _kelvin(settings, _at(10, 30)) == 4000
assert _kelvin(settings, _at(12)) == 4000
# the ramp is compressed: reaching 4000 earlier than without plateau
default = _plateau_settings()
assert _kelvin(settings, _at(8)) > _kelvin(default, _at(8))
# and is monotonic
values = [_kelvin(settings, _at(6) + dt.timedelta(minutes=m)) for m in range(361)]
assert values == sorted(values)
def test_plateau_evening_shape():
settings = _plateau_settings(evening=4 * H)
assert _kelvin(settings, _at(12)) == 4000
assert _kelvin(settings, _at(16)) == 4000 # plateau ends 4h after noon
assert _kelvin(settings, _at(16, 0, 1)) >= 3995
assert _kelvin(settings, _at(18)) == 2700 # sunset: the minimum again
default = _plateau_settings()
assert _kelvin(settings, _at(15)) > _kelvin(default, _at(15))
values = [_kelvin(settings, _at(12) + dt.timedelta(minutes=m)) for m in range(361)]
assert values == sorted(values, reverse=True)
def test_plateau_sides_are_independent():
default = _plateau_settings()
morning = _plateau_settings(morning=3 * H)
evening = _plateau_settings(evening=3 * H)
both = _plateau_settings(morning=3 * H, evening=3 * H)
noon = _at(12)
for when in _day_grid(5):
before_noon = when < noon
after_noon = when > noon
if after_noon:
# a morning plateau never changes the evening
assert morning.brightness_and_color(
when,
False,
) == default.brightness_and_color(when, False)
assert both.brightness_and_color(when, False) == (
evening.brightness_and_color(when, False)
)
if before_noon:
# an evening plateau never changes the morning
assert evening.brightness_and_color(
when,
False,
) == default.brightness_and_color(when, False)
assert both.brightness_and_color(when, False) == (
morning.brightness_and_color(when, False)
)
def test_plateau_is_continuous_at_its_boundaries():
settings = _plateau_settings(morning=2 * H, evening=2 * H)
eps = dt.timedelta(milliseconds=1)
for boundary in (_at(10), _at(14)):
before = settings.sun.sun_position(boundary - eps, 2 * H, 2 * H)
after = settings.sun.sun_position(boundary + eps, 2 * H, 2 * H)
assert abs(before - after) < 1e-6
assert max(before, after) == pytest.approx(1, abs=1e-6)
# sunrise and sunset still map to exactly 0
for event in (_at(6), _at(18)):
assert settings.sun.sun_position(event, 2 * H, 2 * H) == pytest.approx(
0,
abs=1e-9,
)
def test_plateau_never_decreases_color_temp():
default = _plateau_settings()
plateau = _plateau_settings(morning=5 * H, evening=3 * H)
for when in _day_grid(3):
assert _kelvin(plateau, when) >= _kelvin(default, when)
@pytest.mark.parametrize("plateau", [dt.timedelta(hours=12), dt.timedelta(days=3)])
def test_plateau_larger_than_the_half_day_is_clamped(plateau):
settings = _plateau_settings(plateau, plateau)
sun = settings.sun
assert _kelvin(settings, _at(6)) == 2700
assert _kelvin(settings, _at(18)) == 2700
assert _kelvin(settings, _at(12)) == 4000
previous = None
for when in _day_grid(1):
position = sun.sun_position(when, plateau, plateau)
assert -1 <= position <= 1
if previous is not None and _at(6) <= when <= _at(18):
# no jump bigger than what a smooth ramp over 10% of the half-day
# (36 minutes) allows between consecutive minutes
assert abs(position - previous) < 0.1
previous = position
# a ramp of 10% of the half-day (36 minutes) remains at both ends
assert _kelvin(settings, _at(17, 59)) < 4000
assert _kelvin(settings, _at(17, 30)) < 4000
assert _kelvin(settings, _at(6, 30)) < 4000
def test_plateau_with_a_collapsed_day():
# sunrise and sunset only a few seconds apart around noon
settings = SunLightSettings(
name="test",
astral_observer=location.observer,
adapt_until_sleep=False,
max_brightness=100,
max_color_temp=4000,
min_brightness=30,
min_color_temp=2700,
sleep_brightness=1,
sleep_rgb_or_color_temp="color_temp",
sleep_color_temp=1000,
sleep_rgb_color=(255, 56, 0),
sunrise_time=dt.time(11, 59, 58),
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=dt.time(12, 0, 2),
min_sunset_time=None,
max_sunset_time=None,
brightness_mode_time_dark=dt.timedelta(minutes=30),
brightness_mode_time_light=dt.timedelta(minutes=30),
color_temp_plateau_morning=12 * H,
color_temp_plateau_evening=12 * H,
timezone=dt.UTC,
)
for seconds in range(-5, 6):
when = _at(12) + dt.timedelta(seconds=seconds)
assert 2700 <= _kelvin(settings, when) <= 4000
assert _kelvin(settings, _at(12)) == 4000
assert _kelvin(settings, _at(3)) == 2700
def test_plateau_does_not_touch_the_night_or_sleep():
default = _plateau_settings(adapt_until_sleep=True)
plateau = _plateau_settings(5 * H, 5 * H, adapt_until_sleep=True)
for when in _day_grid(11):
if when < _at(6) or when > _at(18):
# below the horizon (including the transition to the sleep color)
assert plateau.brightness_and_color(when, False) == (
default.brightness_and_color(when, False)
)
# sleep mode ignores the color temperature curve entirely
sleep = plateau.brightness_and_color(when, True)
assert sleep == default.brightness_and_color(when, True)
assert sleep["color_temp_kelvin"] == 1000
assert sleep["brightness_pct"] == 1
def test_plateau_with_rgb_sleep_color_after_sunset():
default = _plateau_settings(
adapt_until_sleep=True,
sleep_rgb_or_color_temp="rgb_color",
)
plateau = _plateau_settings(
4 * H,
4 * H,
adapt_until_sleep=True,
sleep_rgb_or_color_temp="rgb_color",
)
for when in (_at(19), _at(22), _at(1), _at(5, 30)):
assert plateau.brightness_and_color(when, False) == (
default.brightness_and_color(when, False)
)
@pytest.mark.parametrize("brightness_mode", ["default", "linear", "tanh"])
def test_plateau_does_not_change_brightness(brightness_mode):
default = _plateau_settings(brightness_mode=brightness_mode)
plateau = _plateau_settings(
5 * H,
5 * H,
brightness_mode=brightness_mode,
adapt_until_sleep=True,
)
reference = _plateau_settings(
brightness_mode=brightness_mode,
adapt_until_sleep=True,
)
for when in _day_grid(9):
for is_sleep in (False, True):
assert plateau.brightness_pct(when, is_sleep) == (
reference.brightness_pct(when, is_sleep)
)
result = plateau.brightness_and_color(when, is_sleep)
ref = reference.brightness_and_color(when, is_sleep)
assert result["brightness_pct"] == ref["brightness_pct"]
# the (brightness-driving) sun position itself is left untouched
assert result["sun_position"] == ref["sun_position"]
assert default.brightness_pct(when, False) == reference.brightness_pct(
when,
False,
)
def test_plateau_with_sunset_offset_is_anchored_on_noon():
# sunrise 06:00 and sunset 18:00 + 2h offset = 20:00, so the (adjusted) noon,
# the middle of the two, is 13:00 and the evening ramp spans 13:00 -> 20:00
settings = _plateau_settings(evening=4 * H, sunset_offset=2 * H)
default = _plateau_settings(sunset_offset=2 * H)
assert _kelvin(settings, _at(11)) == _kelvin(default, _at(11))
assert _kelvin(settings, _at(13)) == 4000
assert _kelvin(settings, _at(17)) == 4000 # plateau ends 4h after noon
assert _kelvin(settings, _at(18)) < 4000
assert _kelvin(settings, _at(18)) > _kelvin(default, _at(18))
assert _kelvin(settings, _at(20)) == 2700 # the offset sunset is respected
# the morning is not affected by the sunset offset nor the other way around
morning = _plateau_settings(morning=2 * H, sunset_offset=2 * H)
assert _kelvin(morning, _at(11)) == 4000
assert _kelvin(morning, _at(8)) > _kelvin(default, _at(8))
assert _kelvin(morning, _at(14)) == _kelvin(default, _at(14))
def test_plateau_with_sunrise_and_sunset_times_and_limits():
settings = _plateau_settings(morning=2 * H, evening=2 * H, sunrise_offset=-1 * H)
# sunrise is 05:00 and sunset 18:00: noon is their midpoint (11:30)
sunrise = settings.sun.sunrise(PLATEAU_DAY)
sunset = settings.sun.sunset(PLATEAU_DAY)
noon, _ = settings.sun.noon_and_midnight(_at(12))
assert noon == sunrise + (sunset - sunrise) / 2
assert _kelvin(settings, noon) == 4000
assert _kelvin(settings, noon - 2 * H) == 4000
assert _kelvin(settings, noon + 2 * H) == 4000
assert _kelvin(settings, sunrise) == 2700
assert _kelvin(settings, sunset) == 2700
def test_plateau_astral_sunrise_and_sunset(tzinfo_and_location):
tzinfo, loc = tzinfo_and_location
kwargs = {
"name": "test",
"astral_observer": loc.observer,
"adapt_until_sleep": False,
"max_brightness": 100,
"max_color_temp": 4000,
"min_brightness": 30,
"min_color_temp": 2700,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": (255, 56, 0),
"sunrise_time": None,
"min_sunrise_time": None,
"max_sunrise_time": None,
"sunset_time": None,
"min_sunset_time": None,
"max_sunset_time": None,
"brightness_mode_time_dark": dt.timedelta(minutes=30),
"brightness_mode_time_light": dt.timedelta(minutes=30),
"sunset_offset": 2 * H,
"timezone": tzinfo,
}
default = SunLightSettings(**kwargs)
plateau = SunLightSettings(
**kwargs,
color_temp_plateau_morning=2 * H,
color_temp_plateau_evening=4 * H,
)
day = dt.datetime(2022, 3, 20, tzinfo=dt.UTC)
noon, _ = default.sun.noon_and_midnight(day)
assert _kelvin(plateau, noon) == 4000
assert _kelvin(plateau, noon - 2 * H) == 4000
assert _kelvin(plateau, noon + 4 * H) == 4000
assert _kelvin(plateau, default.sun.sunset(day.date())) == 2700
assert _kelvin(plateau, default.sun.sunrise(day.date())) == 2700
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
def test_plateau_on_polar_days(date):
kwargs = {
"name": "test",
"astral_observer": TROMSO.observer,
"adapt_until_sleep": False,
"max_brightness": 100,
"max_color_temp": 4000,
"min_brightness": 30,
"min_color_temp": 2700,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "color_temp",
"sleep_color_temp": 1000,
"sleep_rgb_color": (255, 56, 0),
"sunrise_time": None,
"min_sunrise_time": None,
"max_sunrise_time": None,
"sunset_time": None,
"min_sunset_time": None,
"max_sunset_time": None,
"brightness_mode_time_dark": dt.timedelta(hours=1),
"brightness_mode_time_light": dt.timedelta(hours=1),
"timezone": zoneinfo.ZoneInfo("Europe/Oslo"),
}
plateau = SunLightSettings(
**kwargs,
color_temp_plateau_morning=12 * H,
color_temp_plateau_evening=12 * H,
)
start = dt.datetime(date.year, date.month, date.day, tzinfo=dt.UTC)
noon, _ = plateau.sun.noon_and_midnight(start)
assert _kelvin(plateau, noon) == 4000
for minutes in range(0, 24 * 60, 5):
when = start + dt.timedelta(minutes=minutes)
assert 2700 <= _kelvin(plateau, when) <= 4000

View file

@ -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_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
@ -108,6 +110,8 @@ async def test_options(hass):
advanced_data = ADVANCED_DATA.copy()
advanced_data[CONF_INITIAL_TRANSITION] = 23
advanced_data[CONF_EXPAND_LIGHT_GROUPS] = False
advanced_data[CONF_COLOR_TEMP_PLATEAU_MORNING] = 1800
advanced_data[CONF_COLOR_TEMP_PLATEAU_EVENING] = 10800
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
advanced_data[CONF_SUNSET_TIME] = NONE_STR
basic_data = {**BASIC_DATA, "min_brightness": 12}

View file

@ -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_PLATEAU_EVENING,
CONF_COLOR_TEMP_PLATEAU_MORNING,
CONF_DETECT_NON_HA_CHANGES,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
@ -2904,6 +2906,72 @@ async def test_change_switch_settings_service(hass):
assert switch._sun_light_settings.min_color_temp == 2500
async def test_color_temp_plateau_defaults_and_service(hass):
"""The plateau options default to 0 and can be changed at runtime."""
switch, _ = await setup_lights_and_switch(hass)
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(0)
assert settings.color_temp_plateau_evening == datetime.timedelta(0)
async def change_switch_settings(**kwargs):
await hass.services.async_call(
DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
{ATTR_ENTITY_ID: switch.entity_id, **kwargs},
blocking=True,
)
await hass.async_block_till_done()
await change_switch_settings(**{CONF_COLOR_TEMP_PLATEAU_EVENING: 7200})
settings = switch._sun_light_settings
assert settings.color_temp_plateau_evening == datetime.timedelta(hours=2)
# the other side is untouched
assert settings.color_temp_plateau_morning == datetime.timedelta(0)
await change_switch_settings(**{CONF_COLOR_TEMP_PLATEAU_MORNING: 3600})
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(hours=1)
assert settings.color_temp_plateau_evening == datetime.timedelta(hours=2)
for key in (CONF_COLOR_TEMP_PLATEAU_MORNING, CONF_COLOR_TEMP_PLATEAU_EVENING):
for invalid in (-1, 43201):
with pytest.raises(voluptuous.error.MultipleInvalid):
await change_switch_settings(**{key: invalid})
await change_switch_settings(**{CONF_USE_DEFAULTS: "factory"})
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(0)
assert settings.color_temp_plateau_evening == datetime.timedelta(0)
async def test_color_temp_plateau_from_configuration(hass):
"""The options from the config entry reach the sun light settings."""
_, switch = await setup_switch(
hass,
{
CONF_COLOR_TEMP_PLATEAU_MORNING: 1800,
CONF_COLOR_TEMP_PLATEAU_EVENING: datetime.timedelta(hours=3),
},
)
settings = switch._sun_light_settings
assert settings.color_temp_plateau_morning == datetime.timedelta(minutes=30)
assert settings.color_temp_plateau_evening == datetime.timedelta(hours=3)
@pytest.mark.parametrize("invalid", [-1, 43201])
@pytest.mark.parametrize(
"key",
[CONF_COLOR_TEMP_PLATEAU_MORNING, CONF_COLOR_TEMP_PLATEAU_EVENING],
)
def test_color_temp_plateau_yaml_validation(invalid, key):
"""Out of range plateaus are rejected in the YAML schema."""
from homeassistant.components.adaptive_lighting.const import _DOMAIN_SCHEMA
assert _DOMAIN_SCHEMA({key: 3600})[key] == 3600
with pytest.raises(voluptuous.error.MultipleInvalid):
_DOMAIN_SCHEMA({key: invalid})
@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."""

View file

@ -21,6 +21,7 @@ nav = [
{ "Troubleshooting" = "troubleshooting.md" },
{ "Advanced" = [
{ "Brightness Modes" = "advanced/brightness-modes.md" },
{ "Color Temperature Plateau" = "advanced/color-temperature-plateau.md" },
{ "Manual Control" = "advanced/manual-control.md" },
{ "Sleep Mode" = "advanced/sleep-mode.md" },
] },