From 51e0b11860eb065ba080bd7fefa83371be87f571 Mon Sep 17 00:00:00 2001 From: Ahmad Tawakol Date: Mon, 7 Sep 2026 18:47:10 -0300 Subject: [PATCH] Use the sleep RGB colour as the floor whenever it is configured A switch with `sleep_rgb_or_color_temp: rgb_color` expresses its sleep colour as RGB. `_apply_intensity` only walked RGB towards `sleep_rgb_color` on the `adapt_until_sleep` path; everywhere else it re-derived RGB from the interpolated colour temperature. Intensity 0 therefore produced `color_temperature_to_rgb(sleep_color_temp)` rather than the configured `sleep_rgb_color`, breaking the guarantee that 0 reproduces sleep mode. The condition is now the switch's own configuration -- the sleep floor being in force, and the sleep colour being expressed as RGB -- which subsumes the `adapt_until_sleep` case and makes the `keep_rgb` parameter redundant. The `minimum` floor still derives RGB from the colour temperature, because its anchor is `min_color_temp`, a Kelvin, and the sleep colour is not the floor there. The existing test asserted the RGB match only when `force_rgb_color` was set, so it stepped over this. Replaced with a test that asserts what was actually promised, under both values of `adapt_until_sleep`, plus one pinning the `minimum` floor's Kelvin-derived behaviour. Reported by greptile-apps on #1593. Co-Authored-By: Claude Opus 5 --- .../adaptive_lighting/color_and_brightness.py | 17 ++--- tests/test_intensity.py | 63 ++++++++++--------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 95e09449..e876da2a 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -433,7 +433,6 @@ class SunLightSettings: rgb_color: tuple[int, int, int], *, is_sleep: bool, - keep_rgb: bool, ) -> tuple[float | None, int, tuple[int, int, int]]: """Scale the adaptive result towards this switch's floor settings. @@ -473,12 +472,15 @@ class SunLightSettings: ) color_temp_kelvin = 5 * round(color_temp_kelvin / 5) # round to nearest 5 - if keep_rgb: - # This switch drives colour as RGB after sunset, so walk the RGB value - # towards the configured sleep colour rather than re-deriving it from - # the (unused) colour temperature. `keep_rgb` is only ever set when - # `adapt_until_sleep` is on, which forces the sleep floor, so the - # sleep colour is always the right target here. + if ( + self.intensity_floor_is_sleep + and self.sleep_rgb_or_color_temp == "rgb_color" + ): + # This switch expresses its sleep colour as RGB, so walk the RGB value + # towards `sleep_rgb_color` rather than re-deriving it from the + # interpolated colour temperature. Deriving it would land 0% on + # `color_temperature_to_rgb(sleep_color_temp)`, which is not the + # colour sleep mode actually uses. rgb_color = lerp_color_hsv(self.sleep_rgb_color, rgb_color, factor) else: r, g, b = color_temperature_to_rgb(color_temp_kelvin) @@ -526,7 +528,6 @@ class SunLightSettings: color_temp_kelvin, rgb_color, is_sleep=is_sleep, - keep_rgb=force_rgb_color, ) # backwards compatibility for versions < 1.3.1 - see #403 diff --git a/tests/test_intensity.py b/tests/test_intensity.py index ad3e92f9..c5d630b9 100644 --- a/tests/test_intensity.py +++ b/tests/test_intensity.py @@ -9,6 +9,7 @@ from astral.location import Location from homeassistant.components.adaptive_lighting.color_and_brightness import ( SunLightSettings, ) +from homeassistant.util.color import color_temperature_to_rgb TZINFO = zoneinfo.ZoneInfo("Europe/Amsterdam") LOCATION = Location( @@ -95,10 +96,7 @@ def test_apply_intensity_short_circuits_at_full(intensity_floor): """At 100 the interpolation is skipped outright, not merely a no-op.""" settings = make_settings(intensity=100, intensity_floor=intensity_floor) arguments = (50.0, 3000, (255, 180, 100)) - assert ( - settings._apply_intensity(*arguments, is_sleep=False, keep_rgb=False) - == arguments - ) + assert settings._apply_intensity(*arguments, is_sleep=False) == arguments @pytest.mark.parametrize("intensity", [0, 25, 50, 75, 100]) @@ -213,36 +211,41 @@ def test_adapt_until_sleep_overrides_the_floor(datetime, intensity): assert results[0] == results[1] -@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) @pytest.mark.parametrize("adapt_until_sleep", [True, False]) @pytest.mark.parametrize("datetime", TIMES) -def test_rgb_path_always_has_the_sleep_floor( - datetime, - adapt_until_sleep, - intensity_floor, -): - """`_apply_intensity` walks RGB towards `sleep_rgb_color` unconditionally. +def test_zero_intensity_matches_sleep_rgb(datetime, adapt_until_sleep): + """0 must reproduce sleep mode's RGB, not a colour derived from Kelvin. - That is only sound because the RGB path implies `adapt_until_sleep`, which - forces the sleep floor. + A switch using `sleep_rgb_or_color_temp: rgb_color` expresses its sleep + colour as RGB. Interpolating the colour temperature and re-deriving RGB from + it lands 0% on `color_temperature_to_rgb(sleep_color_temp)`, which is a + different colour from the configured `sleep_rgb_color`. This held only on + the `adapt_until_sleep` path before. + """ + kwargs = { + "sleep_rgb_or_color_temp": "rgb_color", + "adapt_until_sleep": adapt_until_sleep, + } + asleep = make_settings(**kwargs).brightness_and_color(datetime, is_sleep=True) + dialled = make_settings(intensity=0, **kwargs).brightness_and_color( + datetime, + is_sleep=False, + ) + assert dialled["rgb_color"] == asleep["rgb_color"] + + +@pytest.mark.parametrize("datetime", TIMES) +def test_minimum_floor_derives_rgb_from_color_temp(datetime): + """With the `minimum` floor the anchor is a Kelvin, so RGB follows it. + + The sleep RGB colour is deliberately NOT used here -- it is not the floor. """ settings = make_settings( - intensity=50, - intensity_floor=intensity_floor, - adapt_until_sleep=adapt_until_sleep, + intensity=0, + intensity_floor="minimum", + adapt_until_sleep=False, sleep_rgb_or_color_temp="rgb_color", ) - if settings.brightness_and_color(datetime, is_sleep=False)["force_rgb_color"]: - assert settings.intensity_floor_is_sleep - - -def test_rgb_path_is_covered(): - """Guard against `test_rgb_path_always_has_the_sleep_floor` going vacuous.""" - settings = make_settings( - adapt_until_sleep=True, - sleep_rgb_or_color_temp="rgb_color", - ) - assert any( - settings.brightness_and_color(datetime, is_sleep=False)["force_rgb_color"] - for datetime in TIMES - ) + result = settings.brightness_and_color(datetime, is_sleep=False) + expected = color_temperature_to_rgb(result["color_temp_kelvin"]) + assert result["rgb_color"] == tuple(round(c) for c in expected)