This commit is contained in:
Andrzej Stecyk 2026-10-19 17:28:55 +02:00 • committed by GitHub
commit 87d30e7e54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 411 additions and 8 deletions

View file

@ -145,6 +145,8 @@ The YAML and frontend configuration methods support all of the options listed be
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |
| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 |
| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` |
| `transition_until_sleep_ct_mode` | How color-temperature-only lights transition after sunset when `transition_until_sleep` is enabled and `sleep_rgb_or_color_temp` is `rgb_color`. `linear`: interpolate `min_color_temp` → `sleep_color_temp` in Kelvin. `match_rgb`: use the color temperature of the RGB blend that RGB lights show at the same moment, so mixed setups look uniform. 🌡️ | `linear` | one of `['linear', 'match_rgb']` |
| `transition_until_sleep_ct_curve` | Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️ | `1.0` | `float` 0.1-10 |
| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` |
| `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` |
| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` |

View file

@ -297,6 +297,8 @@ class SunLightSettings:
brightness_mode_time_dark: datetime.timedelta
brightness_mode_time_light: datetime.timedelta
brightness_mode: Literal["default", "linear", "tanh"] = "default"
adapt_until_sleep_ct_mode: Literal["linear", "match_rgb"] = "linear"
adapt_until_sleep_ct_curve: float = 1.0
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
@ -410,6 +412,47 @@ class SunLightSettings:
msg = "Should not happen"
raise ValueError(msg)
def _color_temp_kelvin_matching_rgb(
self,
rgb_color: tuple[int, int, int],
xy_color: tuple[float, float],
linear_color_temp_kelvin: int,
) -> int:
"""Color temperature for color-temp-only lights following the RGB blend.
Keeps color-temp-only lights on the same perceived trajectory as the
RGB blend after sunset: the CCT is derived from the blended color
instead of interpolating in kelvin space, which diverges perceptually
from the HSV interpolation used for `rgb_color` (see #624).
"""
if not any(self.sleep_rgb_color) or not any(rgb_color):
# Black has no chromaticity, so there is no color temperature to
# match; fall back to the linear interpolation, which ends at
# `sleep_color_temp` when the blend reaches black.
return linear_color_temp_kelvin
mired_min = 1e6 / self.min_color_temp
mired_sleep = 1e6 / self.sleep_color_temp
mired = 1e6 / color_xy_to_cct(*xy_color)
# The kelvin -> RGB -> CCT round trip is approximate, so the CCT of the
# RGB color shown right after sunset is not exactly `min_color_temp`.
# Anchor the start of the trajectory to that CCT, so color-temp-only
# lights keep `min_color_temp` at sunset instead of stepping to a
# slightly different value.
r, g, b = color_temperature_to_rgb(self.min_color_temp)
min_rgb_xy = color_RGB_to_xy(round(r), round(g), round(b))
mired_start = 1e6 / color_xy_to_cct(*min_rgb_xy)
if mired_sleep <= mired_start:
# Degenerate configuration (sleep_color_temp >= min_color_temp).
cct = clamp(1e6 / mired, self.sleep_color_temp, self.min_color_temp)
return 5 * round(cct / 5)
progress = clamp((mired - mired_start) / (mired_sleep - mired_start), 0, 1)
# Optional calibration: reshape the progress towards
# `sleep_color_temp` in mired space (perceptually uniform), so that
# `>1` reaches warm tones earlier and `<1` later.
progress **= 1 / self.adapt_until_sleep_ct_curve
cct = 1e6 / (mired_min + progress * (mired_sleep - mired_min))
return 5 * round(cct / 5)
def brightness_and_color(
self,
dt: datetime.datetime,
@ -445,9 +488,15 @@ class SunLightSettings:
color_temp_kelvin = self.color_temp_kelvin(sun_position)
r, g, b = color_temperature_to_rgb(color_temp_kelvin)
rgb_color = (round(r), round(g), round(b))
xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color)
if force_rgb_color and self.adapt_until_sleep_ct_mode == "match_rgb":
color_temp_kelvin = self._color_temp_kelvin_matching_rgb(
rgb_color,
xy_color,
color_temp_kelvin,
)
# backwards compatibility for versions < 1.3.1 - see #403
color_temp_mired: float = math.floor(1000000 / color_temp_kelvin)
xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color)
hs_color: tuple[float, float] = color_xy_to_hs(*xy_color)
return {
"brightness_pct": brightness_pct,
@ -594,6 +643,41 @@ def lerp_color_hsv(
return cast("tuple[int, int, int]", rgb)
def color_xy_to_cct(x: float, y: float) -> float:
"""Return the correlated color temperature (CCT) in Kelvin of a CIE 1931 xy.
The CCT is found as the color temperature whose RGB representation (via
`color_temperature_to_rgb`, i.e. the same conversion this component uses
to turn a color temperature into an RGB color) is closest to the given
chromaticity in the CIE 1960 uv plane, which is how CCT is defined.
This stays accurate for saturated colors far below the Planckian locus,
where polynomial approximations like McCamy's break down.
"""
def _uv(x: float, y: float) -> tuple[float, float]:
d = -2 * x + 12 * y + 3
return 4 * x / d, 6 * y / d
u0, v0 = _uv(x, y)
def _dist_sq(kelvin: float) -> float:
xk, yk = color_RGB_to_xy(*color_temperature_to_rgb(kelvin))
uk, vk = _uv(xk, yk)
return (u0 - uk) ** 2 + (v0 - vk) ** 2
# Ternary search in mired space (perceptually uniform). 1000-13000 K covers
# every configurable color temperature with a wide margin.
lo_mired, hi_mired = 1e6 / 13000, 1e6 / 1000
for _ in range(40):
m1 = lo_mired + (hi_mired - lo_mired) / 3
m2 = hi_mired - (hi_mired - lo_mired) / 3
if _dist_sq(1e6 / m1) < _dist_sq(1e6 / m2):
hi_mired = m2
else:
lo_mired = m1
return 1e6 / ((lo_mired + hi_mired) / 2)
def lerp(x: float, x1: float, x2: float, y1: float, y2: float) -> float:
"""Linearly interpolate between two values."""
return y1 + (x - x1) * (y2 - y1) / (x2 - x1)

View file

@ -58,8 +58,7 @@ DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = (
CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1
DOCS[CONF_INITIAL_TRANSITION] = (
"Duration of the first transition when lights turn "
"from `off` to `on` in seconds. ⏲️"
"Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️"
)
CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1
@ -162,8 +161,7 @@ DOCS[CONF_MIN_SUNRISE_TIME] = (
CONF_MAX_SUNRISE_TIME = "max_sunrise_time"
DOCS[CONF_MAX_SUNRISE_TIME] = (
"Set the latest virtual sunrise time (HH:MM:SS), allowing"
" for earlier sunrises. 🌅"
"Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅"
)
CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0
@ -235,6 +233,30 @@ DOCS[CONF_ADAPT_UNTIL_SLEEP] = (
"transitioning to these values after sunset. 🌙"
)
CONF_ADAPT_UNTIL_SLEEP_CT_MODE, DEFAULT_ADAPT_UNTIL_SLEEP_CT_MODE = (
"transition_until_sleep_ct_mode",
"linear",
)
DOCS[CONF_ADAPT_UNTIL_SLEEP_CT_MODE] = (
"How color-temperature-only lights transition after sunset when "
"`transition_until_sleep` is enabled and `sleep_rgb_or_color_temp` is "
"`rgb_color`. `linear`: interpolate `min_color_temp` → `sleep_color_temp` "
"in Kelvin. `match_rgb`: use the color temperature of the RGB blend that "
"RGB lights show at the same moment, so mixed setups look uniform. 🌡️"
)
CONF_ADAPT_UNTIL_SLEEP_CT_CURVE, DEFAULT_ADAPT_UNTIL_SLEEP_CT_CURVE = (
"transition_until_sleep_ct_curve",
1.0,
)
DOCS[CONF_ADAPT_UNTIL_SLEEP_CT_CURVE] = (
"Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how "
"fast color-temperature-only lights warm up towards `sleep_color_temp` "
"compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up "
"earlier (use if your color-temp lights still look colder than your RGB "
"lights), `<1` later. 🎛️"
)
CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0
DOCS[CONF_ADAPT_DELAY] = (
"Wait time (seconds) between light turn on and Adaptive Lighting applying "
@ -390,6 +412,22 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
),
(CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION),
(CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool),
(
CONF_ADAPT_UNTIL_SLEEP_CT_MODE,
DEFAULT_ADAPT_UNTIL_SLEEP_CT_MODE,
selector.SelectSelector( # type: ignore[arg-type]
selector.SelectSelectorConfig(
options=["linear", "match_rgb"],
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
),
(
CONF_ADAPT_UNTIL_SLEEP_CT_CURVE,
DEFAULT_ADAPT_UNTIL_SLEEP_CT_CURVE,
vol.All(vol.Coerce(float), vol.Range(min=0.1, max=10)),
),
(CONF_SUNRISE_TIME, NONE_STR, str),
(CONF_MIN_SUNRISE_TIME, NONE_STR, str),
(CONF_MAX_SUNRISE_TIME, NONE_STR, str),

View file

@ -53,6 +53,8 @@
"sleep_rgb_color": "sleep_rgb_color",
"sleep_transition": "sleep_transition",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"transition_until_sleep_ct_mode": "transition_until_sleep_ct_mode",
"transition_until_sleep_ct_curve": "transition_until_sleep_ct_curve",
"sunrise_time": "sunrise_time",
"min_sunrise_time": "min_sunrise_time",
"max_sunrise_time": "max_sunrise_time",
@ -86,6 +88,7 @@
"sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition_until_sleep_ct_curve": "Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️",
"sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅",
"max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅",
@ -100,7 +103,8 @@
"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. ⏲️",
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️"
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
"transition_until_sleep_ct_mode": "How color-temperature-only lights transition after sunset when `transition_until_sleep` is enabled and `sleep_rgb_or_color_temp` is `rgb_color`. `linear`: interpolate `min_color_temp` → `sleep_color_temp` in Kelvin. `match_rgb`: use the color temperature of the RGB blend that RGB lights show at the same moment, so mixed setups look uniform. 🌡️"
}
}
}

View file

@ -96,6 +96,8 @@ from .const import (
CONF_ADAPT_DELAY,
CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
CONF_ADAPT_UNTIL_SLEEP,
CONF_ADAPT_UNTIL_SLEEP_CT_CURVE,
CONF_ADAPT_UNTIL_SLEEP_CT_MODE,
CONF_AUTORESET_CONTROL,
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
@ -1018,6 +1020,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
name=self._name,
astral_observer=observer,
adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP],
adapt_until_sleep_ct_curve=data[CONF_ADAPT_UNTIL_SLEEP_CT_CURVE],
adapt_until_sleep_ct_mode=data[CONF_ADAPT_UNTIL_SLEEP_CT_MODE],
max_brightness=data[CONF_MAX_BRIGHTNESS],
max_color_temp=data[CONF_MAX_COLOR_TEMP],
min_brightness=data[CONF_MIN_BRIGHTNESS],

View file

@ -54,6 +54,8 @@
"sleep_rgb_color": "sleep_rgb_color",
"sleep_transition": "sleep_transition",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"transition_until_sleep_ct_mode": "transition_until_sleep_ct_mode",
"transition_until_sleep_ct_curve": "transition_until_sleep_ct_curve",
"sunrise_time": "sunrise_time",
"min_sunrise_time": "min_sunrise_time",
"max_sunrise_time": "max_sunrise_time",
@ -87,6 +89,7 @@
"sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition_until_sleep_ct_curve": "Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️",
"sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅",
"max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅",
@ -101,7 +104,8 @@
"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. ⏲️",
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️"
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
"transition_until_sleep_ct_mode": "How color-temperature-only lights transition after sunset when `transition_until_sleep` is enabled and `sleep_rgb_or_color_temp` is `rgb_color`. `linear`: interpolate `min_color_temp` → `sleep_color_temp` in Kelvin. `match_rgb`: use the color temperature of the RGB blend that RGB lights show at the same moment, so mixed setups look uniform. 🌡️"
}
}
}

View file

@ -43,6 +43,8 @@
"prefer_rgb_color": "prefer_rgb_color: Czy w miarę możliwości preferować regulację kolorów RGB zamiast regulacji temperatury barwowej światła. 🌈",
"sleep_transition": "sleep_transition: When 'sleep_state' changes. (sekund)",
"transition_until_sleep": "transition_until_sleep: Gdy włączone, Adaptacyjne oświetlenie będzie traktowało ustawienia spania jako minimalne i przejdzie do nich po zachodzie słońca. 🌙",
"transition_until_sleep_ct_mode": "transition_until_sleep_ct_mode",
"transition_until_sleep_ct_curve": "transition_until_sleep_ct_curve",
"sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
"sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- sekund)",
"sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
@ -62,6 +64,7 @@
"sleep_rgb_or_color_temp": "Użyj `\"rgb_color\"` albo `\"color_temp\"` w trybie spania. 🌙",
"sleep_rgb_color": "Kolor RGB w trybie spania (używane, gdy `sleep_rgb_or_color_temp` jest `rgb_color`). 🌈",
"sleep_transition": "Długość przejścia, gdy nastąpi włączenie/wyłączenie \"trybu spania\" (w sekundach). 😴",
"transition_until_sleep_ct_curve": "Używane tylko z `transition_until_sleep_ct_mode: match_rgb`. Kalibruje, jak szybko żarówki CT ocieplają się w stronę `sleep_color_temp` względem CCT mieszanki RGB: `1` podąża za CCT, `>1` ociepla wcześniej (użyj, gdy żarówki CT nadal wyglądają chłodniej niż RGB), `<1` później. 🎛️",
"sunrise_time": "Ustaw stały czas wschodu słońca (HH:MM:SS). 🌅",
"min_sunrise_time": "Ustaw czas najwcześniejszego wirtualnego wschodu słońca (HH:MM:SS), pozwala na opóźnienie wschodu słońca. 🌅",
"max_sunrise_time": "Ustaw czas najpóźniejszego wirtualnego wschodu słońca (HH:MM:SS), pozwala na przyspieszenie wschodu słońca. 🌅",
@ -75,7 +78,8 @@
"brightness_mode_time_light": "(Pomijany, gdy `brightness_mode='default'`). Czas w sekundach, kiedy jasność będzie zwiększana po wschodzie słońca/zmniejszana przed zachodem słońca. 📈📉",
"autoreset_control_seconds": "Czas, po którym manualna kontrola zostanie wyłączona (w sekundach). Ustaw 0, aby wyłączyć. ⏲️",
"send_split_delay": "Opóźnienie (w ms) pomiędzy `separate_turn_on_commands` dla świateł, które nie akceptują jednoczesnego ustawiania jasności i koloru. ⏲️",
"adapt_delay": "Czas (w sekundach) pomiędzy włączeniem światła, a rozpoczęciem adaptowania przez Adaptacyjne oświetlenie. Może pomóc zredukować migotanie. ⏲️"
"adapt_delay": "Czas (w sekundach) pomiędzy włączeniem światła, a rozpoczęciem adaptowania przez Adaptacyjne oświetlenie. Może pomóc zredukować migotanie. ⏲️",
"transition_until_sleep_ct_mode": "Jak żarówki obsługujące tylko temperaturę barwową zmieniają się po zachodzie słońca, gdy włączone jest `transition_until_sleep` a `sleep_rgb_or_color_temp` to `rgb_color`. `linear`: interpolacja `min_color_temp` → `sleep_color_temp` w kelwinach. `match_rgb`: temperatura barwowa mieszanki RGB, którą w tym momencie pokazują żarówki RGB — mieszane instalacje wyglądają jednolicie. 🌡️"
}
}
}

