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>
This commit is contained in:
fir3man 2026-09-30 22:15:20 +02:00
commit d8774dcd25
9 changed files with 88 additions and 14 deletions

View file

@ -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` |

View file

@ -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

View file

@ -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,

View file

@ -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. 🌡️"
}
}
}

View file

@ -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],

View file

@ -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. 🌡️"
}
}
}

View file

@ -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. 🌡️"
}
}
}

View file

@ -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` |

View file

@ -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"],
)