✨ Add time option to adaptive_lighting.apply

Apply the settings Adaptive Lighting would use at a given time of day
(today) instead of now, e.g. to switch a room to its evening settings
early. The adapted attributes are marked as manually controlled, like a
manual change in Home Assistant, so the regular adaptation keeps its
hands off until manual control is reset. They are marked before the
adaptation, so a regular adaptation can't slip in between and overwrite
the values. Sleep mode takes precedence, and the switch's own settings
and attributes are left untouched.

`prepare_adaptation_data` no longer writes the switch's settings: it
did so only to have values to read when the switch was off, and now
uses the settings it calculates itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mathias Berglund 2026-09-23 16:02:18 +03:00
commit 54a821cc4c
10 changed files with 267 additions and 8 deletions

View file

@ -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) |
<!-- OUTPUT:END -->
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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) |
<!-- OUTPUT:END -->
@ -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

View file

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