diff --git a/README.md b/README.md index 4f1bac64..56062805 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 4645f994..8beb5d57 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -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) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index afead25c..6c908d04 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -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), diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 9e22ff57..32132b9d 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -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. 🌡️" } } } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index db1c2d0b..95abf438 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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], diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index cc589156..75448696 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -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. 🌡️" } } } diff --git a/custom_components/adaptive_lighting/translations/pl.json b/custom_components/adaptive_lighting/translations/pl.json index 8957db09..735c664e 100644 --- a/custom_components/adaptive_lighting/translations/pl.json +++ b/custom_components/adaptive_lighting/translations/pl.json @@ -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. 🌡️" } } } diff --git a/docs/configuration.md b/docs/configuration.md index afd05721..72dbb4c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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` | diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 8598b121..082a3193 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -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