mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.
From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
the first write deleted the aspects the second needed — a crash between
them stranded the layout in the old coordinates with nothing able to
finish it. The intent {space: old aspect} is durable now: saved to the
layout store before anything moves, cleared by the same write that stores
the migrated layout, each half idempotent behind its own trigger. The
update event fires only after both halves are on disk. Proven at the exact
crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
nothing between them, so N parallel uploads all measured the store before
any of them wrote. One job under a dedicated upload_lock now — narrower
than write_lock on purpose, a directory scan must not stall config saves.
A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
the editors were boxed into yesterday's drawing. Edit modes measure from
the full square; mode switches refit rather than carry a view clamped
against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
file's ratio. Picking a plan clears it immediately; Save awaits the
bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.
New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
264 lines
12 KiB
Python
Executable File
264 lines
12 KiB
Python
Executable File
"""House Plan: server-side house plan configuration + Lovelace card serving."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from homeassistant.components.frontend import add_extra_js_url
|
|
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 (
|
|
DOMAIN,
|
|
FILES_DIR,
|
|
FILES_URL,
|
|
FRONTEND_URL,
|
|
PLANS_DIR,
|
|
PLANS_URL,
|
|
VERSION,
|
|
)
|
|
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
|
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
|
from .repairs import async_check_plan_files
|
|
from .store import HouseplanConfigEntry, 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 .http_api import HouseplanContentView, HouseplanUploadView
|
|
|
|
hass.http.register_view(HouseplanUploadView())
|
|
hass.http.register_view(HouseplanContentView())
|
|
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)
|
|
# 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
|
|
entry.runtime_data = data
|
|
|
|
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))
|
|
await hass.async_add_executor_job(
|
|
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
|
|
)
|
|
|
|
# Static paths cannot be unregistered — register once per HA run.
|
|
if not hass.data[DOMAIN].get("static_registered"):
|
|
hass.data[DOMAIN]["static_registered"] = True
|
|
static_paths = []
|
|
try:
|
|
from homeassistant.components.http import StaticPathConfig
|
|
|
|
if card_path.exists():
|
|
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
|
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
|
# They are served by HouseplanContentView, which requires auth.
|
|
# Only the card bundle stays public — Lovelace resources must be.
|
|
if static_paths:
|
|
await hass.http.async_register_static_paths(static_paths)
|
|
except ImportError: # very old HA versions
|
|
if card_path.exists():
|
|
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
|
|
|
if not card_path.exists():
|
|
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
|
return True
|
|
|
|
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
|
# resources before rendering dashboards, so the card is available even on a cold
|
|
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
|
# Lovelace, old versions) — fall back to extra_module_url.
|
|
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
|
registered = await _register_lovelace_resource(hass, module_url)
|
|
if not registered:
|
|
add_extra_js_url(hass, module_url)
|
|
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
|
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
|
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
|
if registered:
|
|
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
|
else:
|
|
_LOGGER.info(
|
|
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
|
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
|
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
|
module_url, module_url,
|
|
)
|
|
|
|
# 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 data.store.async_save(
|
|
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
|
)
|
|
rev = int(stored.get("rev", 0))
|
|
if cfg and migrate_config(cfg): # 2. the config half
|
|
rev += 1
|
|
await data.config_store.async_save({"config": cfg, "rev": rev})
|
|
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
|
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
|
|
_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})
|
|
|
|
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))
|
|
)
|
|
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.
|
|
"""
|
|
return True
|
|
|
|
|
|
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
|
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
|
try:
|
|
resources = _lovelace_resources(hass)
|
|
if resources is None or not hasattr(resources, "async_delete_item"):
|
|
return
|
|
for item in list(resources.async_items()):
|
|
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
|
await resources.async_delete_item(item["id"])
|
|
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
|
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
|
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
|
|
|
|
|
def _lovelace_resources(hass: HomeAssistant):
|
|
lovelace = hass.data.get("lovelace")
|
|
resources = getattr(lovelace, "resources", None)
|
|
if resources is None and isinstance(lovelace, dict):
|
|
resources = lovelace.get("resources")
|
|
return resources
|
|
|
|
|
|
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
|
"""Register (or update) the card in the Lovelace resource registry.
|
|
|
|
Returns True on success. Idempotent: if a resource with our path exists —
|
|
update the URL on version change; otherwise create it. Any exception → False
|
|
(fall back to extra_module_url).
|
|
"""
|
|
try:
|
|
resources = _lovelace_resources(hass)
|
|
if resources is None:
|
|
return False
|
|
# the resource registry must be loaded
|
|
if hasattr(resources, "loaded") and not resources.loaded:
|
|
await resources.async_load()
|
|
resources.loaded = True
|
|
elif hasattr(resources, "async_get_info"):
|
|
await resources.async_get_info()
|
|
# only storage mode allows creating items
|
|
if not hasattr(resources, "async_create_item"):
|
|
return False
|
|
base = FRONTEND_URL
|
|
existing = [
|
|
item for item in resources.async_items()
|
|
if str(item.get("url", "")).split("?", 1)[0] == base
|
|
]
|
|
if existing:
|
|
item = existing[0]
|
|
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
|
|
await resources.async_update_item(item["id"], {"url": module_url})
|
|
return True
|
|
await resources.async_create_item({"res_type": "module", "url": module_url})
|
|
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
|
return True
|
|
except Exception as err: # noqa: BLE001 — any failure → fallback
|
|
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
|
return False
|