fix: make card resource recovery observable

Issue: #462
User-Visible: yes
This commit is contained in:
Matysh
2026-09-05 16:04:54 +03:00
parent a4fe939f63
commit fcf0ce80eb
74 changed files with 4739 additions and 450 deletions
+15 -104
View File
@@ -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": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
@@ -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",
+16
View File
@@ -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"
}
}
}
+41 -1
View File
@@ -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": "Первое уведомление о перезагрузке"
}
}
}