mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
402 lines
14 KiB
Python
402 lines
14 KiB
Python
"""Storage helpers: versioned stores and per-entry runtime data."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.storage import Store
|
|
|
|
from .const import (
|
|
DOMAIN,
|
|
STORAGE_CONFIG_KEY,
|
|
STORAGE_KEY,
|
|
STORAGE_MINOR_VERSION,
|
|
STORAGE_VERSION,
|
|
STORAGE_VIRTUAL_LIGHTS_KEY,
|
|
)
|
|
from .coordinate_canonicalization import (
|
|
canonicalize_config_geometry,
|
|
canonicalize_layout_geometry,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
_BG_MODES = frozenset({"static", "daynight"})
|
|
|
|
|
|
def migrate_config_background_mode(old_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Materialize the legacy implicit background mode without changing its view.
|
|
|
|
Only the config-store document has a top-level ``config`` object. Layout
|
|
and virtual-light stores pass through this helper unchanged even though
|
|
they share the same Store subclass and minor version.
|
|
"""
|
|
config = old_data.get("config")
|
|
if not isinstance(config, dict):
|
|
return old_data
|
|
settings = config.get("settings")
|
|
mode = settings.get("bg_mode") if isinstance(settings, dict) else None
|
|
if mode in _BG_MODES:
|
|
return old_data
|
|
|
|
data = copy.deepcopy(old_data)
|
|
migrated_config = data["config"]
|
|
migrated_settings = migrated_config.get("settings")
|
|
if not isinstance(migrated_settings, dict):
|
|
migrated_settings = {}
|
|
migrated_config["settings"] = migrated_settings
|
|
migrated_settings["bg_mode"] = "static"
|
|
return data
|
|
|
|
|
|
class HouseplanStore(Store):
|
|
"""Store with a migration hook.
|
|
|
|
Bump STORAGE_MINOR_VERSION for backward-compatible schema additions and
|
|
STORAGE_VERSION for breaking changes, then handle them here. Keeping the
|
|
skeleton in place from day one means old installations always pass through
|
|
a single, tested upgrade path.
|
|
"""
|
|
|
|
async def _async_migrate_func(
|
|
self,
|
|
old_major_version: int,
|
|
old_minor_version: int,
|
|
old_data: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
if old_major_version == 1 and old_minor_version < 2:
|
|
return migrate_config_background_mode(old_data)
|
|
return old_data
|
|
|
|
|
|
@dataclass
|
|
class HouseplanData:
|
|
"""Runtime data of the single config entry (entry.runtime_data)."""
|
|
|
|
store: HouseplanStore
|
|
config_store: HouseplanStore
|
|
virtual_light_store: HouseplanStore
|
|
virtual_lights: Any | None = None
|
|
# One lock for every load→modify→save cycle of both stores: prevents
|
|
# lost updates from concurrent WS calls and makes the rev check atomic.
|
|
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
# A separate, narrower lock for the check-quota→write-file pair of an
|
|
# upload. Without it N parallel uploads all measure the store BEFORE any
|
|
# of them writes, and all pass a quota only one of them fits under
|
|
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
|
# stall config/layout commits.
|
|
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
# Collect files nothing references any more. Set during setup, which also
|
|
# runs it once and schedules it daily. Exposed so it can be invoked
|
|
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
|
# the work happens, and those are different claims.
|
|
sweep: Callable[[], Awaitable[None]] | None = None
|
|
# Stable HA instance id used only through a one-way export fingerprint.
|
|
instance_id: str = ""
|
|
# Parsed import candidates are short-lived, user-bound and memory-only.
|
|
# dict keeps insertion order, which lets the preview service evict oldest.
|
|
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
# #43: already-sanitized support-package bytes only. Raw config/layout is
|
|
# never retained here; the write lock below is used to take one coherent
|
|
# deep copy and the privacy projection immediately replaces it.
|
|
support_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
# #330 §4.2: junction-limit violation counts of the STORED document,
|
|
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
|
|
# memory-only, invalidated by a rev mismatch; the previous document is
|
|
# not re-judged on every write. Counts only — documents are never held.
|
|
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
|
|
# Runtime-only #485 authority; never serialized by HouseplanStore.
|
|
radar_coordinator: Any | None = None
|
|
|
|
|
|
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
|
|
|
|
|
def create_data(hass: HomeAssistant) -> HouseplanData:
|
|
"""Create the stores for a config entry."""
|
|
data = HouseplanData(
|
|
store=HouseplanStore(hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_MINOR_VERSION),
|
|
config_store=HouseplanStore(
|
|
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
|
|
),
|
|
virtual_light_store=HouseplanStore(
|
|
hass,
|
|
STORAGE_VERSION,
|
|
STORAGE_VIRTUAL_LIGHTS_KEY,
|
|
minor_version=STORAGE_MINOR_VERSION,
|
|
),
|
|
)
|
|
from .virtual_lights import VirtualLightController
|
|
|
|
data.virtual_lights = VirtualLightController(data.virtual_light_store)
|
|
return data
|
|
|
|
|
|
def get_data(hass: HomeAssistant) -> HouseplanData | None:
|
|
"""Runtime data of the loaded entry, or None when not set up."""
|
|
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
|
return entries[0].runtime_data if entries else None
|
|
|
|
|
|
def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
|
"""The loaded config entry, or None."""
|
|
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
|
return entries[0] if entries else None
|
|
|
|
|
|
OPTIMIZE_BACKUP = "optimize_backup"
|
|
OPTIMIZE_PENDING = "optimize_pending"
|
|
LAYOUT_STORE_CORE_KEYS = frozenset({"layout", "rev"})
|
|
|
|
|
|
def layout_store_payload(
|
|
stored: dict[str, Any],
|
|
layout: dict[str, Any],
|
|
rev: int,
|
|
*,
|
|
metadata: dict[str, Any] | None = None,
|
|
remove: tuple[str, ...] = (),
|
|
replace_metadata: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Build one layout-store write without silently dropping metadata.
|
|
|
|
Layout used to be saved by several independent dict comprehensions. Every
|
|
new metadata key therefore had to be added to every caller or was lost on
|
|
the next drag. All writers now express only the metadata they intentionally
|
|
add/remove and this helper preserves the rest.
|
|
"""
|
|
excluded = {*LAYOUT_STORE_CORE_KEYS, *remove}
|
|
out = {} if replace_metadata else {
|
|
key: value for key, value in stored.items() if key not in excluded
|
|
}
|
|
if metadata:
|
|
out.update(metadata)
|
|
out["layout"] = canonicalize_layout_geometry(layout)
|
|
out["rev"] = rev
|
|
return out
|
|
|
|
|
|
async def async_save_layout_state(
|
|
runtime: HouseplanData,
|
|
stored: dict[str, Any],
|
|
layout: dict[str, Any],
|
|
rev: int,
|
|
*,
|
|
metadata: dict[str, Any] | None = None,
|
|
remove: tuple[str, ...] = (),
|
|
replace_metadata: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Persist layout and return the exact store document written."""
|
|
payload = layout_store_payload(
|
|
stored,
|
|
layout,
|
|
rev,
|
|
metadata=metadata,
|
|
remove=remove,
|
|
replace_metadata=replace_metadata,
|
|
)
|
|
await runtime.store.async_save(payload)
|
|
return payload
|
|
|
|
|
|
async def async_save_config_state(
|
|
runtime: HouseplanData,
|
|
config: dict[str, Any],
|
|
rev: int,
|
|
*,
|
|
previous_rev: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Persist configuration and reconcile dependent operational state.
|
|
|
|
Callers already hold ``runtime.write_lock``. Reading the previous
|
|
revision here keeps less common writers (import recovery and undo) on the
|
|
same path as ordinary editor saves without duplicating lifecycle rules.
|
|
"""
|
|
if previous_rev is None:
|
|
previous = await runtime.config_store.async_load() or {}
|
|
try:
|
|
previous_rev = int(previous.get("rev", 0))
|
|
except (TypeError, ValueError):
|
|
previous_rev = 0
|
|
|
|
canonical_config = canonicalize_config_geometry(config)
|
|
payload = {"config": canonical_config, "rev": rev}
|
|
await runtime.config_store.async_save(payload)
|
|
|
|
# The config is already durable at this point. Reconciliation remains a
|
|
# separate Store write; an interrupted pair is detected from config_rev on
|
|
# the next read and fails safe to the compatibility default (all on).
|
|
from .virtual_lights import async_reconcile_virtual_lights
|
|
|
|
try:
|
|
controller = getattr(runtime, "virtual_lights", None)
|
|
if controller is not None:
|
|
await controller.async_flush()
|
|
await async_reconcile_virtual_lights(
|
|
runtime.virtual_light_store,
|
|
canonical_config,
|
|
rev,
|
|
previous_config_rev=previous_rev,
|
|
)
|
|
if controller is not None:
|
|
controller.reset()
|
|
except Exception: # noqa: BLE001 - config commit already stands
|
|
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
|
|
return payload
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolvedStorePair:
|
|
"""One coherent config/layout read, optionally after pending recovery."""
|
|
|
|
config_data: dict[str, Any]
|
|
layout_data: dict[str, Any]
|
|
recovered_kind: str | None = None
|
|
|
|
|
|
def _pending_target(
|
|
layout_data: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
"""Return a structurally usable paired-write intent, if one is present."""
|
|
pending = layout_data.get(OPTIMIZE_PENDING)
|
|
if not isinstance(pending, dict):
|
|
return None
|
|
if not isinstance(pending.get("config"), dict):
|
|
return None
|
|
if not isinstance(pending.get("layout"), dict):
|
|
return None
|
|
return pending
|
|
|
|
|
|
async def async_converge_store_pair(
|
|
runtime: HouseplanData,
|
|
pending: dict[str, Any],
|
|
*,
|
|
config_data: dict[str, Any] | None = None,
|
|
layout_data: dict[str, Any] | None = None,
|
|
) -> ResolvedStorePair:
|
|
"""Converge both stores on one durable paired-write intent.
|
|
|
|
Callers hold ``runtime.write_lock``. Store may raise after bytes reached
|
|
disk, so each failed half is reloaded and compared with the exact payload
|
|
before the exception is allowed to escape.
|
|
"""
|
|
if config_data is None:
|
|
config_data = await runtime.config_store.async_load() or {}
|
|
if layout_data is None:
|
|
layout_data = await runtime.store.async_load() or {}
|
|
|
|
target_config = canonicalize_config_geometry(pending["config"])
|
|
target_layout = canonicalize_layout_geometry(pending["layout"])
|
|
config_rev = int(config_data.get("rev", 0))
|
|
layout_rev = int(layout_data.get("rev", 0))
|
|
target_config_rev = int(pending.get(
|
|
"config_rev", config_rev + (config_data.get("config") != target_config)
|
|
))
|
|
target_layout_rev = int(pending.get(
|
|
"layout_rev", layout_rev + (layout_data.get("layout", {}) != target_layout)
|
|
))
|
|
|
|
exact_metadata = pending.get("final_metadata")
|
|
replace_metadata = isinstance(exact_metadata, dict)
|
|
config_needs_write = (
|
|
config_data.get("config") != target_config
|
|
or (
|
|
config_rev != target_config_rev
|
|
if replace_metadata
|
|
else config_rev < target_config_rev
|
|
)
|
|
)
|
|
if config_needs_write:
|
|
previous_config_rev = config_rev
|
|
config_rev = (
|
|
target_config_rev
|
|
if replace_metadata
|
|
else max(config_rev, target_config_rev)
|
|
)
|
|
expected_config = {"config": target_config, "rev": config_rev}
|
|
try:
|
|
config_data = await async_save_config_state(
|
|
runtime,
|
|
target_config,
|
|
config_rev,
|
|
previous_rev=previous_config_rev,
|
|
)
|
|
except Exception:
|
|
# A Store write is allowed to fail after its atomic replacement.
|
|
# Exact reload distinguishes that case from a half that never
|
|
# reached disk; no inference is made from the exception itself.
|
|
config_data = await runtime.config_store.async_load() or {}
|
|
if config_data != expected_config:
|
|
raise
|
|
|
|
layout_rev = (
|
|
target_layout_rev
|
|
if replace_metadata
|
|
else max(layout_rev, target_layout_rev)
|
|
)
|
|
metadata = dict(exact_metadata) if replace_metadata else None
|
|
if (
|
|
not replace_metadata
|
|
and not pending.get("clear_backup")
|
|
and OPTIMIZE_BACKUP in layout_data
|
|
):
|
|
metadata = {OPTIMIZE_BACKUP: layout_data[OPTIMIZE_BACKUP]}
|
|
remove_metadata = [OPTIMIZE_PENDING, OPTIMIZE_BACKUP]
|
|
if pending.get("clear_backup"):
|
|
# A recovered whole-plan undo replaces the complete layout; a nested
|
|
# repair snapshot from that replaced layout must not survive it.
|
|
remove_metadata.append("repair_backup")
|
|
expected_layout = layout_store_payload(
|
|
layout_data,
|
|
target_layout,
|
|
layout_rev,
|
|
metadata=metadata,
|
|
remove=tuple(remove_metadata),
|
|
replace_metadata=replace_metadata,
|
|
)
|
|
try:
|
|
layout_data = await async_save_layout_state(
|
|
runtime,
|
|
layout_data,
|
|
target_layout,
|
|
layout_rev,
|
|
metadata=metadata,
|
|
remove=tuple(remove_metadata),
|
|
replace_metadata=replace_metadata,
|
|
)
|
|
except Exception:
|
|
layout_data = await runtime.store.async_load() or {}
|
|
if layout_data != expected_layout:
|
|
raise
|
|
|
|
# Return fresh durable documents. A following writer must never continue
|
|
# with pre-recovery revisions or a pre-recovery layout snapshot.
|
|
config_data = await runtime.config_store.async_load() or {}
|
|
layout_data = await runtime.store.async_load() or {}
|
|
return ResolvedStorePair(
|
|
config_data=config_data,
|
|
layout_data=layout_data,
|
|
recovered_kind=str(pending.get("kind") or "plan optimization"),
|
|
)
|
|
|
|
|
|
async def async_resolve_pending_pair(runtime: HouseplanData) -> ResolvedStorePair:
|
|
"""Load a coherent pair, finishing a valid durable intent first."""
|
|
config_data = await runtime.config_store.async_load() or {}
|
|
layout_data = await runtime.store.async_load() or {}
|
|
pending = _pending_target(layout_data)
|
|
if pending is None:
|
|
return ResolvedStorePair(config_data=config_data, layout_data=layout_data)
|
|
return await async_converge_store_pair(
|
|
runtime,
|
|
pending,
|
|
config_data=config_data,
|
|
layout_data=layout_data,
|
|
)
|