diff --git a/README.md b/README.md index 4f1bac64..1080c52a 100644 --- a/README.md +++ b/README.md @@ -227,8 +227,24 @@ Provide a switch in `entity_id`, a list of `lights`, or both. | `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool | | `turn_on_lights` | Whether to turn on lights that are currently off. 🔆 | ❌ | bool | +| `time` | Manually apply the settings for this time of day (HH:MM:SS) instead of now. 🕰️ | ❌ | time (HH:MM:SS) | + +With `time`, the lights get the values Adaptive Lighting would use at that time today, for example to dim a room to its evening settings early. +The adapted attributes are then marked as manually controlled, exactly as if you had changed them yourself: the regular adaptation leaves them alone until manual control is reset (see `autoreset_control_seconds` and `adaptive_lighting.set_manual_control`), and an `adaptive_lighting.manual_control` event is fired. +When sleep mode is on, the sleep settings are applied instead, whatever the time. + +```yaml +action: adaptive_lighting.apply +data: + entity_id: switch.adaptive_lighting_living_room + lights: + - light.sofa + - light.floor_lamp + time: "22:00:00" +``` + #### `adaptive_lighting.set_manual_control` `adaptive_lighting.set_manual_control` can mark (or unmark) whether a light is "manually controlled", meaning that when a light has `manual_control`, the light is not adapted. diff --git a/custom_components/adaptive_lighting/_docs_helpers.py b/custom_components/adaptive_lighting/_docs_helpers.py index c90d0c87..bf966c46 100644 --- a/custom_components/adaptive_lighting/_docs_helpers.py +++ b/custom_components/adaptive_lighting/_docs_helpers.py @@ -44,6 +44,8 @@ def _type_to_str(type_: Any) -> str: # noqa: PLR0911 return f"`{type_.__name__}`" if type_ == cv.boolean: return "bool" + if type_ == cv.time: + return "time (HH:MM:SS)" if isinstance(type_, vol.All): return _format_voluptuous_instance(type_) if isinstance(type_, vol.Any): diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 4645f994..af93fd19 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -464,12 +464,17 @@ class SunLightSettings: self, is_sleep: bool, transition: float | None, + dt: datetime.datetime | None = None, ) -> dict[str, float | int | tuple[float, float] | tuple[float, float, float]]: """Get all light settings. + Uses the current time plus ``transition`` unless ``dt`` is given, in which + case the settings are calculated as if it were ``dt``. + Calculating all values takes <0.5ms. """ - dt = utcnow() + timedelta(seconds=transition or 0) + if dt is None: + dt = utcnow() + timedelta(seconds=transition or 0) return self.brightness_and_color(dt, is_sleep) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index afead25c..16fadba0 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -316,6 +316,10 @@ DOCS[CONF_MANUAL_CONTROL] = "Whether to manually control the lights. 🔒" SERVICE_APPLY = "apply" CONF_TURN_ON_LIGHTS = "turn_on_lights" DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆" +CONF_APPLY_TIME = "time" +DOCS[CONF_APPLY_TIME] = ( + "Manually apply the settings for this time of day (HH:MM:SS) instead of now. 🕰️" +) SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings" CONF_USE_DEFAULTS = "use_defaults" DOCS[CONF_USE_DEFAULTS] = ( @@ -521,6 +525,7 @@ def apply_service_schema() -> vol.Schema: vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, + vol.Optional(CONF_APPLY_TIME): cv.time, }, ) diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index c95e31b5..8bc8e462 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -40,6 +40,11 @@ apply: example: false selector: boolean: null + time: + description: Manually apply the settings for this time of day (HH:MM:SS) instead of now. 🕰️ + example: '22:00:00' + selector: + time: null set_manual_control: description: Mark whether a light is 'manually controlled'. fields: diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 9e22ff57..24ad9140 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -143,6 +143,10 @@ "turn_on_lights": { "description": "Whether to turn on lights that are currently off. 🔆", "name": "turn_on_lights" + }, + "time": { + "description": "Manually apply the settings for this time of day (HH:MM:SS) instead of now. 🕰️", + "name": "time" } } }, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index db1c2d0b..67ad936e 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_APPLY_TIME, CONF_AUTORESET_CONTROL, CONF_BRIGHTNESS_MODE, CONF_BRIGHTNESS_MODE_TIME_DARK, @@ -422,9 +423,23 @@ async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) - ) switches = _switches_from_service_call(hass, service_call) lights = data[CONF_LIGHTS] + apply_time: datetime.time | None = data.get(CONF_APPLY_TIME) + # Applying the settings of another time of day only sticks if the regular + # adaptation leaves the light alone, so treat it like a manual change. + manual_attributes = LightControlAttributes.NONE + if apply_time is not None: + if data[ATTR_ADAPT_BRIGHTNESS]: + manual_attributes |= LightControlAttributes.BRIGHTNESS + if data[ATTR_ADAPT_COLOR]: + manual_attributes |= LightControlAttributes.COLOR for switch in switches: all_lights = switch._resolve_lights(lights or None) switch.manager.lights.update(all_lights) + at_time = None + if apply_time is not None: + tz = switch._sun_light_settings.timezone # pylint: disable=protected-access + today = dt_util.now(tz).date() + at_time = datetime.datetime.combine(today, apply_time, tzinfo=tz) for light in all_lights: if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): context = switch.create_context( @@ -434,6 +449,13 @@ async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) - transition = data.get(CONF_TRANSITION) if transition is None: transition = switch.initial_transition + if manual_attributes: + # Mark before adapting, so a regular adaptation cannot slip in + # between and overwrite the values with those of the current time. + switch.manager.add_manual_control_attributes( + light, + manual_attributes, + ) await switch._adapt_light( # pylint: disable=protected-access light, context=context, @@ -442,7 +464,18 @@ async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) - adapt_color=data[ATTR_ADAPT_COLOR], prefer_rgb_color=data[CONF_PREFER_RGB_COLOR], force=True, + at_time=at_time, ) + if manual_attributes: + # Use the applied state as the manual-control baseline, like + # the state that results from a manual change. + if (state := hass.states.get(light)) is not None: + switch.manager.update_manual_control_state( + light, + state, + manual_attributes, + ) + switch.fire_manual_control_event(light, context) async def handle_set_manual_control_service( @@ -1315,8 +1348,13 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): force: bool = False, context: Context | None = None, already_applied: LightControlAttributes = LightControlAttributes.NONE, + at_time: datetime.datetime | None = None, ) -> AdaptationData | None: - """Prepare `AdaptationData` for adapting a light.""" + """Prepare `AdaptationData` for adapting a light. + + If `at_time` is given, the light is adapted as if it were that time. The + switch's own settings (and its state attributes) are left untouched. + """ adaptation_attributes = self.manager.get_adaption_control_attributes( self, light, @@ -1342,10 +1380,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) return None - # The switch might be off and not have _settings set. - self._settings = self._sun_light_settings.get_settings( + settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on, transition, + at_time, ) # Build service data. @@ -1358,7 +1396,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): service_data[ATTR_TRANSITION] = transition if "brightness" in features and adapt_brightness: - brightness = round(255 * self._settings["brightness_pct"] / 100) + brightness = round(255 * settings["brightness_pct"] / 100) service_data[ATTR_BRIGHTNESS] = brightness sleep_rgb = ( @@ -1370,7 +1408,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) + and not (settings["force_rgb_color"] and "color" in features) ): _LOGGER.debug("%s: Setting color_temp of light %s", self._name, light) state = self.hass.states.get(light) @@ -1378,12 +1416,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): attributes = state.attributes min_kelvin = attributes["min_color_temp_kelvin"] max_kelvin = attributes["max_color_temp_kelvin"] - color_temp_kelvin = self._settings["color_temp_kelvin"] + color_temp_kelvin = settings["color_temp_kelvin"] color_temp_kelvin = clamp(color_temp_kelvin, min_kelvin, max_kelvin) service_data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin elif "color" in features and adapt_color: _LOGGER.debug("%s: Setting rgb_color of light %s", self._name, light) - service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] + service_data[ATTR_RGB_COLOR] = settings["rgb_color"] required_attrs = [ATTR_RGB_COLOR, ATTR_COLOR_TEMP_KELVIN, ATTR_BRIGHTNESS] if not any(attr in service_data for attr in required_attrs): @@ -1420,6 +1458,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): adapt_color: bool | None = None, prefer_rgb_color: bool | None = None, force: bool = False, + at_time: datetime.datetime | None = None, ) -> None: if (lock := self.manager.turn_off_locks.get(light)) and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) @@ -1433,6 +1472,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): prefer_rgb_color, force, context, + at_time=at_time, ) if data is None: return # nothing to adapt diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index cc589156..3dab95a1 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -144,6 +144,10 @@ "turn_on_lights": { "description": "Whether to turn on lights that are currently off. 🔆", "name": "turn_on_lights" + }, + "time": { + "description": "Manually apply the settings for this time of day (HH:MM:SS) instead of now. 🕰️", + "name": "time" } } }, diff --git a/docs/services.md b/docs/services.md index 6258017a..19d68997 100644 --- a/docs/services.md +++ b/docs/services.md @@ -28,6 +28,7 @@ Provide a switch in `entity_id`, a list of `lights`, or both. | `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool | | `turn_on_lights` | Whether to turn on lights that are currently off. 🔆 | ❌ | bool | +| `time` | Manually apply the settings for this time of day (HH:MM:SS) instead of now. 🕰️ | ❌ | time (HH:MM:SS) | @@ -54,6 +55,19 @@ data: adapt_color: true ``` +```yaml +# Set the lights to this evening's 22:00 settings and hold them there +service: adaptive_lighting.apply +data: + entity_id: switch.adaptive_lighting_living_room + lights: + - light.sofa + - light.floor_lamp + time: "22:00:00" +``` + +With `time`, the lights get the values Adaptive Lighting would use at that time today. The adapted attributes are then marked as manually controlled, exactly as if you had changed them yourself: the regular adaptation leaves them alone until manual control is reset (for example by `autoreset_control_seconds` or `adaptive_lighting.set_manual_control`), and an `adaptive_lighting.manual_control` event is fired. When sleep mode is on, the sleep settings are applied instead, whatever the time. + --- ## adaptive_lighting.set_manual_control diff --git a/tests/test_switch.py b/tests/test_switch.py index a3f5b689..8aadef05 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -33,6 +33,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_ADAPT_DELAY, CONF_ADAPT_ONLY_ON_BARE_TURN_ON, CONF_ADAPT_UNTIL_SLEEP, + CONF_APPLY_TIME, CONF_AUTORESET_CONTROL, CONF_BRIGHTNESS_MODE, CONF_BRIGHTNESS_MODE_TIME_DARK, @@ -2186,6 +2187,169 @@ async def test_apply_service_uses_each_switch_transition(hass): assert adapt_2.await_args.kwargs["transition"] == 0 +async def test_apply_service_at_time(hass): + """Apply with 'time' adapts as if it were that time and marks manual control.""" + switch, _ = await setup_lights_and_switch(hass, {CONF_MIN_BRIGHTNESS: 10}) + noon = SUNRISE.replace(hour=12, tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone( + dt_util.UTC, + ) + events = [] + hass.bus.async_listen(f"{DOMAIN}.manual_control", events.append) + + def light_attributes(): + return hass.states.get(ENTITY_LIGHT_1).attributes + + async def interval_update(**kwargs): + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("interval"), + transition=0, + **kwargs, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.adaptive_lighting.color_and_brightness.utcnow", + return_value=noon, + ): + await interval_update(force=True) + assert light_attributes()[ATTR_BRIGHTNESS] == 255 + noon_color_temp = light_attributes()[ATTR_COLOR_TEMP_KELVIN] + settings = deepcopy(switch._settings) + + # After sunset: minimum brightness and the warmest color + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + { + ATTR_ENTITY_ID: switch.entity_id, + CONF_LIGHTS: [ENTITY_LIGHT_1], + CONF_APPLY_TIME: "23:30:00", + }, + blocking=True, + ) + await hass.async_block_till_done() + sun_light_settings = switch._sun_light_settings + tz = sun_light_settings.timezone + at_time = datetime.datetime.combine( + dt_util.now(tz).date(), + datetime.time(23, 30), + tzinfo=tz, + ) + expected = sun_light_settings.brightness_and_color(at_time, is_sleep=False) + applied_brightness = round(255 * expected[ATTR_BRIGHTNESS_PCT] / 100) + assert applied_brightness < 255 + assert light_attributes()[ATTR_BRIGHTNESS] == applied_brightness + assert light_attributes()[ATTR_COLOR_TEMP_KELVIN] < noon_color_temp + # The switch keeps reporting the settings for the current time + assert switch._settings == settings + assert ( + switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.ALL + ) + assert [event.data[ATTR_ENTITY_ID] for event in events] == [ENTITY_LIGHT_1] + + # The regular adaptation leaves the light alone and does not mistake the + # applied values for another manual change. + await interval_update() + assert light_attributes()[ATTR_BRIGHTNESS] == applied_brightness + assert len(events) == 1 + + # Resetting manual control resumes regular adaptation + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MANUAL_CONTROL, + { + ATTR_ENTITY_ID: switch.entity_id, + CONF_LIGHTS: [ENTITY_LIGHT_1], + CONF_MANUAL_CONTROL: False, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert light_attributes()[ATTR_BRIGHTNESS] == 255 + assert light_attributes()[ATTR_COLOR_TEMP_KELVIN] == noon_color_temp + + +async def test_apply_service_at_time_marks_manual_before_adapting(hass): + """No regular adaptation can slip in between applying and marking manual.""" + switch, _ = await setup_lights_and_switch(hass) + manual_during_adaptation = [] + adapt_light = switch._adapt_light + + async def record_and_adapt(light, *args, **kwargs): + manual_during_adaptation.append( + switch.manager.get_manual_control_attributes(light), + ) + await adapt_light(light, *args, **kwargs) + + with patch.object(switch, "_adapt_light", side_effect=record_and_adapt): + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + { + ATTR_ENTITY_ID: switch.entity_id, + CONF_LIGHTS: [ENTITY_LIGHT_1], + CONF_APPLY_TIME: "22:00", + }, + blocking=True, + ) + assert manual_during_adaptation == [LightControlAttributes.ALL] + + +async def test_apply_service_at_time_sleep_mode_and_partial(hass): + """Sleep mode takes precedence, and only adapted attributes become manual.""" + switch, _ = await setup_lights_and_switch(hass, {CONF_SLEEP_TRANSITION: 0}) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + sleep_brightness = round(255 * DEFAULT_SLEEP_BRIGHTNESS / 100) + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == ( + sleep_brightness + ) + + # Manual brightness change + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 200}, + blocking=True, + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT_2}, + blocking=True, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_APPLY, + { + ATTR_ENTITY_ID: switch.entity_id, + CONF_LIGHTS: [ENTITY_LIGHT_1, ENTITY_LIGHT_2], + CONF_APPLY_TIME: datetime.time(12), + ATTR_ADAPT_COLOR: False, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == ( + sleep_brightness + ) + assert ( + switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.BRIGHTNESS + ) + # Lights that are off are neither adapted nor marked + assert hass.states.get(ENTITY_LIGHT_2).state == STATE_OFF + assert not switch.manager.get_manual_control_attributes(ENTITY_LIGHT_2) + + async def test_switch_off_on_off(hass): """Test switch rapid off_on_off."""