mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
Deployed v1.46.3 to my own instance, restarted, and the startup sweep deleted
both floor plans: config/houseplan/plans/ went from f1.svg + f2.png to empty.
The backup is a SecureTar, so they are gone.
The rule was wrong, not the code. v1.46.0 introduced collection that treats
'nothing references this right now' as abandoned and gives it an hour. But
detaching a plan — switching a space to 'draw' — is a normal, reversible action,
and the editor's own comment says the file stays on disk. Those two plans had
been detached for weeks; every pass since v1.46.0 was entitled to remove them,
and the one that finally ran did.
New rule, one for every path:
* superseded by a commit (was in the old revision, is not in the new) — goes
immediately; that is the one thing a commit knows for certain;
* belongs to a space or marker that still exists — never collected, at any
age, because unreferenced is not abandoned;
* a per-dialog staging folder (up_*) — one hour, unchanged: by construction it
only ever holds an upload from a dialog that was never saved;
* anything else — thirty days.
The flag I added an hour ago is gone with it: two rules for the same
question is how this happened. Tests updated to the new grace, plus two that pin
the distinction directly.
I am sorry about the files.
223 lines
10 KiB
Python
Executable File
223 lines
10 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 .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,
|
|
)
|
|
|
|
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
|