diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 82fd34db..58fa3eaa 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1123,6 +1123,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): and adapt_color and not (prefer_rgb_color and "color" in features) and not (sleep_rgb and "color" in features) + and not (self._settings["force_rgb_color"] and "color" in features) ): _LOGGER.debug("%s: Setting color_temp of light %s", self._name, light) attributes = self.hass.states.get(light).attributes @@ -1296,9 +1297,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): else: filtered_lights.append(light) + _LOGGER.debug("%s: filtered_lights: '%s'", self._name, filtered_lights) if not filtered_lights: return - adapt_brightness = self.adapt_brightness_switch.is_on adapt_color = self.adapt_color_switch.is_on @@ -1337,6 +1338,13 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): else: _fire_manual_control_event(self, light, context) else: + _LOGGER.debug( + "%s: Calling _adapt_light from _update_attrs_and_maybe_adapt_lights:" + " '%s' with transition %s", + self._name, + light, + transition, + ) await self._adapt_light(light, transition, context=context) async def _sleep_mode_switch_state_event_action(self, event: Event) -> None: @@ -1487,6 +1495,17 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): self._state = False +def lerp_color( + rgb1: tuple[int, int, int], rgb2: tuple[int, int, int], t: float +) -> tuple[int, int, int]: + """Linearly interpolate between two RGB colors.""" + return ( + int(rgb1[0] + t * (rgb2[0] - rgb1[0])), + int(rgb1[1] + t * (rgb2[1] - rgb1[1])), + int(rgb1[2] + t * (rgb2[2] - rgb1[2])), + ) + + @dataclass(frozen=True) class SunLightSettings: """Track the state of the sun and associated light settings.""" @@ -1654,15 +1673,29 @@ class SunLightSettings: if transition is not None else self.calc_percent(0) ) + rgb_color: tuple[float, float, float] + # Variable `force_rgb_color` is needed for RGB color after sunset (if enabled) + force_rgb_color = False brightness_pct = self.calc_brightness_pct(percent, is_sleep) if is_sleep: color_temp_kelvin = self.sleep_color_temp - rgb_color: tuple[float, float, float] = self.sleep_rgb_color + rgb_color = self.sleep_rgb_color + elif ( + self.sleep_rgb_or_color_temp == "rgb_color" + and self.adapt_until_sleep + and percent < 0 + ): + # Feature requested in + # https://github.com/basnijholt/adaptive-lighting/issues/624 + # This will result in a perceptible jump in color at sunset and sunrise + # because the `color_temperature_to_rgb` function is not 100% accurate. + min_color_rgb = color_temperature_to_rgb(self.min_color_temp) + rgb_color = lerp_color(min_color_rgb, self.sleep_rgb_color, percent) + color_temp_kelvin = self.calc_color_temp_kelvin(percent) + force_rgb_color = True else: color_temp_kelvin = self.calc_color_temp_kelvin(percent) - rgb_color: tuple[float, float, float] = color_temperature_to_rgb( - color_temp_kelvin - ) + rgb_color = color_temperature_to_rgb(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) @@ -1675,6 +1708,7 @@ class SunLightSettings: "xy_color": xy_color, "hs_color": hs_color, "sun_position": percent, + "force_rgb_color": force_rgb_color, } diff --git a/tests/test_switch.py b/tests/test_switch.py index 59692f1e..9b679a86 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -56,6 +56,7 @@ from custom_components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, ATTR_ADAPTIVE_LIGHTING_MANAGER, + CONF_ADAPT_UNTIL_SLEEP, CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, @@ -64,6 +65,7 @@ from custom_components.adaptive_lighting.const import ( CONF_MIN_COLOR_TEMP, CONF_PREFER_RGB_COLOR, CONF_SEPARATE_TURN_ON_COMMANDS, + CONF_SLEEP_RGB_OR_COLOR_TEMP, CONF_SUNRISE_OFFSET, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, @@ -74,6 +76,7 @@ from custom_components.adaptive_lighting.const import ( DEFAULT_NAME, DEFAULT_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_COLOR_TEMP, + DEFAULT_SLEEP_RGB_COLOR, DOMAIN, SERVICE_APPLY, SERVICE_CHANGE_SWITCH_SETTINGS, @@ -1430,6 +1433,7 @@ async def test_proactive_adaptation(hass): { ATTR_BRIGHTNESS_PCT: 67, ATTR_COLOR_TEMP_KELVIN: 3448, + "force_rgb_color": False, }, ) @@ -1464,6 +1468,7 @@ async def test_proactive_adaptation_with_separate_commands(hass): { ATTR_BRIGHTNESS_PCT: 67, ATTR_COLOR_TEMP_KELVIN: 3448, + "force_rgb_color": False, }, ) @@ -1615,3 +1620,84 @@ async def test_two_switches_for_single_light(hass): after_color_temp = attrs[ATTR_COLOR_TEMP_KELVIN] assert before_brightness != after_brightness assert before_color_temp != after_color_temp + + +async def test_adapt_until_sleep_and_rgb_colors(hass): + """Test setting up the Adaptive Lighting switches with different timezones. + + Also test the (sleep) brightness and color temperature settings. + """ + lat, long, timezone = (32.87336, -117.22743, "US/Pacific") + await config_util.async_process_ha_core_config( + hass, + {"latitude": lat, "longitude": long, "time_zone": timezone}, + ) + switch, lights = await setup_lights_and_switch( + hass, + { + CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), + CONF_SUNSET_TIME: datetime.time(SUNSET.hour), + CONF_ADAPT_UNTIL_SLEEP: True, + CONF_SLEEP_RGB_OR_COLOR_TEMP: "rgb_color", + }, + ) + + context = switch.create_context("test") # needs to be passed to update method + min_color_temp = switch._sun_light_settings.min_color_temp + + sunset = SUNSET.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC) + before_sunset = sunset - datetime.timedelta(hours=1) + after_sunset = sunset + datetime.timedelta(hours=1) + sunrise = SUNRISE.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC) + before_sunrise = sunrise - datetime.timedelta(hours=1) + after_sunrise = sunrise + datetime.timedelta(hours=1) + + async def patch_time_and_update(time): + with patch("homeassistant.util.dt.utcnow", return_value=time): + await switch._update_attrs_and_maybe_adapt_lights(context=context) + await hass.async_block_till_done() + + # At sunset the brightness should be max and color_temp at the smallest value + await patch_time_and_update(sunset) + assert not switch._settings["force_rgb_color"] + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp + + # One hour before sunset the brightness should be max and color_temp + # not at the smallest value yet. + await patch_time_and_update(before_sunset) + assert not switch._settings["force_rgb_color"] + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] > min_color_temp + assert "color_temp_kelvin" in switch.manager.last_service_data[ENTITY_LIGHT] + + # One hour after sunset the brightness should be down + await patch_time_and_update(after_sunset) + assert switch._settings["force_rgb_color"] + assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS + assert "rgb_color" in switch.manager.last_service_data[ENTITY_LIGHT] + + # At sunrise the brightness should be max and color_temp at the smallest value + await patch_time_and_update(sunrise) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] == min_color_temp + assert "color_temp_kelvin" in switch.manager.last_service_data[ENTITY_LIGHT] + + # One hour before sunrise the brightness should smaller than max + # and color_temp at the min value. + await patch_time_and_update(before_sunrise) + assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS + assert "rgb_color" in switch.manager.last_service_data[ENTITY_LIGHT] + + # One hour after sunrise the brightness should be up + await patch_time_and_update(after_sunrise) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS + assert switch._settings["color_temp_kelvin"] > min_color_temp + assert "color_temp_kelvin" in switch.manager.last_service_data[ENTITY_LIGHT] + + # Turn on sleep mode which make the brightness and color_temp + # deterministic regardless of the time + await switch.sleep_mode_switch.async_turn_on() + await switch._update_attrs_and_maybe_adapt_lights(context=context) + assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_SLEEP_BRIGHTNESS + assert switch._settings["rgb_color"] == DEFAULT_SLEEP_RGB_COLOR