mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: make card resource recovery observable
Issue: #462 User-Visible: yes
This commit is contained in:
@@ -6,7 +6,6 @@ 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
|
||||
@@ -16,9 +15,11 @@ from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
VERSION,
|
||||
)
|
||||
from .frontend_registration import (
|
||||
async_remove_frontend_registration,
|
||||
async_setup_frontend_registration,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
@@ -99,49 +100,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
)
|
||||
)
|
||||
|
||||
# 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 entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
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,
|
||||
)
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
@@ -319,61 +283,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
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
|
||||
async def async_remove_entry(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Clean up frontend registration and notice on integration removal."""
|
||||
await async_remove_frontend_registration(hass, entry)
|
||||
|
||||
@@ -1,182 +1,182 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "35f0e17be2b28708e1a70d78cbb7fe74381184cfa9b246c8bffe8fa2e48a2f02",
|
||||
"fingerprint": "af3364a8724ae15baba13250bdaf405410c843f9be210b09047dee3c637908ac",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js",
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 297490,
|
||||
"initialViewGzipBytes": 300088,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-DTZnKoij.js",
|
||||
"houseplan-assets/de-BkO6b_ix.js",
|
||||
"houseplan-assets/editor-DLgP_Zhm.js",
|
||||
"houseplan-assets/fr-DPKuvjxy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BYdM6g4w.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DPK9GD4u.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-C4kHv_pH.js",
|
||||
"houseplan-assets/live-interaction-runtime-DaFXqe24.js",
|
||||
"houseplan-assets/zigbee-topology-DLAdvvBO.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVA7jpgE.js"
|
||||
"houseplan-assets/backdrop-pick-D_4HHm8a.js",
|
||||
"houseplan-assets/de-Bd53-1_8.js",
|
||||
"houseplan-assets/editor-Cust4Np0.js",
|
||||
"houseplan-assets/fr-litAD70E.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Di1PIDII.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-KUMEor1Q.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-BHqLEsDr.js",
|
||||
"houseplan-assets/live-interaction-runtime-C5JlXkTi.js",
|
||||
"houseplan-assets/zigbee-topology-B8opqBEW.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CydVHZBm.js"
|
||||
],
|
||||
"lazyGzipBytes": 250680,
|
||||
"lazyGzipBytes": 250876,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-DTZnKoij.js",
|
||||
"houseplan-assets/editor-DLgP_Zhm.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BYdM6g4w.js",
|
||||
"houseplan-assets/zigbee-topology-DLAdvvBO.js"
|
||||
"houseplan-assets/backdrop-pick-D_4HHm8a.js",
|
||||
"houseplan-assets/editor-Cust4Np0.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Di1PIDII.js",
|
||||
"houseplan-assets/zigbee-topology-B8opqBEW.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 186673,
|
||||
"lazyEditorGzipBytes": 186637,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-DTZnKoij.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DPK9GD4u.js"
|
||||
"houseplan-assets/backdrop-pick-D_4HHm8a.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-KUMEor1Q.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14311,
|
||||
"lazyOnboardingGzipBytes": 14324,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-BkO6b_ix.js",
|
||||
"houseplan-assets/fr-DPKuvjxy.js"
|
||||
"houseplan-assets/de-Bd53-1_8.js",
|
||||
"houseplan-assets/fr-litAD70E.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49929,
|
||||
"lazyLocaleGzipBytes": 50146,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-DTZnKoij.js",
|
||||
"sha256": "eb3bb26579a491c0558962ce376120b75bc3a33007f2e07bddf90d0bc57e6a36",
|
||||
"path": "houseplan-assets/backdrop-pick-D_4HHm8a.js",
|
||||
"sha256": "5abe7f20bdb347a01cc36b9a01612e3e8b52a60f87b03c008a53ebe308348c7a",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7355,
|
||||
"gzipBytes": 7358,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js"
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-BkO6b_ix.js",
|
||||
"sha256": "df01029edd46adae9793efe98360e91f000bb016d26aced7774b298f7512da60",
|
||||
"rawBytes": 83907,
|
||||
"gzipBytes": 25239,
|
||||
"path": "houseplan-assets/de-Bd53-1_8.js",
|
||||
"sha256": "25d05d0d6b592ddcf52bb774fe905af9278ae55765448361b4e158d994589d2e",
|
||||
"rawBytes": 84232,
|
||||
"gzipBytes": 25350,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DLgP_Zhm.js",
|
||||
"sha256": "70f52c624e4b857526a575072c0c786d1192daf4aa6f5f2fcee9add1eca35c7e",
|
||||
"path": "houseplan-assets/editor-Cust4Np0.js",
|
||||
"sha256": "70d940744afdaf5b64641d42b0704075f8da5e59d80e5f005cfc6d825c1c85b5",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1581,
|
||||
"gzipBytes": 1584,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js"
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-DPKuvjxy.js",
|
||||
"sha256": "980afcae02130691db67caadc8a4311859ca3ae776f24dec0be7c11a1dbf0d2d",
|
||||
"rawBytes": 85967,
|
||||
"gzipBytes": 24690,
|
||||
"path": "houseplan-assets/fr-litAD70E.js",
|
||||
"sha256": "2d10831cc53a40e1da37a258105dcc134be0522c1c800713ff4f26746fdf3280",
|
||||
"rawBytes": 86273,
|
||||
"gzipBytes": 24796,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Cv78HSdm.js",
|
||||
"sha256": "4b365febbd3141eb8e5ba7cc765e6c95459ad72f04d0341e4363e5a02dbe002b",
|
||||
"rawBytes": 1058176,
|
||||
"gzipBytes": 296696,
|
||||
"path": "houseplan-assets/houseplan-card-Mp6kjVYN.js",
|
||||
"sha256": "46ceafd4b7666ee01be9a762833b40cfc4cc68a20840b98639cc9990f9806dda",
|
||||
"rawBytes": 1068746,
|
||||
"gzipBytes": 299292,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-BkO6b_ix.js",
|
||||
"houseplan-assets/editor-DLgP_Zhm.js",
|
||||
"houseplan-assets/fr-DPKuvjxy.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BYdM6g4w.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DPK9GD4u.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-C4kHv_pH.js",
|
||||
"houseplan-assets/live-interaction-runtime-DaFXqe24.js"
|
||||
"houseplan-assets/de-Bd53-1_8.js",
|
||||
"houseplan-assets/editor-Cust4Np0.js",
|
||||
"houseplan-assets/fr-litAD70E.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-Di1PIDII.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-KUMEor1Q.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-BHqLEsDr.js",
|
||||
"houseplan-assets/live-interaction-runtime-C5JlXkTi.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BYdM6g4w.js",
|
||||
"sha256": "6af3e2b8a0a7296c61055b0fe3ca3cb7d2a0de7100072068b4f5ac2f40d31219",
|
||||
"rawBytes": 639791,
|
||||
"gzipBytes": 170313,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-Di1PIDII.js",
|
||||
"sha256": "edb7d0092827810ef620e74e34a371dfd4a7f157be2b1e5d02f9d46c25d6b75d",
|
||||
"rawBytes": 639587,
|
||||
"gzipBytes": 170266,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DTZnKoij.js",
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js",
|
||||
"houseplan-assets/zigbee-topology-DLAdvvBO.js"
|
||||
"houseplan-assets/backdrop-pick-D_4HHm8a.js",
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js",
|
||||
"houseplan-assets/zigbee-topology-B8opqBEW.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CVA7jpgE.js"
|
||||
"houseplan-assets/zigbee-topology-runtime-CydVHZBm.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-DPK9GD4u.js",
|
||||
"sha256": "fe8f133ec0e2b14d75735f1573f8bbbcc1373fd329b1aa842a3718e17b5f9746",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6956,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-KUMEor1Q.js",
|
||||
"sha256": "5ad8218bb88621fd11b1e984d53ec90b6de59438edb994d4b81a7b4f9ebe4506",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6966,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-DTZnKoij.js",
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js"
|
||||
"houseplan-assets/backdrop-pick-D_4HHm8a.js",
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-C4kHv_pH.js",
|
||||
"sha256": "bc030e15b7d285c103e1e380ccd127921c802acf7472e543ea008161468eb1da",
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-BHqLEsDr.js",
|
||||
"sha256": "a2672627c0af37b0718647ccd3917cce51a40fee9d614e9fbc6f450559026426",
|
||||
"rawBytes": 8626,
|
||||
"gzipBytes": 3236,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js",
|
||||
"houseplan-assets/zigbee-topology-DLAdvvBO.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVA7jpgE.js"
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js",
|
||||
"houseplan-assets/zigbee-topology-B8opqBEW.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CydVHZBm.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-DaFXqe24.js",
|
||||
"sha256": "589e899705fb7ddb456c14865edfc0878b758b83fe24c911203918eebb421029",
|
||||
"path": "houseplan-assets/live-interaction-runtime-C5JlXkTi.js",
|
||||
"sha256": "e0e599935cb2128f175b9b56f6dcb5d8be51617a2c7db2d86f5c539549799d15",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2125,
|
||||
"gzipBytes": 2127,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js"
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DLAdvvBO.js",
|
||||
"sha256": "e58d817c921cbbfdeabe1b685ad0e57e25be95b20870e70097d6a80465880dcf",
|
||||
"path": "houseplan-assets/zigbee-topology-B8opqBEW.js",
|
||||
"sha256": "29442bb965c3b19980e9ed0561e2fe034611593d29b0456da1030aef700044f8",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7424,
|
||||
"gzipBytes": 7429,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js"
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CVA7jpgE.js",
|
||||
"sha256": "98b698a9fe5bef9b2c59a5ecd51bea77e6726ab02a875d65c57336a99cf61a08",
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CydVHZBm.js",
|
||||
"sha256": "a1f63c2e74059c8daf3ccfe3137cc1ab84a88fe605cc5af02dd96064ca2fe9f1",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1761,
|
||||
"gzipBytes": 1764,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js",
|
||||
"houseplan-assets/zigbee-topology-DLAdvvBO.js"
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js",
|
||||
"houseplan-assets/zigbee-topology-B8opqBEW.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "bcd965045c38e837404f8de2ca2b0e0d9acc452ba194e8b0d6405cd49fec8616",
|
||||
"sha256": "a0353f8d538d0a837bbed8537e97ea9c542f5674fd1bc1a17951565046f16397",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 794,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Cv78HSdm.js"
|
||||
"houseplan-assets/houseplan-card-Mp6kjVYN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5
-5
@@ -1,14 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="35f0e17be2b28708e1a70d78cbb7fe74381184cfa9b246c8bffe8fa2e48a2f02";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-Cv78HSdm.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="af3364a8724ae15baba13250bdaf405410c843f9be210b09047dee3c637908ac";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-Mp6kjVYN.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+126
-21
File diff suppressed because one or more lines are too long
+5
-5
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="35f0e17be2b28708e1a70d78cbb7fe74381184cfa9b246c8bffe8fa2e48a2f02";import{n as t,b as n}from"./zigbee-topology-DLAdvvBO.js";import{I as e}from"./houseplan-card-Cv78HSdm.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="af3364a8724ae15baba13250bdaf405410c843f9be210b09047dee3c637908ac";import{n as t,b as n}from"./zigbee-topology-B8opqBEW.js";import{I as e}from"./houseplan-card-Mp6kjVYN.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="35f0e17be2b28708e1a70d78cbb7fe74381184cfa9b246c8bffe8fa2e48a2f02";try{await import("./houseplan-assets/houseplan-card-Cv78HSdm.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="af3364a8724ae15baba13250bdaf405410c843f9be210b09047dee3c637908ac";try{await import("./houseplan-assets/houseplan-card-Mp6kjVYN.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Register the House Plan card with the Home Assistant frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import frontend, persistent_notification
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import DOMAIN, FRONTEND_URL, VERSION
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGISTRATION_KEY = "frontend_registration"
|
||||
FRONTEND_STATIC_REGISTERED_KEY = "static_registered"
|
||||
FRONTEND_FALLBACK_URLS_KEY = "frontend_fallback_urls"
|
||||
FRONTEND_RELOAD_NOTICE_DATA_KEY = "frontend_reload_notice_created"
|
||||
FRONTEND_RELOAD_NOTICE_ID = "houseplan_frontend_reload_notice"
|
||||
FRONTEND_RETRY_DELAY_SECONDS = 1.0
|
||||
|
||||
_NOTICE_TITLE_KEY = "component.houseplan.issues.frontend_reload_notice.title"
|
||||
_NOTICE_DESCRIPTION_KEY = (
|
||||
"component.houseplan.issues.frontend_reload_notice.description"
|
||||
)
|
||||
_NOTICE_TITLE_FALLBACK = "House Plan card connected"
|
||||
_NOTICE_DESCRIPTION_FALLBACK = (
|
||||
"The House Plan card is connected. Fully reload this page to use the latest "
|
||||
"frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you "
|
||||
"manage dashboard resources manually in storage mode, open Settings → "
|
||||
"Dashboards → Resources."
|
||||
)
|
||||
|
||||
RegistrationStatus = Literal[
|
||||
"not_attempted",
|
||||
"created",
|
||||
"updated",
|
||||
"existing",
|
||||
"registry_pending",
|
||||
"yaml_fallback",
|
||||
"transient_error",
|
||||
"error_fallback",
|
||||
]
|
||||
ResourceLoader = Literal[
|
||||
"none",
|
||||
"lovelace_resource",
|
||||
"extra_module_url",
|
||||
"lovelace_resource_with_session_fallback",
|
||||
]
|
||||
ReloadNoticeStatus = Literal[
|
||||
"created",
|
||||
"already_created",
|
||||
"pending_frontend",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegistrationOutcome:
|
||||
"""Result of one Lovelace resource registry attempt."""
|
||||
|
||||
status: RegistrationStatus
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FrontendRegistrationState:
|
||||
"""Observable state and lifecycle handles for the current config-entry run."""
|
||||
|
||||
card_file_present: bool
|
||||
static_path_registered: bool = False
|
||||
resource_status: RegistrationStatus = "not_attempted"
|
||||
loader: ResourceLoader = "none"
|
||||
module_url: str | None = None
|
||||
retry_pending: bool = False
|
||||
retry_attempted: bool = False
|
||||
last_error: str | None = None
|
||||
first_reload_notice: ReloadNoticeStatus = "pending_frontend"
|
||||
active: bool = True
|
||||
fallback_added: bool = False
|
||||
_cancel_started: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_cancel_timer: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_retry_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
||||
_notice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
||||
|
||||
@callback
|
||||
def cancel(self) -> None:
|
||||
"""Prevent all delayed work for this setup generation."""
|
||||
self.active = False
|
||||
self.retry_pending = False
|
||||
if self._cancel_started is not None:
|
||||
self._cancel_started()
|
||||
self._cancel_started = None
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
if self._retry_task is not None and not self._retry_task.done():
|
||||
self._retry_task.cancel()
|
||||
self._retry_task = None
|
||||
|
||||
|
||||
def _safe_error(phase: str, err: BaseException) -> str:
|
||||
"""Return a support-safe failure fingerprint without message or path data."""
|
||||
return f"{phase}:{type(err).__name__}"
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
"""Return the Lovelace resource collection across supported HA versions."""
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
def _is_current(hass: HomeAssistant, state: FrontendRegistrationState) -> bool:
|
||||
"""Return whether a delayed operation still belongs to the loaded entry."""
|
||||
return (
|
||||
state.active
|
||||
and hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY) is state
|
||||
)
|
||||
|
||||
|
||||
def _owned_fallback_urls(hass: HomeAssistant) -> set[str]:
|
||||
"""Return exact extra-module URLs owned for this Home Assistant run."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
urls = domain_data.get(FRONTEND_FALLBACK_URLS_KEY)
|
||||
if not isinstance(urls, set):
|
||||
urls = set()
|
||||
domain_data[FRONTEND_FALLBACK_URLS_KEY] = urls
|
||||
return urls
|
||||
|
||||
|
||||
def _active_extra_module_urls(hass: HomeAssistant) -> frozenset[str] | None:
|
||||
"""Return HA's current module URLs, or None when the API is unknowable.
|
||||
|
||||
``add_extra_js_url`` is intentionally idempotent and has no ownership
|
||||
return value. Looking before adding is therefore the only way to avoid
|
||||
claiming (and later removing) an identical URL configured by the user.
|
||||
Both the oldest supported HA and current HA expose ``UrlManager.urls``;
|
||||
unknown future/legacy shapes fail closed and are never claimed.
|
||||
"""
|
||||
key = getattr(frontend, "DATA_EXTRA_MODULE_URL", None)
|
||||
if key is None:
|
||||
return None
|
||||
manager = hass.data.get(key)
|
||||
if manager is None:
|
||||
# Frontend setup normally creates the manager before this integration.
|
||||
# If it is genuinely absent, no configured URL can predate our add;
|
||||
# add_extra_js_url will either create/use its supported path or fail.
|
||||
return frozenset()
|
||||
urls = getattr(manager, "urls", None)
|
||||
if not isinstance(urls, (set, frozenset, list, tuple)):
|
||||
return None
|
||||
return frozenset(url for url in urls if isinstance(url, str))
|
||||
|
||||
|
||||
async def _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing: list[dict],
|
||||
canonical_id: str,
|
||||
*,
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Best-effort remove legacy duplicates after choosing one authority."""
|
||||
remover = getattr(resources, "async_delete_item", None)
|
||||
if remover is None:
|
||||
return
|
||||
for item in existing:
|
||||
if item.get("id") == canonical_id:
|
||||
continue
|
||||
if not is_active():
|
||||
return
|
||||
try:
|
||||
await remover(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - canonical registration still succeeded
|
||||
_LOGGER.debug(
|
||||
"Could not remove a duplicate House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def async_register_lovelace_resource(
|
||||
hass: HomeAssistant,
|
||||
module_url: str,
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
) -> RegistrationOutcome:
|
||||
"""Create or update the canonical Lovelace resource entry."""
|
||||
active = is_active or (lambda: True)
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return RegistrationOutcome("yaml_fallback")
|
||||
|
||||
try:
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
elif hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
|
||||
existing = [
|
||||
item
|
||||
for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL
|
||||
]
|
||||
if existing:
|
||||
# Prefer an already-current entry when legacy duplicates exist. This
|
||||
# preserves its stable id while the remaining same-base entries are
|
||||
# removed best effort below.
|
||||
item = next(
|
||||
(candidate for candidate in existing if candidate.get("url") == module_url),
|
||||
existing[0],
|
||||
)
|
||||
if item.get("url") == module_url and item.get("type") == "module":
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("existing")
|
||||
if not hasattr(resources, "async_update_item"):
|
||||
return RegistrationOutcome(
|
||||
"transient_error", "registry_update:UnsupportedOperation"
|
||||
)
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_update_item(
|
||||
item["id"], {"url": module_url, "res_type": "module"}
|
||||
)
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("updated")
|
||||
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_create_item(
|
||||
{"url": module_url, "res_type": "module"}
|
||||
)
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource")
|
||||
return RegistrationOutcome("created")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - typed fallback is intentional
|
||||
_LOGGER.debug("Could not register the House Plan Lovelace resource", exc_info=True)
|
||||
return RegistrationOutcome("transient_error", _safe_error("registry", err))
|
||||
|
||||
|
||||
def _add_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> None:
|
||||
"""Add this setup's exact versioned extra-module URL, best effort."""
|
||||
if state.module_url is None or not _is_current(hass, state):
|
||||
return
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
active_before = _active_extra_module_urls(hass)
|
||||
if active_before is not None and state.module_url in active_before:
|
||||
# The exact URL predates this setup. It is a working loader, but it is
|
||||
# not ours to remove after registry recovery or on uninstall.
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
try:
|
||||
frontend.add_extra_js_url(hass, state.module_url)
|
||||
except Exception as err: # noqa: BLE001 - frontend setup must remain available
|
||||
state.loader = "none"
|
||||
state.last_error = _safe_error("fallback_add", err)
|
||||
_LOGGER.warning("Could not add the House Plan frontend fallback", exc_info=True)
|
||||
return
|
||||
if active_before is not None:
|
||||
owned_urls.add(state.module_url)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"House Plan could not prove ownership of the frontend fallback; "
|
||||
"it will remain until Home Assistant restarts"
|
||||
)
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
|
||||
|
||||
def _remove_owned_fallback_url(
|
||||
hass: HomeAssistant,
|
||||
state: FrontendRegistrationState,
|
||||
module_url: str,
|
||||
) -> bool:
|
||||
"""Remove one exact integration-owned fallback when HA supports it."""
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if module_url not in owned_urls:
|
||||
return True
|
||||
remover = getattr(frontend, "remove_extra_js_url", None)
|
||||
if remover is None:
|
||||
return False
|
||||
try:
|
||||
remover(hass, module_url)
|
||||
except KeyError:
|
||||
# Another lifecycle path already removed the exact URL.
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 - registry remains authoritative
|
||||
state.last_error = _safe_error("fallback_remove", err)
|
||||
_LOGGER.debug("Could not remove the House Plan frontend fallback", exc_info=True)
|
||||
return False
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
|
||||
|
||||
def _remove_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> bool:
|
||||
"""Remove this setup's exact fallback when the running HA supports it."""
|
||||
if state.module_url is None:
|
||||
return True
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
removed = _remove_owned_fallback_url(hass, state, state.module_url)
|
||||
state.fallback_added = not removed
|
||||
return removed
|
||||
|
||||
# A pre-existing/user-owned or uninspectable exact URL must never be
|
||||
# removed. Report the combined loader while it is known (or conservatively
|
||||
# assumed) to remain active in this HA document lifecycle.
|
||||
active = _active_extra_module_urls(hass)
|
||||
state.fallback_added = active is None or state.module_url in active
|
||||
return not state.fallback_added
|
||||
|
||||
|
||||
async def _async_create_reload_notice(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Create and persist the one-time localized hard-reload instruction."""
|
||||
# Initial fallback setup and the one-second retry may overlap when loading a
|
||||
# translation is slow. Serialize the persisted one-shot contract rather
|
||||
# than relying only on the stable notification ID (which prevents two UI
|
||||
# rows but would still perform and persist two creates).
|
||||
async with state._notice_lock:
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True:
|
||||
if state.first_reload_notice != "created":
|
||||
state.first_reload_notice = "already_created"
|
||||
return
|
||||
if (
|
||||
not _is_current(hass, state)
|
||||
or not state.card_file_present
|
||||
or not state.static_path_registered
|
||||
or state.loader == "none"
|
||||
):
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
return
|
||||
|
||||
title = _NOTICE_TITLE_FALLBACK
|
||||
description = _NOTICE_DESCRIPTION_FALLBACK
|
||||
try:
|
||||
translations = await async_get_translations(
|
||||
hass,
|
||||
hass.config.language,
|
||||
"issues",
|
||||
integrations={DOMAIN},
|
||||
)
|
||||
title = translations.get(_NOTICE_TITLE_KEY, title)
|
||||
description = translations.get(_NOTICE_DESCRIPTION_KEY, description)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - built-in English fallback is intentional
|
||||
_LOGGER.debug(
|
||||
"Could not load the House Plan reload notice translation",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
try:
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
description,
|
||||
title=title,
|
||||
notification_id=FRONTEND_RELOAD_NOTICE_ID,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - do not consume flag on failure
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not create the House Plan reload notice", exc_info=True
|
||||
)
|
||||
return
|
||||
try:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - roll back the unpersisted notice
|
||||
# A stable ID prevents duplicate rows, but dismissing the notice is
|
||||
# still important: without a durable flag, displaying it would make
|
||||
# System Health claim that the one-shot contract is pending while a
|
||||
# user-visible notice already exists. A later setup/retry can now
|
||||
# attempt the complete create+persist operation honestly.
|
||||
try:
|
||||
persistent_notification.async_dismiss(
|
||||
hass, FRONTEND_RELOAD_NOTICE_ID
|
||||
)
|
||||
except Exception: # noqa: BLE001 - stable ID remains a safe fallback
|
||||
_LOGGER.debug(
|
||||
"Could not roll back an unpersisted House Plan reload notice",
|
||||
exc_info=True,
|
||||
)
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not persist the House Plan reload notice state",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
state.first_reload_notice = "created"
|
||||
|
||||
|
||||
async def _async_retry_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Perform the single delayed resource-registry retry."""
|
||||
if not _is_current(hass, state) or state.module_url is None:
|
||||
return
|
||||
state.retry_pending = False
|
||||
state.retry_attempted = True
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
elif outcome.status == "yaml_fallback":
|
||||
state.resource_status = "yaml_fallback"
|
||||
state.last_error = outcome.last_error
|
||||
_add_fallback(hass, state)
|
||||
else:
|
||||
state.resource_status = "error_fallback"
|
||||
state.last_error = outcome.last_error or "registry_retry:Unavailable"
|
||||
_add_fallback(hass, state)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
|
||||
|
||||
def _schedule_retry(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Schedule exactly one lifecycle-bound retry after HA start plus one second."""
|
||||
state.retry_pending = True
|
||||
|
||||
@callback
|
||||
def _after_started(_event=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_started = None
|
||||
|
||||
@callback
|
||||
def _after_delay(_now=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_timer = None
|
||||
state._retry_task = entry.async_create_background_task(
|
||||
hass,
|
||||
_async_retry_registration(hass, entry, state),
|
||||
"houseplan frontend resource retry",
|
||||
)
|
||||
|
||||
state._cancel_timer = async_call_later(
|
||||
hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay
|
||||
)
|
||||
|
||||
state._cancel_started = async_at_started(hass, _after_started)
|
||||
|
||||
|
||||
async def async_setup_frontend_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
card_path: Path,
|
||||
) -> FrontendRegistrationState:
|
||||
"""Register static/card resources without blocking the rest of entry setup."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
previous = domain_data.get(FRONTEND_REGISTRATION_KEY)
|
||||
if isinstance(previous, FrontendRegistrationState):
|
||||
previous.cancel()
|
||||
|
||||
card_file_present = card_path.is_file()
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}" if card_file_present else None
|
||||
active_fallbacks = _active_extra_module_urls(hass)
|
||||
fallback_added = module_url is not None and (
|
||||
module_url in _owned_fallback_urls(hass)
|
||||
or (active_fallbacks is not None and module_url in active_fallbacks)
|
||||
)
|
||||
state = FrontendRegistrationState(
|
||||
card_file_present=card_file_present,
|
||||
loader="extra_module_url" if fallback_added else "none",
|
||||
module_url=module_url,
|
||||
first_reload_notice=(
|
||||
"already_created"
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True
|
||||
else "pending_frontend"
|
||||
),
|
||||
fallback_added=fallback_added,
|
||||
)
|
||||
domain_data[FRONTEND_REGISTRATION_KEY] = state
|
||||
entry.async_on_unload(state.cancel)
|
||||
|
||||
if domain_data.get(FRONTEND_STATIC_REGISTERED_KEY):
|
||||
state.static_path_registered = True
|
||||
elif card_file_present:
|
||||
try:
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
except ImportError: # Home Assistant versions before the async API
|
||||
hass.http.register_static_path(
|
||||
FRONTEND_URL, str(card_path), cache_headers=False
|
||||
)
|
||||
else:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - setup continues without frontend
|
||||
state.last_error = _safe_error("static_path", err)
|
||||
_LOGGER.warning("Could not register the House Plan static path", exc_info=True)
|
||||
else:
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = True
|
||||
state.static_path_registered = True
|
||||
|
||||
if not card_file_present:
|
||||
_LOGGER.warning("houseplan-card.js was not found next to the integration")
|
||||
return state
|
||||
if not state.static_path_registered or state.module_url is None:
|
||||
return state
|
||||
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return state
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan card auto-registered as a Lovelace resource: %s",
|
||||
state.module_url,
|
||||
)
|
||||
else:
|
||||
_add_fallback(hass, state)
|
||||
if outcome.status in {"registry_pending", "transient_error"}:
|
||||
_schedule_retry(hass, entry, state)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card uses the frontend extra-module fallback: %s",
|
||||
state.module_url,
|
||||
)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def get_frontend_registration_state(
|
||||
hass: HomeAssistant,
|
||||
) -> FrontendRegistrationState | None:
|
||||
"""Return current frontend registration state for System Health."""
|
||||
state = hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY)
|
||||
return state if isinstance(state, FrontendRegistrationState) else None
|
||||
|
||||
|
||||
async def async_remove_frontend_registration(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Remove all House Plan resource entries and its one-time notice."""
|
||||
state = get_frontend_registration_state(hass)
|
||||
if state is not None:
|
||||
state.cancel()
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
if domain_data.get(FRONTEND_REGISTRATION_KEY) is state:
|
||||
domain_data.pop(FRONTEND_REGISTRATION_KEY, None)
|
||||
|
||||
# Fallback ownership belongs to this HA run, not to the latest config-entry
|
||||
# generation. A bundle-missing reload can therefore have no current URL
|
||||
# while an earlier exact URL is still registered. Uninstall cleans every
|
||||
# URL that this integration itself added, and never pattern-matches foreign
|
||||
# extra-module entries.
|
||||
cleanup_state = state or FrontendRegistrationState(card_file_present=False)
|
||||
for module_url in tuple(_owned_fallback_urls(hass)):
|
||||
_remove_owned_fallback_url(hass, cleanup_state, module_url)
|
||||
|
||||
try:
|
||||
persistent_notification.async_dismiss(hass, FRONTEND_RELOAD_NOTICE_ID)
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not dismiss the House Plan reload notice", exc_info=True)
|
||||
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] != FRONTEND_URL:
|
||||
continue
|
||||
try:
|
||||
await resources.async_delete_item(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - continue best-effort cleanup
|
||||
_LOGGER.debug(
|
||||
"Could not remove one House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
_LOGGER.debug("House Plan Lovelace resource removed")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not remove the House Plan Lovelace resource", exc_info=True)
|
||||
@@ -10,6 +10,9 @@
|
||||
"frontend",
|
||||
"websocket_api"
|
||||
],
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
],
|
||||
"documentation": "https://github.com/Matysh/houseplan-card",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .frontend_registration import get_frontend_registration_state
|
||||
from .store import get_data
|
||||
|
||||
|
||||
@@ -23,7 +24,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
return {
|
||||
result = {
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
@@ -33,3 +34,42 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
frontend = get_frontend_registration_state(hass)
|
||||
if frontend is None:
|
||||
result.update(
|
||||
{
|
||||
"card_file": "missing",
|
||||
"static_path": "not_registered",
|
||||
"resource_status": "not_attempted",
|
||||
"resource_loader": "none",
|
||||
"resource_url": "unavailable",
|
||||
"resource_retry": "not_needed",
|
||||
"resource_error": "none",
|
||||
"first_reload_notice": "pending_frontend",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
result.update(
|
||||
{
|
||||
"card_file": "present" if frontend.card_file_present else "missing",
|
||||
"static_path": (
|
||||
"registered"
|
||||
if frontend.static_path_registered
|
||||
else "not_registered"
|
||||
),
|
||||
"resource_status": frontend.resource_status,
|
||||
"resource_loader": frontend.loader,
|
||||
"resource_url": frontend.module_url or "unavailable",
|
||||
"resource_retry": (
|
||||
"pending"
|
||||
if frontend.retry_pending
|
||||
else "attempted"
|
||||
if frontend.retry_attempted
|
||||
else "not_needed"
|
||||
),
|
||||
"resource_error": frontend.last_error or "none",
|
||||
"first_reload_notice": frontend.first_reload_notice,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House-Plan-Karte verbunden",
|
||||
"description": "Die House-Plan-Karte ist verbunden. Laden Sie diese Seite vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Wenn Sie Dashboard-Ressourcen im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Datei der House-Plan-Karte",
|
||||
"static_path": "Statischer Frontend-Pfad",
|
||||
"resource_status": "Status der Ressourcenregistrierung",
|
||||
"resource_loader": "Lademethode der Frontend-Ressource",
|
||||
"resource_url": "URL der Frontend-Ressource",
|
||||
"resource_retry": "Wiederholung der Ressourcenregistrierung",
|
||||
"resource_error": "Fehler der Frontend-Registrierung",
|
||||
"first_reload_notice": "Erster Hinweis zum Neuladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "L’image du plan est introuvable",
|
||||
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Carte House Plan connectée",
|
||||
"description": "La carte House Plan est connectée. Rechargez complètement cette page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). Si vous gérez manuellement les ressources du tableau de bord en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Fichier de la carte House Plan",
|
||||
"static_path": "Chemin statique de l’interface",
|
||||
"resource_status": "État de l’enregistrement de la ressource",
|
||||
"resource_loader": "Mode de chargement de la ressource",
|
||||
"resource_url": "URL de la ressource de l’interface",
|
||||
"resource_retry": "Nouvelle tentative d’enregistrement",
|
||||
"resource_error": "Erreur d’enregistrement de l’interface",
|
||||
"first_reload_notice": "Premier avis de rechargement"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Карточка House Plan подключена",
|
||||
"description": "Карточка House Plan подключена. Полностью перезагрузите эту страницу, чтобы использовать актуальный интерфейс (`Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS). Если вы вручную управляете ресурсами панели в режиме хранения, откройте Настройки → Панели → Ресурсы."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Файл карточки House Plan",
|
||||
"static_path": "Статический путь интерфейса",
|
||||
"resource_status": "Статус регистрации ресурса",
|
||||
"resource_loader": "Способ загрузки ресурса",
|
||||
"resource_url": "URL ресурса интерфейса",
|
||||
"resource_retry": "Повтор регистрации ресурса",
|
||||
"resource_error": "Ошибка регистрации интерфейса",
|
||||
"first_reload_notice": "Первое уведомление о перезагрузке"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user