mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
279 lines
13 KiB
Python
Executable File
279 lines
13 KiB
Python
Executable File
"""House Plan: server-side house plan configuration + Lovelace card serving."""
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import logging
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ConfigEntryNotReady
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
|
|
from . import websocket_api as hp_ws
|
|
from .const import (
|
|
ASSETS_DIR,
|
|
DOMAIN,
|
|
FILES_DIR,
|
|
PLANS_DIR,
|
|
)
|
|
from .frontend_registration import (
|
|
async_remove_frontend_registration,
|
|
async_setup_frontend_registration,
|
|
)
|
|
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
|
from .panel_registration import (
|
|
async_setup_panel_registration,
|
|
remove_panel_registration,
|
|
)
|
|
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
|
from .repairs import async_check_plan_files
|
|
from .store import (
|
|
HouseplanConfigEntry,
|
|
async_resolve_pending_pair,
|
|
async_save_config_state,
|
|
async_save_layout_state,
|
|
create_data,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
|
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
|
hass.data.setdefault(DOMAIN, {})
|
|
hp_ws.async_register(hass)
|
|
from .frontend_assets import HouseplanFrontendAssetView
|
|
from .http_api import (
|
|
HouseplanContentView,
|
|
HouseplanDecorAssetUploadView,
|
|
HouseplanImportPreviewView,
|
|
HouseplanUploadView,
|
|
)
|
|
|
|
hass.http.register_view(HouseplanUploadView())
|
|
hass.http.register_view(HouseplanDecorAssetUploadView())
|
|
hass.http.register_view(HouseplanContentView())
|
|
hass.http.register_view(HouseplanImportPreviewView())
|
|
hass.http.register_view(HouseplanFrontendAssetView())
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
|
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
|
|
data = create_data(hass)
|
|
# Home Assistant's installation id never leaves the instance. Exports
|
|
# carry only a salted SHA-256 fingerprint so same-instance internal files
|
|
# can be distinguished from cross-instance references.
|
|
try:
|
|
from homeassistant.helpers import instance_id as ha_instance_id
|
|
|
|
value = ha_instance_id.async_get(hass)
|
|
data.instance_id = str(await value if inspect.isawaitable(value) else value)
|
|
except Exception: # noqa: BLE001 - old HA/test harness fallback
|
|
data.instance_id = str(entry.entry_id)
|
|
# test-before-setup: storage must be readable, otherwise retry later
|
|
try:
|
|
await data.store.async_load()
|
|
await data.config_store.async_load()
|
|
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
|
|
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
|
try:
|
|
await data.virtual_light_store.async_load()
|
|
except Exception: # noqa: BLE001 — operational state fails safe to default on
|
|
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
|
|
entry.runtime_data = data
|
|
|
|
from .radar import RadarCoordinator
|
|
data.radar_coordinator = RadarCoordinator(hass, data)
|
|
await data.radar_coordinator.async_setup()
|
|
|
|
# server-side vacuum trails: the integration records the path itself
|
|
from .trails import TrailRecorder
|
|
recorder = TrailRecorder(hass, data)
|
|
await recorder.async_setup()
|
|
# setdefault: the CI harness sets entries up without async_setup, so
|
|
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
|
|
# downstream WS test with unknown_error
|
|
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
|
|
|
|
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
|
plans_path = Path(hass.config.path(PLANS_DIR))
|
|
files_path = Path(hass.config.path(FILES_DIR))
|
|
assets_path = Path(hass.config.path(ASSETS_DIR))
|
|
await hass.async_add_executor_job(
|
|
lambda: (
|
|
plans_path.mkdir(parents=True, exist_ok=True),
|
|
files_path.mkdir(parents=True, exist_ok=True),
|
|
assets_path.mkdir(parents=True, exist_ok=True),
|
|
)
|
|
)
|
|
|
|
# NOTE (audit B1): plans and marker files are no longer static. They are
|
|
# served by HouseplanContentView, which requires auth. Only the manifest-
|
|
# gated frontend bundle stays public so Lovelace can load it. Registration
|
|
# failures are observable and recoverable, but never skip storage migration,
|
|
# repairs or housekeeping below.
|
|
await async_setup_frontend_registration(hass, entry, card_path)
|
|
|
|
# Resolve an interrupted whole-plan pair before any other setup-time
|
|
# writer reads or mutates either store. This is the same fence used by
|
|
# runtime writers, so startup migration cannot build on a half-commit.
|
|
optimize_revs: tuple[int, int] | None = None
|
|
recovered_import = False
|
|
async with data.write_lock:
|
|
resolved = await async_resolve_pending_pair(data)
|
|
if resolved.recovered_kind is not None:
|
|
optimize_revs = (
|
|
int(resolved.config_data.get("rev", 0)),
|
|
int(resolved.layout_data.get("rev", 0)),
|
|
)
|
|
recovered_import = resolved.recovered_kind.startswith("import")
|
|
_LOGGER.warning(
|
|
"House Plan: completed an interrupted %s",
|
|
resolved.recovered_kind.replace("_", " "),
|
|
)
|
|
|
|
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
|
# normalised against a per-space aspect ratio; the canvas is now always
|
|
# square and a plan is centred inside it. Nothing about the drawing changes
|
|
# — the box is padded and the numbers re-expressed against it.
|
|
# The two stores are written independently, and the lock is no transaction:
|
|
# a crash between the writes used to leave the config in square coordinates
|
|
# with the layout still in the old ones — permanently, because the config
|
|
# write had already deleted the `aspect` fields the layout half needed
|
|
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
|
|
# and each half carries its own trigger with its own write: the config half
|
|
# removes `aspect`, the layout half removes the saved intent. Whatever
|
|
# half is missing after a crash, the next start finishes exactly it.
|
|
async with data.write_lock:
|
|
stored = await data.config_store.async_load() or {}
|
|
cfg = stored.get("config")
|
|
lay_stored = await data.store.async_load() or {}
|
|
layout = lay_stored.get("layout") or {}
|
|
pending = {
|
|
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
|
}
|
|
merged = {**pending, **pending_from_config(cfg)}
|
|
if merged:
|
|
lay_rev = int(lay_stored.get("rev", 0))
|
|
if merged != pending: # 1. the durable intent, before anything moves
|
|
await async_save_layout_state(
|
|
data, lay_stored, layout, lay_rev,
|
|
metadata={"geom_pending": merged}, remove=("geom_pending",),
|
|
)
|
|
rev = int(stored.get("rev", 0))
|
|
if cfg and migrate_config(cfg): # 2. the config half
|
|
rev += 1
|
|
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
|
|
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
|
await async_save_layout_state(
|
|
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
|
|
)
|
|
_LOGGER.info(
|
|
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
|
)
|
|
# only once both halves are durable — a client refetching on this
|
|
# event must never see one migrated half and one old one
|
|
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
|
|
|
if optimize_revs is not None:
|
|
# Setup-time geometry migration may have advanced either revision
|
|
# after pair recovery. Events always describe the final durable stores.
|
|
recovered_config = await data.config_store.async_load() or {}
|
|
recovered_layout = await data.store.async_load() or {}
|
|
optimize_revs = (
|
|
int(recovered_config.get("rev", 0)),
|
|
int(recovered_layout.get("rev", 0)),
|
|
)
|
|
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
|
|
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
|
|
if recovered_import:
|
|
current = (await data.config_store.async_load() or {}).get("config") or {}
|
|
await recorder.async_purge_orphans(current)
|
|
await recorder.async_refresh()
|
|
|
|
await async_check_plan_files(hass, entry)
|
|
|
|
# Scheduled collection of everything nobody ended up referencing.
|
|
#
|
|
# A commit collects what that commit superseded, which is the right rule for
|
|
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
|
# after the file has already uploaded, lose the connection after the upload
|
|
# succeeded, or call the API directly, and the file is unreferenced with no
|
|
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
|
# only removed streaming temporaries, which are a different, narrower case.
|
|
#
|
|
# Passing the CURRENT configuration as both sides means "nothing was
|
|
# superseded": every referenced file is preserved and only unreferenced ones
|
|
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
|
# so it cannot decide from a snapshot that a commit is about to replace.
|
|
async def _sweep(_now=None) -> None:
|
|
files_dir = Path(hass.config.path(FILES_DIR))
|
|
plans_dir = Path(hass.config.path(PLANS_DIR))
|
|
try:
|
|
# `data` from the closure, NOT get_data(hass): during
|
|
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
|
# async_loaded_entries() does not list it and the lookup returned
|
|
# None. The startup pass then silently degraded to removing
|
|
# streaming temporaries only, and the real collection waited a full
|
|
# day — restarting more often than that meant it never ran at all
|
|
# (HP-1462-01). The callback is unregistered with the entry, so
|
|
# closing over its runtime data matches the lifecycle exactly.
|
|
async with data.write_lock:
|
|
stored = await data.config_store.async_load() or {}
|
|
cfg = stored.get("config") or {}
|
|
|
|
def _collect() -> int:
|
|
n = sweep_upload_temps(files_dir)
|
|
# same config on both sides: nothing is superseded, so this
|
|
# only ever collects what the shared rules call abandoned
|
|
n += collect_attachments(files_dir, cfg, cfg)
|
|
n += collect_plans(plans_dir, cfg, cfg)
|
|
return n
|
|
|
|
n = await hass.async_add_executor_job(_collect)
|
|
if n:
|
|
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
|
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
|
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
|
|
|
data.sweep = _sweep
|
|
await _sweep()
|
|
entry.async_on_unload(
|
|
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
|
)
|
|
|
|
# The application panel is intentionally the final setup step. It is a
|
|
# discoverability surface over the already-working stores and APIs, so a
|
|
# missing/colliding/broken panel remains fail-soft and cannot strand a
|
|
# sidebar entry after a migration, repair or initial housekeeping failure.
|
|
panel_path = Path(__file__).parent / "frontend" / "houseplan-panel.js"
|
|
await async_setup_panel_registration(hass, entry, panel_path)
|
|
return True
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
|
"""Unload the entry.
|
|
|
|
WS commands and the HTTP view are global (async_setup) and stay registered —
|
|
their handlers resolve runtime data per call and answer `not_ready` while no
|
|
entry is loaded. Static paths cannot be unregistered by design.
|
|
"""
|
|
remove_panel_registration(hass)
|
|
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
|
if rec:
|
|
rec.teardown()
|
|
if entry.runtime_data.radar_coordinator:
|
|
entry.runtime_data.radar_coordinator.teardown()
|
|
entry.runtime_data.radar_coordinator = None
|
|
return True
|
|
|
|
|
|
async def async_remove_entry(
|
|
hass: HomeAssistant, entry: HouseplanConfigEntry
|
|
) -> None:
|
|
"""Clean up frontend registration and notice on integration removal."""
|
|
remove_panel_registration(hass)
|
|
await async_remove_frontend_registration(hass, entry)
|