View file

@ -49,6 +49,8 @@ All configuration options are listed below with their default values. These opti
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |
| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 |
| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` |
| `transition_until_sleep_ct_mode` | How color-temperature-only lights transition after sunset when `transition_until_sleep` is enabled and `sleep_rgb_or_color_temp` is `rgb_color`. `linear`: interpolate `min_color_temp` → `sleep_color_temp` in Kelvin. `match_rgb`: use the color temperature of the RGB blend that RGB lights show at the same moment, so mixed setups look uniform. 🌡️ | `linear` | one of `['linear', 'match_rgb']` |
| `transition_until_sleep_ct_curve` | Only used with `transition_until_sleep_ct_mode: match_rgb`. Calibrates how fast color-temperature-only lights warm up towards `sleep_color_temp` compared to the CCT of the RGB blend: `1` follows the CCT, `>1` warms up earlier (use if your color-temp lights still look colder than your RGB lights), `<1` later. 🎛️ | `1.0` | `float` 0.1-10 |
| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` |
| `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` |
| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` |

View file

@ -11,6 +11,7 @@ from homeassistant.components.adaptive_lighting.color_and_brightness import (
SunEvents,
SunLightSettings,
clamp,
color_xy_to_cct,
)
# Create a mock astral location object (its `.observer` is passed to `SunEvents`)
@ -547,3 +548,263 @@ 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
def test_color_xy_to_cct_roundtrip():
"""CCT derived from a blackbody RGB color must match the original kelvins."""
from homeassistant.util.color import (
color_RGB_to_xy,
color_temperature_to_rgb,
)
for kelvin in (1500, 2000, 2700, 3000, 4000, 5500, 6500):
xy = color_RGB_to_xy(*color_temperature_to_rgb(kelvin))
cct = color_xy_to_cct(*xy)
assert abs(cct - kelvin) / kelvin < 0.02, (kelvin, cct)
def test_adapt_until_sleep_ct_follows_rgb_blend():
"""With `adapt_until_sleep` and an RGB sleep color, color-temp-only lights
must follow the perceived color of the RGB blend (CCT derived from the
blended color) instead of a separate linear interpolation in kelvin space,
which diverges perceptually from the HSV blend used for `rgb_color` (#624).
"""
from homeassistant.util.color import color_RGB_to_xy
settings = SunLightSettings(
name="test",
astral_observer=location.observer,
adapt_until_sleep=True,
max_brightness=100,
max_color_temp=5500,
min_brightness=30,
min_color_temp=3000,
sleep_brightness=1,
sleep_rgb_or_color_temp="rgb_color",
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=dt.timezone.utc,
adapt_until_sleep_ct_mode="match_rgb",
)
date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc)
sunset = settings.sun.sunset(date.date())
_, midnight = settings.sun.noon_and_midnight(date)
if midnight < sunset:
midnight += dt.timedelta(days=1)
previous_kelvin = None
seen_forced = 0
for fraction in (0.15, 0.3, 0.5, 0.7, 0.9):
moment = sunset + (midnight - sunset) * fraction
result = settings.brightness_and_color(moment, is_sleep=False)
if not result["force_rgb_color"]:
continue
seen_forced += 1
expected_cct = clamp(
color_xy_to_cct(*color_RGB_to_xy(*result["rgb_color"])),
settings.sleep_color_temp,
settings.min_color_temp,
)
# The trajectory is anchored at `min_color_temp` for the RGB color
# shown at sunset (whose CCT is only approximately `min_color_temp`),
# so allow for that small, constant offset.
assert abs(result["color_temp_kelvin"] - expected_cct) <= 40, (
moment,
result,
)
assert (
settings.sleep_color_temp
<= result["color_temp_kelvin"]
<= settings.min_color_temp
)
if previous_kelvin is not None:
assert result["color_temp_kelvin"] <= previous_kelvin
previous_kelvin = result["color_temp_kelvin"]
assert seen_forced > 0
@pytest.mark.parametrize(("curve", "expect_warmer"), [(3.0, True), (0.5, False)])
def test_adapt_until_sleep_ct_curve(curve, expect_warmer):
"""`transition_until_sleep_ct_curve` reshapes the color-temp trajectory:
`>1` makes CT-only lights warmer than the plain CCT of the RGB blend at the
same moment, `<1` colder, while the endpoints (sunset/sleep) are unchanged.
"""
kwargs = {
"name": "test",
"astral_observer": location.observer,
"adapt_until_sleep": True,
"max_brightness": 100,
"max_color_temp": 5500,
"min_brightness": 30,
"min_color_temp": 3000,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "rgb_color",
"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": dt.timezone.utc,
"adapt_until_sleep_ct_mode": "match_rgb",
}
plain = SunLightSettings(**kwargs)
shaped = SunLightSettings(**kwargs, adapt_until_sleep_ct_curve=curve)
date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc)
sunset = plain.sun.sunset(date.date())
_, midnight = plain.sun.noon_and_midnight(date)
if midnight < sunset:
midnight += dt.timedelta(days=1)
checked = 0
for fraction in (0.3, 0.5, 0.7):
moment = sunset + (midnight - sunset) * fraction
a = plain.brightness_and_color(moment, is_sleep=False)
b = shaped.brightness_and_color(moment, is_sleep=False)
if not a["force_rgb_color"]:
continue
assert a["rgb_color"] == b["rgb_color"]
if 1000 < a["color_temp_kelvin"] < 3000:
checked += 1
if expect_warmer:
assert b["color_temp_kelvin"] < a["color_temp_kelvin"]
else:
assert b["color_temp_kelvin"] > a["color_temp_kelvin"]
assert 1000 <= b["color_temp_kelvin"] <= 3000
assert checked > 0
# endpoints unchanged
at_sunset = shaped.brightness_and_color(sunset, is_sleep=False)
assert at_sunset["color_temp_kelvin"] == 3000
def test_adapt_until_sleep_ct_mode_linear_is_default_and_unchanged():
"""The default `linear` mode keeps the historical kelvin interpolation."""
settings = SunLightSettings(
name="test",
astral_observer=location.observer,
adapt_until_sleep=True,
max_brightness=100,
max_color_temp=5500,
min_brightness=30,
min_color_temp=3000,
sleep_brightness=1,
sleep_rgb_or_color_temp="rgb_color",
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=dt.timezone.utc,
)
assert settings.adapt_until_sleep_ct_mode == "linear"
date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc)
sunset = settings.sun.sunset(date.date())
_, midnight = settings.sun.noon_and_midnight(date)
if midnight < sunset:
midnight += dt.timedelta(days=1)
moment = sunset + (midnight - sunset) * 0.5
result = settings.brightness_and_color(moment, is_sleep=False)
assert result["force_rgb_color"]
assert result["color_temp_kelvin"] == settings.color_temp_kelvin(
result["sun_position"],
)
def _settings_until_sleep(**overrides):
kwargs = {
"name": "test",
"astral_observer": location.observer,
"adapt_until_sleep": True,
"max_brightness": 100,
"max_color_temp": 5500,
"min_brightness": 30,
"min_color_temp": 2700,
"sleep_brightness": 1,
"sleep_rgb_or_color_temp": "rgb_color",
"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": dt.timezone.utc,
"adapt_until_sleep_ct_mode": "match_rgb",
}
kwargs.update(overrides)
return SunLightSettings(**kwargs)
@pytest.mark.parametrize("min_color_temp", [2000, 2700, 3000, 4000])
@pytest.mark.parametrize("curve", [1.0, 2.5])
def test_adapt_until_sleep_match_rgb_no_step_at_sunset(min_color_temp, curve):
"""`match_rgb` must keep `min_color_temp` right after sunset.
The kelvin -> RGB -> CCT round trip is approximate, so without anchoring
the trajectory the color temperature would step to a slightly different
value the moment the sun goes below the horizon.
"""
settings = _settings_until_sleep(
min_color_temp=min_color_temp,
adapt_until_sleep_ct_curve=curve,
)
date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc)
sunset = settings.sun.sunset(date.date())
at_sunset = settings.brightness_and_color(sunset, is_sleep=False)
assert not at_sunset["force_rgb_color"]
assert at_sunset["color_temp_kelvin"] == min_color_temp
just_after = settings.brightness_and_color(
sunset + dt.timedelta(seconds=1),
is_sleep=False,
)
assert just_after["force_rgb_color"]
assert just_after["color_temp_kelvin"] == min_color_temp
def test_adapt_until_sleep_match_rgb_black_sleep_color():
"""A black `sleep_rgb_color` has no chromaticity to match.
Color-temp-only lights must still end at `sleep_color_temp` (not at
`min_color_temp`) and warm up monotonically in between.
"""
settings = _settings_until_sleep(sleep_rgb_color=(0, 0, 0))
date = dt.datetime(2022, 6, 1, tzinfo=dt.timezone.utc)
sunset = settings.sun.sunset(date.date())
_, midnight = settings.sun.noon_and_midnight(date)
if midnight < sunset:
midnight += dt.timedelta(days=1)
previous = None
for fraction in (0.01, 0.25, 0.5, 0.75, 0.99, 1.0):
result = settings.brightness_and_color(
sunset + (midnight - sunset) * fraction,
is_sleep=False,
)
assert result["force_rgb_color"]
assert 1000 <= result["color_temp_kelvin"] <= 2700
if previous is not None:
assert result["color_temp_kelvin"] <= previous
previous = result["color_temp_kelvin"]
assert result["rgb_color"] == (0, 0, 0)
assert result["color_temp_kelvin"] == settings.sleep_color_temp