Files
2026-09-27 15:17:44 +03:00

238 lines
7.7 KiB
Python

"""Persistent operational state for manual virtual lights."""
from __future__ import annotations
import asyncio
import copy
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .store import HouseplanStore
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
SAVE_DELAY_S = 0.5
_LOGGER = logging.getLogger(__name__)
def is_manual_virtual_light(marker: Any) -> bool:
"""Return whether a marker uses the exact persistent manual-light mode."""
return (
isinstance(marker, dict)
and isinstance(marker.get("id"), str)
and bool(marker["id"])
and marker.get("binding") == "virtual"
and marker.get("is_light") is True
and marker.get("tap_action") == "toggle"
and marker.get("removed") is not True
)
def eligible_virtual_light_ids(config: Any) -> set[str]:
"""Collect live marker ids eligible for persistent manual state."""
if not isinstance(config, dict):
return set()
markers = config.get("markers")
if not isinstance(markers, list):
return set()
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
def _integer(value: Any, default: int = 0) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(0, parsed)
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
if not isinstance(stored, dict):
return 0, 0, set()
raw_off = stored.get("off")
off = (
{item for item in raw_off if isinstance(item, str) and item}
if isinstance(raw_off, list)
else set()
)
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
def _snapshot_payload(
stored: Any,
config: dict[str, Any],
config_rev: int,
*,
previous_config_rev: int | None = None,
) -> dict[str, Any]:
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
expected_rev = config_rev if previous_config_rev is None else previous_config_rev
off = stored_off & eligible if state_config_rev == expected_rev else set()
if off != stored_off:
rev += 1
return _wire(rev, config_rev, off)
class VirtualLightController:
"""Runtime cache with coalesced durable writes for rapid toggles."""
def __init__(self, hass: HomeAssistant, store: HouseplanStore) -> None:
self.hass = hass
self.store = store
self._state: dict[str, Any] | None = None
self._dirty = False
self._save_task: asyncio.Task[None] | None = None
self._flush_event = asyncio.Event()
self._flush_lock = asyncio.Lock()
async def async_snapshot(
self, config: dict[str, Any], config_rev: int,
) -> dict[str, Any]:
source = self._state
if source is None:
source = await self.store.async_load() or {}
payload = _snapshot_payload(source, config, config_rev)
self._state = payload
if payload != source:
self._dirty = True
self._schedule_save()
return copy.deepcopy(payload)
async def async_toggle(
self,
config: dict[str, Any],
config_rev: int,
marker_id: str,
) -> dict[str, Any] | None:
if marker_id not in eligible_virtual_light_ids(config):
return None
snapshot = await self.async_snapshot(config, config_rev)
off = set(snapshot["off"])
if marker_id in off:
off.remove(marker_id)
else:
off.add(marker_id)
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
self._state = payload
self._dirty = True
self._schedule_save()
return {
"marker_id": marker_id,
"on": marker_id not in off,
"rev": payload["rev"],
}
def _schedule_save(self) -> None:
if self._save_task is None or self._save_task.done():
self._save_task = self.hass.async_create_task(self._delayed_save())
async def _delayed_save(self) -> None:
try:
while self._dirty:
if not self._flush_event.is_set():
try:
await asyncio.wait_for(
self._flush_event.wait(), timeout=SAVE_DELAY_S,
)
except TimeoutError:
pass
if not self._dirty or self._state is None:
continue
payload = copy.deepcopy(self._state)
self._dirty = False
try:
await self.store.async_save(payload)
except Exception: # noqa: BLE001 - next toggle/unload retries
self._dirty = True
_LOGGER.exception("House Plan: virtual-light delayed save failed")
return
finally:
self._save_task = None
if not self._dirty:
self._flush_event.clear()
async def async_flush(self) -> None:
"""Persist the latest state before config transitions or unload."""
async with self._flush_lock:
task = self._save_task
if task is not None:
self._flush_event.set()
await task
if self._dirty and self._state is not None:
payload = copy.deepcopy(self._state)
await self.store.async_save(payload)
self._dirty = False
if not self._dirty:
self._flush_event.clear()
def reset(self) -> None:
"""Forget cache after an external reconciliation wrote the store."""
self._state = None
async def async_virtual_light_snapshot(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
) -> dict[str, Any]:
"""Return a coherent snapshot, repairing stale or interrupted state.
A revision gap means an older writer may have changed eligibility without
knowing about this Store. Clearing every manual-off bit is conservative:
it restores the pre-feature/default-on behaviour and cannot resurrect an
old off state for a marker whose role changed in the meantime.
"""
stored = await store.async_load() or {}
payload = _snapshot_payload(stored, config, config_rev)
if payload != stored:
await store.async_save(payload)
return payload
async def async_reconcile_virtual_lights(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
*,
previous_config_rev: int,
) -> dict[str, Any]:
"""Carry eligible state across one known configuration transition."""
stored = await store.async_load() or {}
payload = _snapshot_payload(
stored, config, config_rev, previous_config_rev=previous_config_rev,
)
if payload != stored:
await store.async_save(payload)
return payload
async def async_toggle_virtual_light(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
marker_id: str,
) -> dict[str, Any] | None:
"""Atomically invert one eligible marker and persist before returning."""
if marker_id not in eligible_virtual_light_ids(config):
return None
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
off = set(snapshot["off"])
if marker_id in off:
off.remove(marker_id)
else:
off.add(marker_id)
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
await store.async_save(payload)
return {
"marker_id": marker_id,
"on": marker_id not in off,
"rev": payload["rev"],
}