mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-12 06:44:04 +02:00
Merge b1729d906c into 7d0f4b610a
This commit is contained in:
commit
de2e7bacd5
10 changed files with 586 additions and 4 deletions
|
|
@ -164,6 +164,7 @@ The YAML and frontend configuration methods support all of the options listed be
|
|||
| `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` |
|
||||
| `restore_manual_control` | Keep manual control across Home Assistant restarts. Manually controlled lights are saved to storage and restored at startup while they are still on, so the startup adaptation leaves them alone. Ignored when `autoreset_control_seconds` is set. 💾 | `False` | `bool` |
|
||||
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
|
||||
| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 |
|
||||
| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` |
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
|||
if len(data) == 1 and ATTR_ADAPTIVE_LIGHTING_MANAGER in data:
|
||||
# no more config_entries
|
||||
manager = data.pop(ATTR_ADAPTIVE_LIGHTING_MANAGER)
|
||||
# Write a pending debounced save before the manager goes away.
|
||||
await manager.async_flush_manual_control()
|
||||
manager.disable()
|
||||
|
||||
if not data:
|
||||
|
|
|
|||
|
|
@ -262,6 +262,17 @@ DOCS[CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE] = (
|
|||
"Set to `false` to preserve manual control across sleep mode changes. 😴"
|
||||
)
|
||||
|
||||
CONF_RESTORE_MANUAL_CONTROL, DEFAULT_RESTORE_MANUAL_CONTROL = (
|
||||
"restore_manual_control",
|
||||
False,
|
||||
)
|
||||
DOCS[CONF_RESTORE_MANUAL_CONTROL] = (
|
||||
"Keep manual control across Home Assistant restarts. Manually controlled lights "
|
||||
"are saved to storage and restored at startup while they are still on, so the "
|
||||
"startup adaptation leaves them alone. Ignored when `autoreset_control_seconds` "
|
||||
"is set. 💾"
|
||||
)
|
||||
|
||||
CONF_SKIP_REDUNDANT_COMMANDS, DEFAULT_SKIP_REDUNDANT_COMMANDS = (
|
||||
"skip_redundant_commands",
|
||||
False,
|
||||
|
|
@ -303,6 +314,8 @@ SLEEP_MODE_SWITCH = "sleep_mode_switch"
|
|||
ADAPT_COLOR_SWITCH = "adapt_color_switch"
|
||||
ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch"
|
||||
ATTR_ADAPTIVE_LIGHTING_MANAGER = "manager"
|
||||
MANUAL_CONTROL_STORAGE_KEY = f"{DOMAIN}.manual_control"
|
||||
MANUAL_CONTROL_STORAGE_VERSION = 1
|
||||
UNDO_UPDATE_LISTENER = "undo_update_listener"
|
||||
NONE_STR = "None"
|
||||
ATTR_ADAPT_COLOR = "adapt_color"
|
||||
|
|
@ -444,6 +457,7 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
|
|||
DEFAULT_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
|
||||
bool,
|
||||
),
|
||||
(CONF_RESTORE_MANUAL_CONTROL, DEFAULT_RESTORE_MANUAL_CONTROL, bool),
|
||||
(CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool),
|
||||
(CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)),
|
||||
(CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float),
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️",
|
||||
"manual_control_on_external_turn_on": "manual_control_on_external_turn_on: Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️",
|
||||
"reset_manual_control_on_sleep_mode_change": "reset_manual_control_on_sleep_mode_change: Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴",
|
||||
"restore_manual_control": "restore_manual_control: Keep manual control across Home Assistant restarts. Manually controlled lights are saved to storage and restored at startup while they are still on, so the startup adaptation leaves them alone. Ignored when `autoreset_control_seconds` is set. 💾",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"send_split_delay": "send_split_delay",
|
||||
"adapt_delay": "adapt_delay",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ from homeassistant.helpers.event import (
|
|||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.color import (
|
||||
color_temperature_to_rgb,
|
||||
|
|
@ -121,6 +122,7 @@ from .const import (
|
|||
CONF_ONLY_ONCE,
|
||||
CONF_PREFER_RGB_COLOR,
|
||||
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
|
||||
CONF_RESTORE_MANUAL_CONTROL,
|
||||
CONF_SEND_SPLIT_DELAY,
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS,
|
||||
CONF_SKIP_REDUNDANT_COMMANDS,
|
||||
|
|
@ -144,6 +146,8 @@ from .const import (
|
|||
ICON_COLOR_TEMP,
|
||||
ICON_MAIN,
|
||||
ICON_SLEEP,
|
||||
MANUAL_CONTROL_STORAGE_KEY,
|
||||
MANUAL_CONTROL_STORAGE_VERSION,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
SLEEP_MODE_SWITCH,
|
||||
TURNING_OFF_DELAY,
|
||||
|
|
@ -545,6 +549,10 @@ async def async_setup_entry(
|
|||
adapt_brightness_switch,
|
||||
)
|
||||
|
||||
if switch._restore_manual_control:
|
||||
# Seed the previous run's manual control before the entities are added.
|
||||
await manager.async_load_persisted_manual_control()
|
||||
|
||||
data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch
|
||||
data[config_entry.entry_id][ADAPT_COLOR_SWITCH] = adapt_color_switch
|
||||
data[config_entry.entry_id][ADAPT_BRIGHTNESS_SWITCH] = adapt_brightness_switch
|
||||
|
|
@ -999,6 +1007,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._reset_manual_control_on_sleep_mode_change = data[
|
||||
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE
|
||||
]
|
||||
self._restore_manual_control = data[CONF_RESTORE_MANUAL_CONTROL]
|
||||
if self._restore_manual_control:
|
||||
self.manager.persist_manual_control = True
|
||||
self._skip_redundant_commands = data[CONF_SKIP_REDUNDANT_COMMANDS]
|
||||
self._intercept = data[CONF_INTERCEPT]
|
||||
self._multi_light_intercept = data[CONF_MULTI_LIGHT_INTERCEPT]
|
||||
|
|
@ -1085,7 +1096,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
last_state: State | None = await self.async_get_last_state()
|
||||
is_new_entry = last_state is None # newly added to HA
|
||||
if is_new_entry or last_state.state == STATE_ON: # type: ignore[union-attr]
|
||||
await self.async_turn_on(adapt_lights=not self._only_once)
|
||||
await self.async_turn_on(
|
||||
adapt_lights=not self._only_once,
|
||||
restoring=True,
|
||||
)
|
||||
else:
|
||||
self._state = False
|
||||
assert not self.remove_listeners
|
||||
|
|
@ -1267,8 +1281,16 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
async def async_turn_on( # type: ignore[override]
|
||||
self,
|
||||
adapt_lights: bool = True,
|
||||
restoring: bool = False,
|
||||
) -> None:
|
||||
"""Turn on adaptive lighting."""
|
||||
"""Turn on adaptive lighting.
|
||||
|
||||
`restoring` is set by `async_added_to_hass` only. On that path manual
|
||||
control loaded from storage by `restore_manual_control` is left alone,
|
||||
so the forced adaptation below honours it and the profile that happens
|
||||
to be set up first does not decide for the others. The manager resolves
|
||||
those lights once, at `EVENT_HOMEASSISTANT_STARTED`.
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"%s: Called 'async_turn_on', current state is '%s'",
|
||||
self._name,
|
||||
|
|
@ -1277,7 +1299,16 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if self.is_on:
|
||||
return
|
||||
self._state = True
|
||||
self.manager.reset(*self.lights)
|
||||
if restoring and self.manager.restored_lights:
|
||||
self.manager.reset(
|
||||
*[
|
||||
light
|
||||
for light in self.lights
|
||||
if light not in self.manager.restored_lights
|
||||
],
|
||||
)
|
||||
else:
|
||||
self.manager.reset(*self.lights)
|
||||
await self._setup_listeners()
|
||||
if adapt_lights:
|
||||
await self._update_attrs_and_maybe_adapt_lights(
|
||||
|
|
@ -1874,6 +1905,21 @@ class AdaptiveLightingManager:
|
|||
self.turn_off_locks: dict[str, asyncio.Lock] = {}
|
||||
# Tracks which lights are manually controlled
|
||||
self.manual_control: dict[str, LightControlAttributes] = {}
|
||||
# Manual control persisted across restarts (debounced Store, see
|
||||
# `restore_manual_control`). Saving is off until a profile opts in.
|
||||
self._store: Store = Store(
|
||||
hass,
|
||||
MANUAL_CONTROL_STORAGE_VERSION,
|
||||
MANUAL_CONTROL_STORAGE_KEY,
|
||||
)
|
||||
self._store_loaded = False
|
||||
self._store_lock = asyncio.Lock()
|
||||
self._save_scheduled = False
|
||||
self._remove_restore_listener: CALLBACK_TYPE | None = None
|
||||
self.persist_manual_control = False
|
||||
# Lights whose manual control came from storage and is not resolved yet
|
||||
# (see `_async_resolve_restored_manual_control`).
|
||||
self.restored_lights: set[str] = set()
|
||||
# Track 'state_changed' events of self.lights resulting from this integration
|
||||
self.our_last_state_on_change: dict[str, list[State]] = {}
|
||||
# Track last 'service_data' to 'light.turn_on' resulting from this integration
|
||||
|
|
@ -1941,8 +1987,126 @@ class AdaptiveLightingManager:
|
|||
exc_info=True,
|
||||
)
|
||||
|
||||
async def async_load_persisted_manual_control(self) -> None:
|
||||
"""Seed `manual_control` from the previous run's storage (once).
|
||||
|
||||
Only a restart restores manual control. When Home Assistant is already
|
||||
running the profile is being added or reloaded, and a reload keeps
|
||||
clearing manual control the way it always has.
|
||||
"""
|
||||
async with self._store_lock:
|
||||
if self._store_loaded:
|
||||
return
|
||||
self._store_loaded = True
|
||||
if self.hass.is_running:
|
||||
return
|
||||
try:
|
||||
data = await self._store.async_load()
|
||||
except Exception:
|
||||
_LOGGER.exception("Could not load persisted manual_control state")
|
||||
return
|
||||
stored = (data or {}).get("manual_control") or {}
|
||||
for light, value in stored.items():
|
||||
try:
|
||||
flags = LightControlAttributes(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if flags:
|
||||
self.manual_control[light] = flags
|
||||
self.restored_lights.add(light)
|
||||
if not self.restored_lights:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Loaded persisted manual_control for %s",
|
||||
sorted(self.restored_lights),
|
||||
)
|
||||
self._remove_restore_listener = self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
self._async_resolve_restored_manual_control,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_resolve_restored_manual_control(
|
||||
self,
|
||||
_event: Event[NoEventData] | None = None,
|
||||
) -> None:
|
||||
"""Decide once which restored manual control survives the restart.
|
||||
|
||||
Manual control belongs to the light, not to a profile, so the outcome
|
||||
must not depend on which profile is set up first. A restored flag is
|
||||
kept while the light is on and at least one profile controlling it has
|
||||
`restore_manual_control` enabled without `autoreset_control_seconds`.
|
||||
The rest is dropped: manual control only clears on an on to off event,
|
||||
so a light switched off while Home Assistant was down would otherwise
|
||||
keep a stale flag, and a light no profile controls anymore would linger
|
||||
in `manual_control` forever.
|
||||
"""
|
||||
self._remove_restore_listener = None
|
||||
restored = sorted(self.restored_lights)
|
||||
self.restored_lights.clear()
|
||||
if not restored:
|
||||
return
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
untracked: list[str] = []
|
||||
for light in restored:
|
||||
switches = _switches_with_lights(
|
||||
self.hass,
|
||||
[light],
|
||||
expand_light_groups=False,
|
||||
)
|
||||
if not switches:
|
||||
untracked.append(light)
|
||||
elif is_on(self.hass, light) and any(
|
||||
switch._restore_manual_control
|
||||
and switch._auto_reset_manual_control_time == 0
|
||||
for switch in switches
|
||||
):
|
||||
kept.append(light)
|
||||
else:
|
||||
dropped.append(light)
|
||||
if dropped:
|
||||
self.reset(*dropped)
|
||||
if untracked:
|
||||
self.remove_lights(*untracked)
|
||||
_LOGGER.info(
|
||||
"Manual control after restart: restored %s, dropped %s,"
|
||||
" no longer controlled by any profile %s",
|
||||
kept,
|
||||
dropped,
|
||||
untracked,
|
||||
)
|
||||
self._schedule_manual_control_state_update(*kept)
|
||||
|
||||
def _manual_control_snapshot(self) -> dict[str, Any]:
|
||||
"""Serializable view of the current manual control (tracked lights only)."""
|
||||
return {
|
||||
"manual_control": {
|
||||
light: int(flags)
|
||||
for light, flags in self.manual_control.items()
|
||||
if flags and (not self.lights or light in self.lights)
|
||||
},
|
||||
}
|
||||
|
||||
def schedule_save_manual_control(self) -> None:
|
||||
"""Persist the current manual control, debounced, when a profile opted in."""
|
||||
if not self.persist_manual_control:
|
||||
return
|
||||
self._save_scheduled = True
|
||||
self._store.async_delay_save(self._manual_control_snapshot, 2.0)
|
||||
|
||||
async def async_flush_manual_control(self) -> None:
|
||||
"""Write a pending debounced save so no timer outlives the integration."""
|
||||
if not self._save_scheduled:
|
||||
return
|
||||
self._save_scheduled = False
|
||||
await self._store.async_save(self._manual_control_snapshot())
|
||||
|
||||
def disable(self) -> None:
|
||||
"""Disable listeners and pending manual-reset and transition timers."""
|
||||
if self._remove_restore_listener is not None:
|
||||
self._remove_restore_listener()
|
||||
self._remove_restore_listener = None
|
||||
for remove in self.listener_removers:
|
||||
remove()
|
||||
for timer in self.auto_reset_manual_control_timers.values():
|
||||
|
|
@ -2425,7 +2589,12 @@ class AdaptiveLightingManager:
|
|||
attributes,
|
||||
self.get_manual_control_attributes(light),
|
||||
)
|
||||
changed = (
|
||||
self.manual_control.get(light, LightControlAttributes.NONE) != attributes
|
||||
)
|
||||
self.manual_control[light] = attributes
|
||||
if changed:
|
||||
self.schedule_save_manual_control()
|
||||
delay = self.auto_reset_manual_control_times.get(light)
|
||||
|
||||
async def reset() -> None:
|
||||
|
|
@ -2620,6 +2789,8 @@ class AdaptiveLightingManager:
|
|||
"Light %s: Clearing manual control attributes.",
|
||||
light,
|
||||
)
|
||||
if self.manual_control.get(light):
|
||||
self.schedule_save_manual_control()
|
||||
self.manual_control[light] = LightControlAttributes.NONE
|
||||
self.last_manual_control_state.pop(light, None)
|
||||
self.pending_manual_control_state.pop(light, None)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
"adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️",
|
||||
"manual_control_on_external_turn_on": "manual_control_on_external_turn_on: Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️",
|
||||
"reset_manual_control_on_sleep_mode_change": "reset_manual_control_on_sleep_mode_change: Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴",
|
||||
"restore_manual_control": "restore_manual_control: Keep manual control across Home Assistant restarts. Manually controlled lights are saved to storage and restored at startup while they are still on, so the startup adaptation leaves them alone. Ignored when `autoreset_control_seconds` is set. 💾",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"send_split_delay": "send_split_delay",
|
||||
"adapt_delay": "adapt_delay",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,27 @@ adaptive_lighting:
|
|||
manual_control_on_external_turn_on: true # leave unmatched off→on events unchanged
|
||||
```
|
||||
|
||||
### restore_manual_control
|
||||
|
||||
By default a Home Assistant restart clears manual control: the startup adaptation brings every light back to the adaptive values. When enabled, manual control is saved to storage as it changes and restored at startup, so a light you dimmed before the restart stays where you left it. The option is off by default and nothing is written to storage until at least one profile enables it.
|
||||
|
||||
What is stored is the manual-control flag of every light your profiles control, with the same brightness and color detail the switch attributes show. It is written whenever a flag changes, whether you dimmed the light, called `adaptive_lighting.set_manual_control`, or turned the light off. Writes are debounced by two seconds and go to one `adaptive_lighting.manual_control` file per Home Assistant instance.
|
||||
|
||||
At startup the stored flags are pruned before anything adapts. A flag is kept only when the light is on. A light that was turned off while Home Assistant was down is not restored, because manual control normally clears on the off event that never reached Home Assistant. A light that is unavailable at startup is treated the same way, since its integration may bring it back in any state. A light that no profile controls anymore is forgotten entirely. Everything that is kept follows the usual rules from then on: off and on, an explicit reset, or a sleep mode change clears it.
|
||||
|
||||
Profiles with `autoreset_control_seconds` do not restore manual control, because their holds are meant to expire on their own. Manual control belongs to the light rather than to a profile, so when several profiles control the same light the flag is restored as long as one of those profiles has this option enabled without an autoreset. The result does not depend on the order in which the profiles are set up.
|
||||
|
||||
Only a full restart restores manual control. Reloading a config entry, or changing an option, still clears it the way it always has.
|
||||
|
||||
```yaml
|
||||
adaptive_lighting:
|
||||
- name: "Keep my dimming through restarts"
|
||||
lights:
|
||||
- light.living_room
|
||||
take_over_control: true
|
||||
restore_manual_control: true
|
||||
```
|
||||
|
||||
## Checking Manual Control Status
|
||||
|
||||
You can see which lights are marked as manually controlled by checking the switch attributes:
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ All configuration options are listed below with their default values. These opti
|
|||
| `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` |
|
||||
| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` |
|
||||
| `restore_manual_control` | Keep manual control across Home Assistant restarts. Manually controlled lights are saved to storage and restored at startup while they are still on, so the startup adaptation leaves them alone. Ignored when `autoreset_control_seconds` is set. 💾 | `False` | `bool` |
|
||||
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
|
||||
| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 |
|
||||
| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` |
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
CONF_EXPAND_LIGHT_GROUPS,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
||||
CONF_RESTORE_MANUAL_CONTROL,
|
||||
CONF_SUNRISE_TIME,
|
||||
CONF_SUNSET_TIME,
|
||||
DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_RESTORE_MANUAL_CONTROL,
|
||||
DOMAIN,
|
||||
NONE_STR,
|
||||
VALIDATION_TUPLES,
|
||||
|
|
@ -110,6 +112,7 @@ async def test_options(hass):
|
|||
advanced_data[CONF_EXPAND_LIGHT_GROUPS] = False
|
||||
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
|
||||
advanced_data[CONF_SUNSET_TIME] = NONE_STR
|
||||
advanced_data[CONF_RESTORE_MANUAL_CONTROL] = True
|
||||
basic_data = {**BASIC_DATA, "min_brightness": 12}
|
||||
user_input = {
|
||||
**basic_data,
|
||||
|
|
@ -136,6 +139,10 @@ async def test_options(hass):
|
|||
_schema_defaults(_advanced_section(result).schema)[CONF_INITIAL_TRANSITION]
|
||||
== 23
|
||||
)
|
||||
assert (
|
||||
_schema_defaults(_advanced_section(result).schema)[CONF_RESTORE_MANUAL_CONTROL]
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
async def test_options_schema_has_each_setting_once(hass):
|
||||
|
|
@ -157,6 +164,10 @@ async def test_options_schema_has_each_setting_once(hass):
|
|||
_schema_defaults(advanced.schema)[CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON]
|
||||
is DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON
|
||||
)
|
||||
assert (
|
||||
_schema_defaults(advanced.schema)[CONF_RESTORE_MANUAL_CONTROL]
|
||||
is DEFAULT_RESTORE_MANUAL_CONTROL
|
||||
)
|
||||
assert {key.schema for key in schema if key.schema != "advanced"} == BASIC_OPTIONS
|
||||
assert {key.schema for key in advanced.schema.schema} == set(
|
||||
DEFAULT_DATA,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
CONF_ONLY_ONCE,
|
||||
CONF_PREFER_RGB_COLOR,
|
||||
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
|
||||
CONF_RESTORE_MANUAL_CONTROL,
|
||||
CONF_SEND_SPLIT_DELAY,
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS,
|
||||
CONF_SKIP_REDUNDANT_COMMANDS,
|
||||
|
|
@ -137,7 +138,7 @@ from homeassistant.util.color import (
|
|||
color_temperature_mired_to_kelvin,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_capture_events
|
||||
from tests.common import mock_area_registry as mock_ha_area_registry
|
||||
|
||||
# HA 2026.6 removed the legacy `light: platform: template` YAML format
|
||||
|
|
@ -6344,3 +6345,361 @@ async def test_unloaded_polling_profile_preserves_other_split_adaptation(
|
|||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
assert ATTR_COLOR_TEMP_KELVIN in calls[-1]
|
||||
|
||||
|
||||
MANUAL_CONTROL_STORE_KEY = f"{DOMAIN}.manual_control"
|
||||
UNAVAILABLE_LIGHT = "light.unavailable_bulb"
|
||||
|
||||
|
||||
def _stored_manual_control(manual_control: dict[str, int]) -> dict[str, Any]:
|
||||
"""Build the `.storage` payload the previous run would have written."""
|
||||
return {
|
||||
"version": 1,
|
||||
"key": MANUAL_CONTROL_STORE_KEY,
|
||||
"data": {"manual_control": manual_control},
|
||||
}
|
||||
|
||||
|
||||
def _restore_profile(lights: list[str], **extra: Any) -> dict[str, Any]:
|
||||
"""Build a profile config for the restart tests."""
|
||||
return {
|
||||
CONF_LIGHTS: lights,
|
||||
CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour),
|
||||
CONF_SUNSET_TIME: datetime.time(SUNSET.hour),
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_DETECT_NON_HA_CHANGES: True,
|
||||
CONF_PREFER_RGB_COLOR: False,
|
||||
CONF_MIN_COLOR_TEMP: 2500,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
async def _turn_light_on(hass, light: str, **service_data: Any) -> None:
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: light, **service_data},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def _restart(hass, *configs: dict[str, Any]) -> list[AdaptiveSwitch]:
|
||||
"""Set up profiles the way a restart does, before Home Assistant starts."""
|
||||
hass.set_state(CoreState.not_running)
|
||||
switches = []
|
||||
for config in configs:
|
||||
_, switch = await setup_switch(hass, config)
|
||||
switches.append(switch)
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
return switches
|
||||
|
||||
|
||||
async def _set_hold(hass, switch, light, manual_control):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
{
|
||||
ATTR_ENTITY_ID: switch.entity_id,
|
||||
CONF_LIGHTS: [light],
|
||||
CONF_MANUAL_CONTROL: manual_control,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_manual_control_persistence_roundtrip(hass):
|
||||
"""Manual control is written to storage on set and on reset."""
|
||||
switch, _ = await setup_lights_and_switch(hass, {CONF_RESTORE_MANUAL_CONTROL: True})
|
||||
manager = switch.manager
|
||||
with patch.object(manager._store, "async_delay_save") as delay_save:
|
||||
await _set_hold(hass, switch, ENTITY_LIGHT_1, True)
|
||||
assert delay_save.called
|
||||
assert manager._manual_control_snapshot() == {
|
||||
"manual_control": {ENTITY_LIGHT_1: int(LightControlAttributes.ALL)},
|
||||
}
|
||||
delay_save.reset_mock()
|
||||
# brightness-only hold keeps its granularity
|
||||
manager.set_manual_control_attributes(
|
||||
ENTITY_LIGHT_2,
|
||||
LightControlAttributes.BRIGHTNESS,
|
||||
)
|
||||
assert manager._manual_control_snapshot()["manual_control"][
|
||||
ENTITY_LIGHT_2
|
||||
] == int(
|
||||
LightControlAttributes.BRIGHTNESS,
|
||||
)
|
||||
# re-setting the same flags does not schedule another write
|
||||
delay_save.reset_mock()
|
||||
manager.set_manual_control_attributes(
|
||||
ENTITY_LIGHT_2,
|
||||
LightControlAttributes.BRIGHTNESS,
|
||||
)
|
||||
assert not delay_save.called
|
||||
# clearing drops the key and persists
|
||||
await _set_hold(hass, switch, ENTITY_LIGHT_1, False)
|
||||
assert delay_save.called
|
||||
assert (
|
||||
ENTITY_LIGHT_1 not in manager._manual_control_snapshot()["manual_control"]
|
||||
)
|
||||
|
||||
|
||||
async def test_manual_control_restored_on_startup(hass, hass_storage):
|
||||
"""Stored manual control survives the startup adaptation; off lights are pruned."""
|
||||
hass_storage[MANUAL_CONTROL_STORE_KEY] = _stored_manual_control(
|
||||
{
|
||||
ENTITY_LIGHT_1: int(LightControlAttributes.ALL),
|
||||
ENTITY_LIGHT_2: int(LightControlAttributes.BRIGHTNESS),
|
||||
ENTITY_LIGHT_3: int(LightControlAttributes.ALL), # will be off
|
||||
},
|
||||
)
|
||||
await setup_lights(hass)
|
||||
# Pre-restart state the bulbs are still holding: dinner-style dim levels.
|
||||
for light in (ENTITY_LIGHT_1, ENTITY_LIGHT_2):
|
||||
await _turn_light_on(hass, light, brightness=3, color_temp_kelvin=2500)
|
||||
assert hass.states.get(ENTITY_LIGHT_3).state == STATE_OFF
|
||||
|
||||
manual_events = async_capture_events(hass, f"{DOMAIN}.manual_control")
|
||||
(switch,) = await _restart(
|
||||
hass,
|
||||
_restore_profile(
|
||||
[ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3],
|
||||
**{
|
||||
CONF_RESTORE_MANUAL_CONTROL: True,
|
||||
# pause_changed so a brightness-only hold still adapts color
|
||||
CONF_TAKE_OVER_CONTROL_MODE: TakeOverControlMode.PAUSE_CHANGED.value,
|
||||
},
|
||||
),
|
||||
)
|
||||
manager = switch.manager
|
||||
|
||||
# Holds seeded exactly; the off light was pruned; silent (no take-over event)
|
||||
assert manager.manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
|
||||
assert manager.manual_control[ENTITY_LIGHT_2] == LightControlAttributes.BRIGHTNESS
|
||||
assert not manager.manual_control.get(ENTITY_LIGHT_3)
|
||||
assert not manager.restored_lights
|
||||
assert manual_events == []
|
||||
assert set(hass.states.get(switch.entity_id).attributes["manual_control"]) == {
|
||||
ENTITY_LIGHT_1,
|
||||
ENTITY_LIGHT_2,
|
||||
}
|
||||
|
||||
# Boot-time forced adapt already ran inside setup; run another forced pass
|
||||
# to be sure: the full hold is untouched, the brightness-only hold keeps
|
||||
# its brightness but gets the curve's color.
|
||||
await switch._update_attrs_and_maybe_adapt_lights(
|
||||
context=switch.create_context("test"),
|
||||
transition=0,
|
||||
force=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
s1 = hass.states.get(ENTITY_LIGHT_1).attributes
|
||||
s2 = hass.states.get(ENTITY_LIGHT_2).attributes
|
||||
assert s1[ATTR_BRIGHTNESS] == 3
|
||||
assert s1[ATTR_COLOR_TEMP_KELVIN] == 2500
|
||||
assert s2[ATTR_BRIGHTNESS] == 3
|
||||
# curve value (the curve moves a few K between the adapt and this read)
|
||||
assert abs(s2[ATTR_COLOR_TEMP_KELVIN] - switch._settings["color_temp_kelvin"]) < 50
|
||||
assert s2[ATTR_COLOR_TEMP_KELVIN] != 2500
|
||||
|
||||
# The normal lifecycle still applies: off clears it
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: ENTITY_LIGHT_1},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert not manager.manual_control.get(ENTITY_LIGHT_1)
|
||||
|
||||
|
||||
async def test_manual_control_restore_prunes_unavailable_light(hass, hass_storage):
|
||||
"""A light that is unavailable at boot is pruned like a light that is off."""
|
||||
hass_storage[MANUAL_CONTROL_STORE_KEY] = _stored_manual_control(
|
||||
{
|
||||
ENTITY_LIGHT_1: int(LightControlAttributes.ALL),
|
||||
UNAVAILABLE_LIGHT: int(LightControlAttributes.ALL),
|
||||
},
|
||||
)
|
||||
await setup_lights(hass)
|
||||
await _turn_light_on(hass, ENTITY_LIGHT_1, brightness=3)
|
||||
# Its integration has not come back yet, so the state carries no brightness.
|
||||
hass.states.async_set(UNAVAILABLE_LIGHT, STATE_UNAVAILABLE)
|
||||
|
||||
(switch,) = await _restart(
|
||||
hass,
|
||||
_restore_profile(
|
||||
[ENTITY_LIGHT_1, UNAVAILABLE_LIGHT],
|
||||
**{CONF_RESTORE_MANUAL_CONTROL: True},
|
||||
),
|
||||
)
|
||||
manager = switch.manager
|
||||
assert manager.manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
|
||||
assert not manager.manual_control.get(UNAVAILABLE_LIGHT)
|
||||
assert not manager.restored_lights
|
||||
assert hass.states.get(switch.entity_id).attributes["manual_control"] == [
|
||||
ENTITY_LIGHT_1,
|
||||
]
|
||||
|
||||
|
||||
async def test_manual_control_restore_drops_light_no_profile_controls(
|
||||
hass,
|
||||
hass_storage,
|
||||
):
|
||||
"""A stored light that was removed from the profiles is forgotten."""
|
||||
hass_storage[MANUAL_CONTROL_STORE_KEY] = _stored_manual_control(
|
||||
{
|
||||
ENTITY_LIGHT_1: int(LightControlAttributes.ALL),
|
||||
ENTITY_LIGHT_2: int(LightControlAttributes.ALL),
|
||||
},
|
||||
)
|
||||
await setup_lights(hass)
|
||||
for light in (ENTITY_LIGHT_1, ENTITY_LIGHT_2):
|
||||
await _turn_light_on(hass, light, brightness=3)
|
||||
|
||||
# light_2 was taken out of the profile while Home Assistant was down.
|
||||
(switch,) = await _restart(
|
||||
hass,
|
||||
_restore_profile([ENTITY_LIGHT_1], **{CONF_RESTORE_MANUAL_CONTROL: True}),
|
||||
)
|
||||
manager = switch.manager
|
||||
assert manager.manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
|
||||
assert ENTITY_LIGHT_2 not in manager.manual_control
|
||||
assert ENTITY_LIGHT_2 not in manager._manual_control_snapshot()["manual_control"]
|
||||
assert not manager.restored_lights
|
||||
|
||||
|
||||
@pytest.mark.parametrize("opted_in_first", [True, False])
|
||||
async def test_manual_control_restored_for_shared_profiles(
|
||||
hass,
|
||||
hass_storage,
|
||||
opted_in_first,
|
||||
):
|
||||
"""Profiles share the light, so the setup order must not decide the outcome."""
|
||||
hass_storage[MANUAL_CONTROL_STORE_KEY] = _stored_manual_control(
|
||||
{ENTITY_LIGHT_1: int(LightControlAttributes.ALL)},
|
||||
)
|
||||
await setup_lights(hass)
|
||||
await _turn_light_on(hass, ENTITY_LIGHT_1, brightness=3)
|
||||
|
||||
opted_in = _restore_profile(
|
||||
[ENTITY_LIGHT_1],
|
||||
**{CONF_NAME: "opted in", CONF_RESTORE_MANUAL_CONTROL: True},
|
||||
)
|
||||
plain = _restore_profile([ENTITY_LIGHT_1], **{CONF_NAME: "plain"})
|
||||
order = [opted_in, plain] if opted_in_first else [plain, opted_in]
|
||||
switches = await _restart(hass, *order)
|
||||
|
||||
manager = switches[0].manager
|
||||
assert manager.manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
|
||||
for switch in switches:
|
||||
assert hass.states.get(switch.entity_id).attributes["manual_control"] == [
|
||||
ENTITY_LIGHT_1,
|
||||
]
|
||||
|
||||
|
||||
async def test_manual_control_restored_across_autoreset_profile(hass, hass_storage):
|
||||
"""An opted-in profile keeps the hold even when another profile autoresets."""
|
||||
hass_storage[MANUAL_CONTROL_STORE_KEY] = _stored_manual_control(
|
||||
{ENTITY_LIGHT_1: int(LightControlAttributes.ALL)},
|
||||
)
|
||||
await setup_lights(hass)
|
||||
await _turn_light_on(hass, ENTITY_LIGHT_1, brightness=3)
|
||||
|
||||
switches = await _restart(
|
||||
hass,
|
||||
_restore_profile(
|
||||
[ENTITY_LIGHT_1],
|
||||
**{CONF_NAME: "opted in", CONF_RESTORE_MANUAL_CONTROL: True},
|
||||
),
|
||||
_restore_profile(
|
||||
[ENTITY_LIGHT_1],
|
||||
**{CONF_NAME: "autoreset", CONF_AUTORESET_CONTROL: 3600},
|
||||
),
|
||||
)
|
||||
assert switches[0].manager.manual_control[ENTITY_LIGHT_1] == (
|
||||
LightControlAttributes.ALL
|
||||
)
|
||||
|
||||
|
||||
async def test_manual_control_not_restored_with_autoreset(hass, hass_storage):
|
||||
"""Profiles with autoreset keep the default reset at startup."""
|
||||
hass_storage[MANUAL_CONTROL_STORE_KEY] = _stored_manual_control(
|
||||
{ENTITY_LIGHT_1: int(LightControlAttributes.ALL)},
|
||||
)
|
||||
await setup_lights(hass)
|
||||
await _turn_light_on(hass, ENTITY_LIGHT_1, brightness=3)
|
||||
|
||||
(switch,) = await _restart(
|
||||
hass,
|
||||
_restore_profile(
|
||||
[ENTITY_LIGHT_1],
|
||||
**{CONF_RESTORE_MANUAL_CONTROL: True, CONF_AUTORESET_CONTROL: 3},
|
||||
),
|
||||
)
|
||||
assert not switch.manager.manual_control.get(ENTITY_LIGHT_1)
|
||||
assert not switch.manager.restored_lights
|
||||
|
||||
|
||||
async def test_manual_control_not_restored_by_default(hass, hass_storage):
|
||||
"""Without the option, stored manual control is ignored and nothing is written."""
|
||||
hass_storage[MANUAL_CONTROL_STORE_KEY] = _stored_manual_control(
|
||||
{ENTITY_LIGHT_1: int(LightControlAttributes.ALL)},
|
||||
)
|
||||
await setup_lights(hass)
|
||||
await _turn_light_on(hass, ENTITY_LIGHT_1, brightness=3)
|
||||
|
||||
(switch,) = await _restart(hass, _restore_profile([ENTITY_LIGHT_1]))
|
||||
assert not switch.manager.manual_control.get(ENTITY_LIGHT_1)
|
||||
assert not switch.manager.restored_lights
|
||||
with patch.object(switch.manager._store, "async_delay_save") as delay_save:
|
||||
await _set_hold(hass, switch, ENTITY_LIGHT_1, True)
|
||||
assert switch.manager.manual_control[ENTITY_LIGHT_1]
|
||||
assert not delay_save.called
|
||||
|
||||
|
||||
async def test_manual_control_restore_empty_store(hass):
|
||||
"""No stored data behaves exactly like the default."""
|
||||
await setup_lights(hass)
|
||||
await _turn_light_on(hass, ENTITY_LIGHT_1, brightness=3)
|
||||
(switch,) = await _restart(
|
||||
hass,
|
||||
_restore_profile([ENTITY_LIGHT_1], **{CONF_RESTORE_MANUAL_CONTROL: True}),
|
||||
)
|
||||
assert not switch.manager.restored_lights
|
||||
assert not any(switch.manager.manual_control.values())
|
||||
assert switch.manager._manual_control_snapshot() == {"manual_control": {}}
|
||||
|
||||
|
||||
async def test_manual_control_pending_save_flushed_on_unload(hass, hass_storage):
|
||||
"""Unloading the last profile writes the pending save and leaves no timer."""
|
||||
await setup_lights(hass)
|
||||
entry, switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_LIGHTS: [ENTITY_LIGHT_1], CONF_RESTORE_MANUAL_CONTROL: True},
|
||||
)
|
||||
manager = switch.manager
|
||||
store = manager._store
|
||||
await _set_hold(hass, switch, ENTITY_LIGHT_1, True)
|
||||
# The debounced write is still waiting when the entry goes away.
|
||||
assert manager._save_scheduled
|
||||
assert store._delay_handle is not None
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert store._delay_handle is None
|
||||
assert not manager._save_scheduled
|
||||
assert hass_storage[MANUAL_CONTROL_STORE_KEY]["data"] == {
|
||||
"manual_control": {ENTITY_LIGHT_1: int(LightControlAttributes.ALL)},
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(store, "async_save") as save,
|
||||
patch.object(store, "async_delay_save") as delay_save,
|
||||
):
|
||||
await hass.async_block_till_done()
|
||||
assert not save.called
|
||||
assert not delay_save.called
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue