From e3cbb4c0b3297422c28fb64cdd7c65c64381e9a2 Mon Sep 17 00:00:00 2001 From: fir3man Date: Wed, 30 Sep 2026 21:55:49 +0200 Subject: [PATCH 1/4] fix: keep color-temp-only lights on the RGB sleep blend trajectory When `transition_until_sleep` is enabled with `sleep_rgb_or_color_temp: rgb_color`, RGB-capable lights blend toward `sleep_rgb_color` in HSV space, while color-temp-only lights interpolate min_color_temp -> sleep_color_temp linearly in kelvin space. The two trajectories diverge perceptually, so CT-only bulbs look noticeably colder than RGB bulbs all evening (follow-up to #624, which introduced the RGB blend). Derive the color temperature for CT-only lights from the blended RGB color itself: find the CCT (nearest point on the Planckian locus in CIE 1960 uv, consistent with color_temperature_to_rgb) of the blended color, clamped between sleep_color_temp and min_color_temp. This also removes the CT discontinuity at sunset since CCT(rgb(min_color_temp)) == min_color_temp by construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../adaptive_lighting/color_and_brightness.py | 47 ++++++++++- tests/test_color_and_brightness.py | 78 +++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 4645f994..3a4bed0d 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -445,9 +445,18 @@ 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: + # Keep color-temp-only lights on the same perceived trajectory as + # the RGB blend: derive the CCT from the blended color instead of + # interpolating in kelvin space, which diverges perceptually from + # the HSV interpolation used for `rgb_color` (see #624). + cct = color_xy_to_cct(*xy_color) + color_temp_kelvin = 5 * round( + clamp(cct, self.sleep_color_temp, self.min_color_temp) / 5, + ) # 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 +603,42 @@ 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 for a CIE 1931 + xy chromaticity. + + 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) over the range + # supported by `color_temperature_to_rgb` (1000-40000 K). + 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/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index 8598b121..349b6b59 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,80 @@ 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, + ) + 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, + ) + assert abs(result["color_temp_kelvin"] - expected_cct) <= 5, ( + 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 From ba441747f74e4941a02f65f4f316bdf2112dde1a Mon Sep 17 00:00:00 2001 From: fir3man Date: Wed, 30 Sep 2026 22:12:13 +0200 Subject: [PATCH 2/4] feat: add transition_until_sleep_ct_curve to calibrate CT-only lights Deriving the CCT from the RGB blend is colorimetrically correct, but a color-temperature-only bulb cannot reproduce the saturation of the blended orange, and real bulbs render nominal kelvins differently. In practice CT-only lights can still look colder than the RGB lights next to them. Add an optional `transition_until_sleep_ct_curve` (default 1.0 = follow the CCT exactly) that reshapes the progress towards `sleep_color_temp` in mired space: `>1` reaches warm tones earlier, `<1` later. The endpoints (min_color_temp at sunset, sleep_color_temp at sleep) are unchanged, so there is still no jump at sunset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + .../adaptive_lighting/color_and_brightness.py | 24 ++++++-- custom_components/adaptive_lighting/const.py | 23 ++++++-- .../adaptive_lighting/strings.json | 2 + custom_components/adaptive_lighting/switch.py | 2 + .../adaptive_lighting/translations/en.json | 2 + .../adaptive_lighting/translations/pl.json | 2 + docs/configuration.md | 1 + tests/test_color_and_brightness.py | 58 +++++++++++++++++++ 9 files changed, 106 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4f1bac64..c81a4d04 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ 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_curve` | Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️ | `1.0` | `float` | | `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 3a4bed0d..63306483 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -297,6 +297,7 @@ 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_curve: float = 1.0 sunrise_offset: datetime.timedelta = datetime.timedelta() sunset_offset: datetime.timedelta = datetime.timedelta() timezone: datetime.tzinfo = UTC @@ -451,10 +452,24 @@ class SunLightSettings: # the RGB blend: derive the CCT from the blended color instead of # interpolating in kelvin space, which diverges perceptually from # the HSV interpolation used for `rgb_color` (see #624). - cct = color_xy_to_cct(*xy_color) - color_temp_kelvin = 5 * round( - clamp(cct, self.sleep_color_temp, self.min_color_temp) / 5, + cct = clamp( + color_xy_to_cct(*xy_color), + self.sleep_color_temp, + self.min_color_temp, ) + if self.adapt_until_sleep_ct_curve != 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. + mired_min = 1e6 / self.min_color_temp + mired_sleep = 1e6 / self.sleep_color_temp + if mired_sleep != mired_min: + progress = (1e6 / cct - mired_min) / (mired_sleep - mired_min) + progress = clamp(progress, 0, 1) ** ( + 1 / self.adapt_until_sleep_ct_curve + ) + cct = 1e6 / (mired_min + progress * (mired_sleep - mired_min)) + color_temp_kelvin = 5 * round(cct / 5) # backwards compatibility for versions < 1.3.1 - see #403 color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) hs_color: tuple[float, float] = color_xy_to_hs(*xy_color) @@ -604,8 +619,7 @@ def lerp_color_hsv( def color_xy_to_cct(x: float, y: float) -> float: - """Return the correlated color temperature (CCT) in Kelvin for a CIE 1931 - xy chromaticity. + """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 diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index afead25c..9b164ec4 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,18 @@ DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( "transitioning to these values after sunset. 🌙" ) +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` and `sleep_rgb_or_color_temp: " + "rgb_color`. Shapes 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 look colder than " + "your RGB lights in the evening), `<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 +400,11 @@ 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_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..d72befe1 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -53,6 +53,7 @@ "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_curve": "transition_until_sleep_ct_curve", "sunrise_time": "sunrise_time", "min_sunrise_time": "min_sunrise_time", "max_sunrise_time": "max_sunrise_time", @@ -86,6 +87,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` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<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. 🌅", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index db1c2d0b..4ae02b64 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -96,6 +96,7 @@ from .const import ( CONF_ADAPT_DELAY, CONF_ADAPT_ONLY_ON_BARE_TURN_ON, CONF_ADAPT_UNTIL_SLEEP, + CONF_ADAPT_UNTIL_SLEEP_CT_CURVE, CONF_AUTORESET_CONTROL, CONF_BRIGHTNESS_MODE, CONF_BRIGHTNESS_MODE_TIME_DARK, @@ -1018,6 +1019,7 @@ 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], 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..3bf7a96b 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -54,6 +54,7 @@ "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_curve": "transition_until_sleep_ct_curve", "sunrise_time": "sunrise_time", "min_sunrise_time": "min_sunrise_time", "max_sunrise_time": "max_sunrise_time", @@ -87,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` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<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. 🌅", diff --git a/custom_components/adaptive_lighting/translations/pl.json b/custom_components/adaptive_lighting/translations/pl.json index 8957db09..0aeb9cfe 100644 --- a/custom_components/adaptive_lighting/translations/pl.json +++ b/custom_components/adaptive_lighting/translations/pl.json @@ -43,6 +43,7 @@ "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_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 +63,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` i `sleep_rgb_or_color_temp: rgb_color`. Określa, jak szybko żarówki obsługujące tylko temperaturę barwową 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 wyglądają wieczorem 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. 🌅", diff --git a/docs/configuration.md b/docs/configuration.md index afd05721..1bc6c49e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,6 +49,7 @@ 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_curve` | Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️ | `1.0` | `float` | | `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 349b6b59..65bfd630 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -625,3 +625,61 @@ def test_adapt_until_sleep_ct_follows_rgb_blend(): 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, + } + 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 From d8774dcd2507469c1185a170386c097b62901c01 Mon Sep 17 00:00:00 2001 From: fir3man Date: Wed, 30 Sep 2026 22:15:20 +0200 Subject: [PATCH 3/4] feat: make CT-from-RGB-blend opt-in via transition_until_sleep_ct_mode Add `transition_until_sleep_ct_mode` (`linear` | `match_rgb`, default `linear`) so existing installations keep the historical kelvin interpolation unless they opt in. `transition_until_sleep_ct_curve` only applies in `match_rgb` mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- .../adaptive_lighting/color_and_brightness.py | 3 +- custom_components/adaptive_lighting/const.py | 33 ++++++++++++--- .../adaptive_lighting/strings.json | 6 ++- custom_components/adaptive_lighting/switch.py | 2 + .../adaptive_lighting/translations/en.json | 6 ++- .../adaptive_lighting/translations/pl.json | 6 ++- docs/configuration.md | 3 +- tests/test_color_and_brightness.py | 40 +++++++++++++++++++ 9 files changed, 88 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c81a4d04..14214863 100644 --- a/README.md +++ b/README.md @@ -145,7 +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_curve` | Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️ | `1.0` | `float` | +| `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` | | `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 63306483..5ee06eff 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -297,6 +297,7 @@ 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() @@ -447,7 +448,7 @@ class SunLightSettings: 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: + if force_rgb_color and self.adapt_until_sleep_ct_mode == "match_rgb": # Keep color-temp-only lights on the same perceived trajectory as # the RGB blend: derive the CCT from the blended color instead of # interpolating in kelvin space, which diverges perceptually from diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 9b164ec4..6c908d04 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -233,16 +233,28 @@ 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` and `sleep_rgb_or_color_temp: " - "rgb_color`. Shapes 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 look colder than " - "your RGB lights in the evening), `<1` later. 🌡️" + "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 @@ -400,6 +412,17 @@ 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, diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index d72befe1..32132b9d 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -53,6 +53,7 @@ "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", @@ -87,7 +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` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️", + "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. 🌅", @@ -102,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 4ae02b64..95abf438 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -97,6 +97,7 @@ from .const import ( 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, @@ -1020,6 +1021,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): 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 3bf7a96b..75448696 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -54,6 +54,7 @@ "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", @@ -88,7 +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` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️", + "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. 🌅", @@ -103,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 0aeb9cfe..735c664e 100644 --- a/custom_components/adaptive_lighting/translations/pl.json +++ b/custom_components/adaptive_lighting/translations/pl.json @@ -43,6 +43,7 @@ "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)", @@ -63,7 +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` i `sleep_rgb_or_color_temp: rgb_color`. Określa, jak szybko żarówki obsługujące tylko temperaturę barwową 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 wyglądają wieczorem chłodniej niż RGB), `<1` później. 🌡️", + "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. 🌅", @@ -77,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 1bc6c49e..b07e62f0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,7 +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_curve` | Only used with `transition_until_sleep` and `sleep_rgb_or_color_temp: rgb_color`. Shapes 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 look colder than your RGB lights in the evening), `<1` later. 🌡️ | `1.0` | `float` | +| `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` | | `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 65bfd630..0529ed74 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -592,6 +592,7 @@ def test_adapt_until_sleep_ct_follows_rgb_blend(): 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()) @@ -654,6 +655,7 @@ def test_adapt_until_sleep_ct_curve(curve, expect_warmer): "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) @@ -683,3 +685,41 @@ def test_adapt_until_sleep_ct_curve(curve, expect_warmer): # 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"], + ) From 0a768be866d3f4bc74791ca55ce19c5f193fe141 Mon Sep 17 00:00:00 2001 From: fir3man Date: Mon, 5 Oct 2026 06:34:45 +0200 Subject: [PATCH 4/4] fix: handle black sleep color and anchor match_rgb trajectory at sunset Address review findings on transition_until_sleep_ct_mode: match_rgb: - A black `sleep_rgb_color` (or a black blend) has no chromaticity, so there is no color temperature to match. Fall back to the linear kelvin interpolation so color-temp-only lights end at `sleep_color_temp` instead of being clamped to `min_color_temp`. - 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 there is no color temperature step at sunset. - Document the accepted range (0.1-10) of `transition_until_sleep_ct_curve` in README.md and docs/configuration.md, as the table formatter does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../adaptive_lighting/color_and_brightness.py | 70 ++++++++++----- docs/configuration.md | 2 +- tests/test_color_and_brightness.py | 87 ++++++++++++++++++- 4 files changed, 135 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 14214863..56062805 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ The YAML and frontend configuration methods support all of the options listed be | `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` | +| `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 5ee06eff..8beb5d57 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -412,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, @@ -449,28 +490,11 @@ class SunLightSettings: 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": - # Keep color-temp-only lights on the same perceived trajectory as - # the RGB blend: derive the CCT from the blended color instead of - # interpolating in kelvin space, which diverges perceptually from - # the HSV interpolation used for `rgb_color` (see #624). - cct = clamp( - color_xy_to_cct(*xy_color), - self.sleep_color_temp, - self.min_color_temp, + color_temp_kelvin = self._color_temp_kelvin_matching_rgb( + rgb_color, + xy_color, + color_temp_kelvin, ) - if self.adapt_until_sleep_ct_curve != 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. - mired_min = 1e6 / self.min_color_temp - mired_sleep = 1e6 / self.sleep_color_temp - if mired_sleep != mired_min: - progress = (1e6 / cct - mired_min) / (mired_sleep - mired_min) - progress = clamp(progress, 0, 1) ** ( - 1 / self.adapt_until_sleep_ct_curve - ) - cct = 1e6 / (mired_min + progress * (mired_sleep - mired_min)) - color_temp_kelvin = 5 * round(cct / 5) # backwards compatibility for versions < 1.3.1 - see #403 color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) hs_color: tuple[float, float] = color_xy_to_hs(*xy_color) @@ -641,8 +665,8 @@ def color_xy_to_cct(x: float, y: float) -> float: uk, vk = _uv(xk, yk) return (u0 - uk) ** 2 + (v0 - vk) ** 2 - # Ternary search in mired space (perceptually uniform) over the range - # supported by `color_temperature_to_rgb` (1000-40000 K). + # 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 diff --git a/docs/configuration.md b/docs/configuration.md index b07e62f0..72dbb4c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,7 +50,7 @@ All configuration options are listed below with their default values. These opti | `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` | +| `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 0529ed74..082a3193 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -613,7 +613,10 @@ def test_adapt_until_sleep_ct_follows_rgb_blend(): settings.sleep_color_temp, settings.min_color_temp, ) - assert abs(result["color_temp_kelvin"] - expected_cct) <= 5, ( + # 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, ) @@ -723,3 +726,85 @@ def test_adapt_until_sleep_ct_mode_linear_is_default_and_unchanged(): 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