Add restore_manual_control option to keep manual control across restarts

A restart currently clears manual control for every light: the startup
adaptation resets the flags and force-adapts, so a light that was dimmed by
hand before the restart comes back at the adaptive values. With the new
opt-in option, manual control is written to storage as it changes (debounced,
one store per Home Assistant instance) and restored at startup. The restored
flags are resolved once at EVENT_HOMEASSISTANT_STARTED: kept while the light
is on and at least one profile controlling it has the option without
autoreset_control_seconds, dropped otherwise, and forgotten when no profile
controls the light anymore, so the outcome does not depend on the order the
profiles are set up in. Only a full restart restores; a config-entry reload
still clears manual control. Unloading the last entry writes a pending save.
Defaults stay unchanged and nothing is read or written unless a profile opts in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EdXCHuaz4Yj932oEwtgouW
This commit is contained in:
Joe Neuman 2026-09-08 18:00:23 -07:00
commit b1729d906c
10 changed files with 586 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View file

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