`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=Qi,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=Qi,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
+ ${this._fmtLen(e,i)} · ${r}°
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=is,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=is,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
${i.map(e=>{const[i,n,r,a]="x"===e.axis?[e.at,e.from[1],e.at,t[1]+Math.sign(t[1]-e.from[1])*o]:[e.from[0],e.at,t[0]+Math.sign(t[0]-e.from[0])*o,e.at];return j``})}
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:ke(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
@@ -2133,7 +2133,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
`}}ss.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ss._touchSeen=!1,ss._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ss.styles=Ai,customElements.get("houseplan-card")||customElements.define("houseplan-card",ss),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.45.0 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}}rs.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},rs._touchSeen=!1,rs._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,rs.styles=Oi,customElements.get("houseplan-card")||customElements.define("houseplan-card",rs),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.45.1 ","background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/custom_components/houseplan/manifest.json b/custom_components/houseplan/manifest.json
index 01a7fba..becb960 100755
--- a/custom_components/houseplan/manifest.json
+++ b/custom_components/houseplan/manifest.json
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
- "version": "1.45.0"
+ "version": "1.45.1"
}
diff --git a/custom_components/houseplan/plans.py b/custom_components/houseplan/plans.py
new file mode 100644
index 0000000..b9fc4a2
--- /dev/null
+++ b/custom_components/houseplan/plans.py
@@ -0,0 +1,88 @@
+"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
+
+The file system is not part of the configuration store's transaction, so who
+may delete a plan file, and when, is a correctness question rather than a
+housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
+because it is the part that has to be reasoned about and tested.
+"""
+from __future__ import annotations
+
+import logging
+import time
+from pathlib import Path
+from typing import Any
+
+from .const import PLAN_ORPHAN_TTL_S
+from .validation import PLAN_EXTENSIONS
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def plan_basename(url: Any) -> str:
+ """File name a stored plan_url points at ('' when there is none)."""
+ if not isinstance(url, str) or not url:
+ return ""
+ return url.split("?", 1)[0].rsplit("/", 1)[-1]
+
+
+def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
+ """Plan file names a configuration references."""
+ out: set[str] = set()
+ for sp in (cfg or {}).get("spaces") or []:
+ name = plan_basename(sp.get("plan_url"))
+ if name:
+ out.add(name)
+ return out
+
+
+def is_plan_file(name: str) -> bool:
+ """Does this look like a plan we wrote: . or ..?"""
+ parts = name.split(".")
+ return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
+
+
+def collect_plans(
+ plans_dir: Path,
+ old_cfg: dict[str, Any] | None,
+ new_cfg: dict[str, Any],
+ now: float | None = None,
+) -> int:
+ """Drop plan files the accepted configuration made obsolete (review R3-1).
+
+ Called inside the config write lock, right after the new revision is
+ stored, so it decides from the two configurations that actually bracket the
+ commit instead of trusting a client to say what may be deleted. The earlier
+ design — a `plan/cleanup` command carrying `keep` — could not be ordered
+ against another client's commit: a delayed call removed the file that
+ client had just saved, leaving the accepted configuration pointing at
+ nothing, which is the damage copy-on-write was introduced to prevent.
+
+ Two rules, both conservative:
+ * a file the OLD configuration referenced and the new one does not was
+ authoritative and has been superseded — remove it;
+ * any other unreferenced plan file is a rejected or abandoned upload, and
+ is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
+ belong to a transaction that has not committed yet.
+ """
+ if not plans_dir.is_dir():
+ return 0
+ new_refs = plan_refs(new_cfg)
+ old_refs = plan_refs(old_cfg)
+ cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
+ removed = 0
+ for item in sorted(plans_dir.iterdir()):
+ if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
+ continue
+ superseded = item.name in old_refs
+ try:
+ stale = item.stat().st_mtime < cutoff
+ except OSError:
+ stale = False
+ if not superseded and not stale:
+ continue
+ try:
+ item.unlink()
+ removed += 1
+ except OSError as err:
+ _LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
+ return removed
diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py
index 4e7511d..50efa74 100755
--- a/custom_components/houseplan/websocket_api.py
+++ b/custom_components/houseplan/websocket_api.py
@@ -19,10 +19,11 @@ from .const import (
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
)
from .auth import may_write
+from .plans import collect_plans
from .store import HouseplanData, get_data, get_entry
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
- PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
+ PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
)
@@ -39,7 +40,6 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
- websocket_api.async_register_command(hass, ws_plan_cleanup)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
@@ -321,6 +321,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
+
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -362,6 +363,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
+ # still holding the lock: the file system is not part of the store's
+ # transaction, so collection has to be pinned to this commit (R3-1)
+ await hass.async_add_executor_job(
+ collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
+ )
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
@@ -406,8 +412,10 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
# deleted here (review R2-1). The old name stays readable, so a config write
# that is later rejected — revision conflict, validation, lost connection —
# leaves the stored plan exactly as it was. The card calls
- # `houseplan/plan/cleanup` only after its config CAS succeeds; a crash in
- # between leaves an orphan file that the next successful save removes.
+ # nothing here; the superseded file is collected by `config/set` itself,
+ # inside the write lock, once a revision that no longer references it has
+ # been accepted (review R3-1). A crash in between leaves an orphan, which
+ # the same collector removes on a later commit once it is old enough.
#
# `.` separates the id from the token because a space id cannot contain one
# (SPACE_ID_RE), so ".." can never be confused with the
@@ -422,61 +430,3 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
await hass.async_add_executor_job(_write)
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
-
-
-def _plan_files(plans_dir: Path, space_id: str) -> list[Path]:
- """Every plan file belonging to a space: the legacy flat name and versioned ones."""
- out: list[Path] = []
- if not plans_dir.is_dir():
- return out
- for item in plans_dir.iterdir():
- if not item.is_file():
- continue
- parts = item.name.split(".")
- # "." (legacy) or ".."
- if len(parts) in (2, 3) and parts[0] == space_id and parts[-1].lower() in PLAN_EXTENSIONS:
- out.append(item)
- return out
-
-
-@websocket_api.websocket_command(
- {
- vol.Required("type"): "houseplan/plan/cleanup",
- vol.Required("space_id"): str,
- vol.Required("keep"): str,
- }
-)
-@websocket_api.async_response
-async def ws_plan_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
- """Drop superseded plan files for a space (review R2-1).
-
- Called by the card ONLY after the config write that references `keep` has
- been accepted. Until then every previous file is still on disk, which is
- what makes a rejected save harmless. Deleting nothing is always a safe
- outcome here — the orphans are bounded by one per rejected upload and are
- collected by the next successful one.
- """
- if not _check_write(hass, connection):
- connection.send_error(msg["id"], "unauthorized", "Only administrators may manage plans")
- return
- space_id = msg["space_id"]
- if not valid_space_id(space_id):
- connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
- return
- keep = sanitize_filename(msg["keep"])
- plans_dir = Path(hass.config.path(PLANS_DIR))
-
- def _clean() -> int:
- removed = 0
- for item in _plan_files(plans_dir, space_id):
- if item.name == keep:
- continue
- try:
- item.unlink()
- removed += 1
- except OSError as err: # noqa: PERF203 — a stuck file must not fail the save
- _LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
- return removed
-
- removed = await hass.async_add_executor_job(_clean)
- connection.send_result(msg["id"], {"ok": True, "removed": removed})
diff --git a/demo/smoke_plan_upload_reject.mjs b/demo/smoke_plan_upload_reject.mjs
index 15ca001..651005e 100644
--- a/demo/smoke_plan_upload_reject.mjs
+++ b/demo/smoke_plan_upload_reject.mjs
@@ -1,8 +1,10 @@
-// Граница транзакции загрузки подложки (ревью R2-1).
+// Граница транзакции загрузки подложки (ревью R2-1, уточнено в R3-1).
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
-// сохранение не имеет права трогать сохранённый план. Проверяем контракт со
-// стороны карточки: удаление старых файлов (houseplan/plan/cleanup) уходит
-// ТОЛЬКО после принятого config/set — и никогда после отказа.
+// сохранение не имеет права трогать сохранённый план. Со стороны карточки
+// контракт теперь такой: она НЕ управляет удалением файлов вообще — уборку
+// делает сам config/set под блокировкой (клиент не может упорядочить свою
+// уборку относительно чужого коммита, R3-1). Здесь проверяем, что карточка
+// не отправляет никаких команд удаления и корректно ведёт себя при отказе.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
@@ -18,7 +20,8 @@ const res = await page.evaluate(async () => {
uploads++;
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
}
- if (m.type === 'houseplan/plan/cleanup') { cleanups.push(m); return { ok: true, removed: 1 }; }
+ // любая команда удаления файлов от клиента — нарушение контракта R3-1
+ if (m.type === 'houseplan/plan/cleanup' || m.type === 'houseplan/plan/delete') { cleanups.push(m); return { ok: true }; }
if (m.type === 'houseplan/config/set') {
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
c.__sent = m.config; return { ok: true, rev: 77 };
@@ -48,11 +51,11 @@ const res = await page.evaluate(async () => {
c._spaceDialog = null; await c.updateComplete;
await attach();
out.cleanupsAfterAccept = cleanups.length;
- out.cleanupSpace = cleanups[0]?.space_id;
- out.cleanupKeep = cleanups[0]?.keep;
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
out.savedPlanUrl = f1?.plan_url;
- out.keepMatchesSavedUrl = !!f1 && f1.plan_url.endsWith('/' + cleanups[0]?.keep);
+ out.dialogClosedOnAccept = c._spaceDialog === null;
+ // вторая загрузка не переиспользует имя первой: старый файл жив до коммита
+ out.versionedNames = uploads === 2;
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом
@@ -60,10 +63,9 @@ checkAll(res, {
uploadedOnReject: true,
cleanupsAfterReject: 0,
dialogStaysOpenOnReject: true,
- cleanupsAfterAccept: 1,
- cleanupSpace: 'f1',
- cleanupKeep: 'f1.tok2.png',
+ cleanupsAfterAccept: 0,
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
- keepMatchesSavedUrl: true,
+ dialogClosedOnAccept: true,
+ versionedNames: true,
});
await finish(browser);
diff --git a/demo/smoke_sign_cap.mjs b/demo/smoke_sign_cap.mjs
index f53ebaf..bb00d7d 100644
--- a/demo/smoke_sign_cap.mjs
+++ b/demo/smoke_sign_cap.mjs
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
for (const p of pdfs) c._display(p.url);
await new Promise((r) => setTimeout(r, 120));
out.firstBatches = [...batchSizes];
- out.signedAfterFirst = Object.keys(c._signed).length;
+ out.signedAfterFirst = Object.keys(c._signer.entries).length;
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
batchSizes.length = 0;
@@ -40,7 +40,7 @@ const res = await page.evaluate(async () => {
c._resign();
await new Promise((r) => setTimeout(r, 120));
out.resignBatches = [...batchSizes];
- const vals = Object.values(c._signed).map((v) => v.url);
+ const vals = Object.values(c._signer.entries).map((v) => v.url);
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
@@ -50,15 +50,16 @@ const res = await page.evaluate(async () => {
round = 3;
c._resign();
await new Promise((r) => setTimeout(r, 120));
- out.prunedTo = Object.keys(c._signed).length;
+ out.prunedTo = Object.keys(c._signer.entries).length;
out.pruneBatches = [...batchSizes];
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
const one = pdfs[0].url;
- c._signed = { ...c._signed, [one]: { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 } };
+ c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
out.expiredNotServed = c._display(one) === '';
+ out.expiredDropped = c._signer.entries[one] === undefined;
// а стареющая, но ещё живая — отдаётся, пока едет замена
- c._signed = { ...c._signed, [one]: { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 } };
+ c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
return out;
});
@@ -71,6 +72,7 @@ checkAll(res, {
prunedTo: 5,
pruneBatches: [5],
expiredNotServed: true,
+ expiredDropped: true,
agingStillServed: true,
});
await finish(browser);
diff --git a/demo/smoke_space_card_bg.mjs b/demo/smoke_space_card_bg.mjs
new file mode 100644
index 0000000..39774f2
--- /dev/null
+++ b/demo/smoke_space_card_bg.mjs
@@ -0,0 +1,91 @@
+// Ревью R3-2: houseplan-space-card подписывала URL подложки и выбрасывала
+// результат — getCardSize() правил временную модель, а render() строил свою
+// заново из конфига, поэтому запрашивал сырой requires_auth-путь и на
+// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
+import { launch, checkAll, finish } from './serve.mjs';
+const { page, browser } = await launch({ width: 900, height: 900 }, 1);
+const res = await page.evaluate(async () => {
+ const out = {};
+ await customElements.whenDefined('houseplan-space-card');
+ const main = window.__card;
+ const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
+
+ // подложка на защищённом эндпоинте + управляемый ответ на подпись
+ const cfg = JSON.parse(JSON.stringify(main._serverCfg));
+ cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
+ let signCalls = 0;
+ let failFirst = true;
+ const requestedHrefs = [];
+ const hass = { ...main.hass, callWS: async (m) => {
+ if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
+ if (m.type === 'houseplan/layout/get') return { layout: {} };
+ if (m.type === 'houseplan/content/sign') {
+ signCalls++;
+ if (failFirst && signCalls === 1) throw new Error('ws down');
+ const urls = {};
+ for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
+ return { urls };
+ }
+ return { ok: true };
+ } };
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const card = document.createElement('houseplan-space-card');
+ card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
+ card.hass = hass;
+ host.appendChild(card);
+
+ const stage = async () => {
+ const t0 = Date.now();
+ while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
+ await new Promise((r) => setTimeout(r, 60));
+ }
+ await card.updateComplete;
+ return card.renderRoot.querySelector('.hp-static-stage svg image');
+ };
+ const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
+
+ // 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
+ await stage();
+ await new Promise((r) => setTimeout(r, 120));
+ out.hrefAfterFailedSign = await href();
+
+ // 2) повтор после ошибки: pending освобождён, вторая попытка проходит
+ card.requestUpdate(); await card.updateComplete;
+ await new Promise((r) => setTimeout(r, 150));
+ out.hrefAfterRetry = await href();
+ out.retried = signCalls >= 2;
+
+ // 3) повторный рендер не теряет подпись и не просит её заново
+ const before = signCalls;
+ card.requestUpdate(); await card.updateComplete;
+ out.hrefStable = await href();
+ out.noExtraSignOnRerender = signCalls === before;
+
+ // 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
+ const ent = card._signer.entries;
+ ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
+ card.requestUpdate(); await card.updateComplete;
+ out.hrefWhenExpired = await href();
+ ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
+ card.requestUpdate(); await card.updateComplete;
+ out.hrefWhenAging = await href();
+
+ // ни один сырой (неподписанный) путь не должен уходить в сеть
+ for (const im of card.renderRoot.querySelectorAll('image')) requestedHrefs.push(im.getAttribute('href'));
+ out.noRawHrefEver = !requestedHrefs.includes(raw);
+ return out;
+});
+// зафиксировано прогоном на v1.45.1 и сверено с кодом
+checkAll(res, {
+ hrefAfterFailedSign: null,
+ hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
+ retried: true,
+ hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
+ noExtraSignOnRerender: true,
+ hrefWhenExpired: null,
+ hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
+ noRawHrefEver: true,
+});
+await finish(browser);
diff --git a/demo/srv/assets/houseplan-card.js b/demo/srv/assets/houseplan-card.js
index a55479d..fb1e61c 100755
--- a/demo/srv/assets/houseplan-card.js
+++ b/demo/srv/assets/houseplan-card.js
@@ -1,10 +1,10 @@
-const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new WeakMap;let o=class{constructor(t,e,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const i=this.t;if(e&&void 0===t){const e=void 0!==i&&1===i.length;e&&(t=s.get(i)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&s.set(i,t))}return t}toString(){return this.cssText}};const n=(t,...e)=>{const s=1===t.length?t[0]:e.reduce((e,i,s)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[s+1],t[0]);return new o(s,t,i)},r=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,i))(e)})(t):t,{is:a,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:h,getOwnPropertySymbols:p,getPrototypeOf:d}=Object,u=globalThis,_=u.trustedTypes,g=_?_.emptyScript:"",m=u.reactiveElementPolyfillSupport,f=(t,e)=>t,b={toAttribute(t,e){switch(e){case Boolean:t=t?g:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},v=(t,e)=>!a(t,e),y={attribute:!0,type:String,converter:b,reflect:!1,useDefault:!1,hasChanged:v};Symbol.metadata??=Symbol("metadata"),u.litPropertyMetadata??=new WeakMap;let w=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=y){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),s=this.getPropertyDescriptor(t,i,e);void 0!==s&&l(this.prototype,t,s)}}static getPropertyDescriptor(t,e,i){const{get:s,set:o}=c(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:s,set(e){const n=s?.call(this);o?.call(this,e),this.requestUpdate(t,n,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??y}static _$Ei(){if(this.hasOwnProperty(f("elementProperties")))return;const t=d(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(f("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(f("properties"))){const t=this.properties,e=[...h(t),...p(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(r(t))}else void 0!==t&&e.push(r(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const i=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((i,s)=>{if(e)i.adoptedStyleSheets=s.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of s){const s=document.createElement("style"),o=t.litNonce;void 0!==o&&s.setAttribute("nonce",o),s.textContent=e.cssText,i.appendChild(s)}})(i,this.constructor.elementStyles),i}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),s=this.constructor._$Eu(t,i);if(void 0!==s&&!0===i.reflect){const o=(void 0!==i.converter?.toAttribute?i.converter:b).toAttribute(e,i.type);this._$Em=t,null==o?this.removeAttribute(s):this.setAttribute(s,o),this._$Em=null}}_$AK(t,e){const i=this.constructor,s=i._$Eh.get(t);if(void 0!==s&&this._$Em!==s){const t=i.getPropertyOptions(s),o="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:b;this._$Em=s;const n=o.fromAttribute(e,t.type);this[s]=n??this._$Ej?.get(s)??n,this._$Em=null}}requestUpdate(t,e,i,s=!1,o){if(void 0!==t){const n=this.constructor;if(!1===s&&(o=this[t]),i??=n.getPropertyOptions(t),!((i.hasChanged??v)(o,e)||i.useDefault&&i.reflect&&o===this._$Ej?.get(t)&&!this.hasAttribute(n._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:s,wrapped:o},n){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,n??e??this[t]),!0!==o||void 0!==n)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===s&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,s=this[e];!0!==t||this._$AL.has(e)||void 0===s||this.C(e,void 0,i,s)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};w.elementStyles=[],w.shadowRootOptions={mode:"open"},w[f("elementProperties")]=new Map,w[f("finalized")]=new Map,m?.({ReactiveElement:w}),(u.reactiveElementVersions??=[]).push("2.1.2");const x=globalThis,$=t=>t,k=x.trustedTypes,S=k?k.createPolicy("lit-html",{createHTML:t=>t}):void 0,M="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,D="?"+C,T=`<${D}>`,z=document,E=()=>z.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,A=Array.isArray,R="[ \t\n\f\r]",N=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,O=/-->/g,I=/>/g,L=RegExp(`>|${R}(?:([^\\s"'>=/]+)(${R}*=${R}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),q=/'/g,F=/"/g,H=/^(?:script|style|textarea|title)$/i,U=t=>(e,...i)=>({_$litType$:t,strings:e,values:i}),B=U(1),j=U(2),W=Symbol.for("lit-noChange"),G=Symbol.for("lit-nothing"),V=new WeakMap,K=z.createTreeWalker(z,129);function Z(t,e){if(!A(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(e):e}const Y=(t,e)=>{const i=t.length-1,s=[];let o,n=2===e?"":3===e?"":"")),s]};class J{constructor({strings:t,_$litType$:e},i){let s;this.parts=[];let o=0,n=0;const r=t.length-1,a=this.parts,[l,c]=Y(t,e);if(this.el=J.createElement(l,i),K.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(s=K.nextNode())&&a.length0){s.textContent=k?k.emptyScript:"";for(let i=0;iA(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==G&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(z.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,s="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=J.createElement(Z(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===s)this._$AH.p(e);else{const t=new Q(s,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=V.get(t.strings);return void 0===e&&V.set(t.strings,e=new J(t)),e}k(t){A(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,s=0;for(const o of t)s===e.length?e.push(i=new tt(this.O(E()),this.O(E()),this,this.options)):i=e[s],i._$AI(o),s++;s2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=G}_$AI(t,e=this,i,s){const o=this.strings;let n=!1;if(void 0===o)t=X(this,t,e,0),n=!P(t)||t!==this._$AH&&t!==W,n&&(this._$AH=t);else{const s=t;let r,a;for(t=o[0],r=0;r{const s=i?.renderBefore??e;let o=s._$litPart$;if(void 0===o){const t=i?.renderBefore??null;s._$litPart$=o=new tt(e.insertBefore(E(),t),t,void 0,i??{})}return o._$AI(t),o})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return W}}lt._$litElement$=!0,lt.finalized=!0,at.litElementHydrateSupport?.({LitElement:lt});const ct=at.litElementPolyfillSupport;ct?.({LitElement:lt}),(at.litElementVersions??=[]).push("4.2.2");const ht=new Set(["hacs","sun","backup","hassio","met","telegram_bot","mobile_app","systemmonitor","better_thermostat","adaptive_lighting","yandex_pogoda","upnp_serial_number"]),pt=[{pattern:"протечк|leak|water sensor",icon:"mdi:water-alert"},{pattern:"клапан|valve",icon:"mdi:pipe-valve"},{pattern:"дым|smoke",icon:"mdi:smoke-detector"},{pattern:"термоголов|trv|radiator",icon:"mdi:radiator"},{pattern:"чайник|kettle|термопот",icon:"mdi:kettle"},{pattern:"сауна|sauna|harvia|парная|парилк",icon:"mdi:hot-tub"},{pattern:"температ|temperature|climate sensor",icon:"mdi:thermometer"},{pattern:"qingping|air monitor|молекул|air quality",icon:"mdi:air-filter"},{pattern:"штор|curtain|blind|shade",icon:"mdi:roller-shade"},{pattern:"розетк|plug|socket|outlet",icon:"mdi:power-socket-de"},{pattern:"выключат|switch",icon:"mdi:light-switch"},{pattern:"лампа|лампочк|bulb|gx53|светильник|rgb|lamp|light strip",icon:"mdi:lightbulb"},{pattern:"камер|camera",icon:"mdi:cctv"},{pattern:"замок|ttlock|lock|sn609|sn9161",icon:"mdi:lock"},{pattern:"ворота|garage|gate",icon:"mdi:garage-variant"},{pattern:"калитк|door|открыт|contact",icon:"mdi:door"},{pattern:"счётчик|счетчик|kws|meter",icon:"mdi:meter-electric"},{pattern:"вводный автомат|breaker|wifimcbn",icon:"mdi:electric-switch"},{pattern:"myheat|котёл|котел|boiler|отоплен|heating",icon:"mdi:water-boiler"},{pattern:"холодильник|fridge",icon:"mdi:fridge"},{pattern:"стиральн|washer|washing",icon:"mdi:washing-machine"},{pattern:"сушилк|dryer",icon:"mdi:tumble-dryer"},{pattern:"пылесос|vacuum|dreame|roborock",icon:"mdi:robot-vacuum"},{pattern:"soundbar",icon:"mdi:soundbar"},{pattern:"колонк|станц|speaker|яндекс|yandex|алиса|alice",icon:"mdi:speaker"},{pattern:"tv|телевизор|hyundaitv|mitv|television",icon:"mdi:television"},{pattern:"keenetic|роутер|router|mesh|access point",icon:"mdi:router-wireless"},{pattern:"ибп|ups|kirpich",icon:"mdi:battery-charging-high"},{pattern:"slzb|координат|zigbee|coordinator",icon:"mdi:zigbee"},{pattern:"motion|движен|presence|присутств",icon:"mdi:motion-sensor"},{pattern:"humidity|влажн",icon:"mdi:water-percent"}];function dt(t){const e=[];for(const i of t)if(i&&"string"==typeof i.pattern&&i.icon)try{e.push({re:new RegExp(i.pattern,"i"),icon:i.icon})}catch{}return e}const ut=dt(pt),_t={temperature:"mdi:thermometer",humidity:"mdi:water-percent",motion:"mdi:motion-sensor",occupancy:"mdi:motion-sensor",door:"mdi:door",window:"mdi:window-closed",garage_door:"mdi:garage-variant",smoke:"mdi:smoke-detector",moisture:"mdi:water-alert",gas:"mdi:gas-cylinder",power:"mdi:meter-electric",energy:"mdi:meter-electric",illuminance:"mdi:brightness-5",co2:"mdi:molecule-co2",pm25:"mdi:air-filter",battery:"mdi:battery"},gt="mdi:chip";function mt(t,e,i){const s=((t||"")+" "+(e||"")).toLowerCase();for(const{re:t,icon:e}of i??ut)if(t.test(s))return e;return gt}const ft=["light","switch","cover","valve","lock","climate","fan","media_player","camera","vacuum","humidifier","water_heater","alarm_control_panel","sensor","binary_sensor","event","button","number","select","update"];var bt=/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i,vt=Math.ceil,yt=Math.floor,wt="[BigNumber Error] ",xt=wt+"Number primitive has more than 15 significant digits: ",$t=1e14,kt=14,St=9007199254740991,Mt=[1,10,100,1e3,1e4,1e5,1e6,1e7,1e8,1e9,1e10,1e11,1e12,1e13],Ct=1e7,Dt=1e9;function Tt(t){var e=0|t;return t>0||t===e?e:e-1}function zt(t){for(var e,i,s=1,o=t.length,n=t[0]+"";sc^i?1:-1;for(a=(l=o.length)<(c=n.length)?l:c,r=0;rn[r]^i?1:-1;return l==c?0:l>c^i?1:-1}function Pt(t,e,i,s){if(ti||t!==yt(t))throw Error(wt+(s||"Argument")+("number"==typeof t?ti?" out of range: ":" not an integer: ":" not a primitive number: ")+String(t))}function At(t){var e=t.c.length-1;return Tt(t.e/kt)==e&&t.c[e]%2!=0}function Rt(t,e){return(t.length>1?t.charAt(0)+"."+t.slice(1):t)+(e<0?"e":"e+")+e}function Nt(t,e,i){var s,o;if(e<0){for(o=i+".";++e;o+=i);t=o+t}else if(++e>(s=t.length)){for(o=i,e-=s;--e;o+=i);t+=o}else ev?d.c=d.e=null:t.e=10;l/=10,a++);return void(a>v?d.c=d.e=null:(d.e=a,d.c=[t]))}p=String(t)}else{if(!bt.test(p=String(t)))return o(d,p,c);d.s=45==p.charCodeAt(0)?(p=p.slice(1),-1):1}(a=p.indexOf("."))>-1&&(p=p.replace(".","")),(l=p.search(/e/i))>0?(a<0&&(a=l),a+=+p.slice(l+1),p=p.substring(0,l)):a<0&&(a=p.length)}else{if(Pt(e,2,k.length,"Base"),10==e&&S)return z(d=new M(t),_+d.e+1,g);if(p=String(t),c="number"==typeof t){if(0*t!=0)return o(d,p,c,e);if(d.s=1/t<0?(p=p.slice(1),-1):1,M.DEBUG&&p.replace(/^0\.0*|\./,"").length>15)throw Error(xt+t)}else d.s=45===p.charCodeAt(0)?(p=p.slice(1),-1):1;for(i=k.slice(0,e),a=l=0,h=p.length;la){a=h;continue}}else if(!r&&(p==p.toUpperCase()&&(p=p.toLowerCase())||p==p.toLowerCase()&&(p=p.toUpperCase()))){r=!0,l=-1,a=0;continue}return o(d,String(t),c,e)}c=!1,(a=(p=s(p,e,10,d.s)).indexOf("."))>-1?p=p.replace(".",""):a=p.length}for(l=0;48===p.charCodeAt(l);l++);for(h=p.length;48===p.charCodeAt(--h););if(p=p.slice(l,++h)){if(h-=l,c&&M.DEBUG&&h>15&&(t>St||t!==yt(t)))throw Error(xt+d.s*t);if((a=a-l-1)>v)d.c=d.e=null;else if(a=f)?Rt(l,r):Nt(l,r,"0");else if(n=(t=z(new M(t),e,i)).e,a=(l=zt(t.c)).length,1==s||2==s&&(e<=n||n<=m)){for(;ar),l=Nt(l,n,"0"),n+1>a){if(--e>0)for(l+=".";e--;l+="0");}else if((e+=n-a)>0)for(n+1==a&&(l+=".");e--;l+="0");return t.s<0&&o?"-"+l:l}function D(t,e){for(var i,s,o=1,n=new M(t[0]);o=10;o/=10,s++);return(i=s+i*kt-1)>v?t.c=t.e=null:i=10;a/=10,o++);if((n=e-o)<0)n+=kt,r=e,l=p[c=0],h=yt(l/d[o-r-1]%10);else if((c=vt((n+1)/kt))>=p.length){if(!s)break t;for(;p.length<=c;p.push(0));l=h=0,o=1,r=(n%=kt)-kt+1}else{for(l=a=p[c],o=1;a>=10;a/=10,o++);h=(r=(n%=kt)-kt+o)<0?0:yt(l/d[o-r-1]%10)}if(s=s||e<0||null!=p[c+1]||(r<0?l:l%d[o-r-1]),s=i<4?(h||s)&&(0==i||i==(t.s<0?3:2)):h>5||5==h&&(4==i||s||6==i&&(n>0?r>0?l/d[o-r]:0:p[c-1])%10&1||i==(t.s<0?8:7)),e<1||!p[0])return p.length=0,s?(e-=t.e+1,p[0]=d[(kt-e%kt)%kt],t.e=-e||0):p[0]=t.e=0,t;if(0==n?(p.length=c,a=1,c--):(p.length=c+1,a=d[kt-n],p[c]=r>0?yt(l/d[o-r]%d[r])*a:0),s)for(;;){if(0==c){for(n=1,r=p[0];r>=10;r/=10,n++);for(r=p[0]+=a,a=1;r>=10;r/=10,a++);n!=a&&(t.e++,p[0]==$t&&(p[0]=1));break}if(p[c]+=a,p[c]!=$t)break;p[c--]=0,a=1}for(n=p.length;0===p[--n];p.pop());}t.e>v?t.c=t.e=null:t.e=f?Rt(e,i):Nt(e,i,"0"),t.s<0?"-"+e:e)}return M.clone=t,M.ROUND_UP=0,M.ROUND_DOWN=1,M.ROUND_CEIL=2,M.ROUND_FLOOR=3,M.ROUND_HALF_UP=4,M.ROUND_HALF_DOWN=5,M.ROUND_HALF_EVEN=6,M.ROUND_HALF_CEIL=7,M.ROUND_HALF_FLOOR=8,M.EUCLID=9,M.config=M.set=function(t){var e,i;if(null!=t){if("object"!=typeof t)throw Error(wt+"Object expected: "+t);if(t.hasOwnProperty(e="DECIMAL_PLACES")&&(Pt(i=t[e],0,Dt,e),_=i),t.hasOwnProperty(e="ROUNDING_MODE")&&(Pt(i=t[e],0,8,e),g=i),t.hasOwnProperty(e="EXPONENTIAL_AT")&&((i=t[e])&&i.pop?(Pt(i[0],-Dt,0,e),Pt(i[1],0,Dt,e),m=i[0],f=i[1]):(Pt(i,-Dt,Dt,e),m=-(f=i<0?-i:i))),t.hasOwnProperty(e="RANGE"))if((i=t[e])&&i.pop)Pt(i[0],-Dt,-1,e),Pt(i[1],1,Dt,e),b=i[0],v=i[1];else{if(Pt(i,-Dt,Dt,e),!i)throw Error(wt+e+" cannot be zero: "+i);b=-(v=i<0?-i:i)}if(t.hasOwnProperty(e="CRYPTO")){if((i=t[e])!==!!i)throw Error(wt+e+" not true or false: "+i);if(i){if("undefined"==typeof crypto||!crypto||!crypto.getRandomValues&&!crypto.randomBytes)throw y=!i,Error(wt+"crypto unavailable");y=i}else y=i}if(t.hasOwnProperty(e="MODULO_MODE")&&(Pt(i=t[e],0,9,e),w=i),t.hasOwnProperty(e="POW_PRECISION")&&(Pt(i=t[e],0,Dt,e),x=i),t.hasOwnProperty(e="FORMAT")){if("object"!=typeof(i=t[e]))throw Error(wt+e+" not an object: "+i);$=i}if(t.hasOwnProperty(e="ALPHABET")){if("string"!=typeof(i=t[e])||/^.?$|[+\-.\s]|(.).*\1/.test(i))throw Error(wt+e+" invalid: "+i);S="0123456789"==i.slice(0,10),k=i}}return{DECIMAL_PLACES:_,ROUNDING_MODE:g,EXPONENTIAL_AT:[m,f],RANGE:[b,v],CRYPTO:y,MODULO_MODE:w,POW_PRECISION:x,FORMAT:$,ALPHABET:k}},M.isBigNumber=function(t){if(!t||!0!==t._isBigNumber)return!1;if(!M.DEBUG)return!0;var e,i,s=t.c,o=t.e,n=t.s;t:if("[object Array]"=={}.toString.call(s)){if((1===n||-1===n)&&o>=-Dt&&o<=Dt&&o===yt(o)){if(0===s[0]){if(0===o&&1===s.length)return!0;break t}if((e=(o+1)%kt)<1&&(e+=kt),String(s[0]).length==e){for(e=0;e=$t||i!==yt(i))break t;if(0!==i)return!0}}}else if(null===s&&null===o&&(null===n||1===n||-1===n))return!0;throw Error(wt+"Invalid BigNumber: "+t)},M.maximum=M.max=function(){return D(arguments,-1)},M.minimum=M.min=function(){return D(arguments,1)},M.random=(n=9007199254740992,r=Math.random()*n&2097151?function(){return yt(Math.random()*n)}:function(){return 8388608*(1073741824*Math.random()|0)+(8388608*Math.random()|0)},function(t){var e,i,s,o,n,a=0,l=[],c=new M(u);if(null==t?t=_:Pt(t,0,Dt),o=vt(t/kt),y)if(crypto.getRandomValues){for(e=crypto.getRandomValues(new Uint32Array(o*=2));a>>11))>=9e15?(i=crypto.getRandomValues(new Uint32Array(2)),e[a]=i[0],e[a+1]=i[1]):(l.push(n%1e14),a+=2);a=o/2}else{if(!crypto.randomBytes)throw y=!1,Error(wt+"crypto unavailable");for(e=crypto.randomBytes(o*=7);a=9e15?crypto.randomBytes(7).copy(e,a):(l.push(n%1e14),a+=7);a=o/7}if(!y)for(;a=10;n/=10,a++);ai-1&&(null==r[o+1]&&(r[o+1]=0),r[o+1]+=r[o]/i|0,r[o]%=i)}return r.reverse()}return function(s,o,n,r,a){var l,c,h,p,d,u,m,f,b=s.indexOf("."),v=_,y=g;for(b>=0&&(p=x,x=0,s=s.replace(".",""),u=(f=new M(o)).pow(s.length-b),x=p,f.c=e(Nt(zt(u.c),u.e,"0"),10,n,t),f.e=f.c.length),h=p=(m=e(s,o,n,a?(l=k,t):(l=t,k))).length;0==m[--p];m.pop());if(!m[0])return l.charAt(0);if(b<0?--h:(u.c=m,u.e=h,u.s=r,m=(u=i(u,f,v,y,n)).c,d=u.r,h=u.e),b=m[c=h+v+1],p=n/2,d=d||c<0||null!=m[c+1],d=y<4?(null!=b||d)&&(0==y||y==(u.s<0?3:2)):b>p||b==p&&(4==y||d||6==y&&1&m[c-1]||y==(u.s<0?8:7)),c<1||!m[0])s=d?Nt(l.charAt(1),-v,l.charAt(0)):l.charAt(0);else{if(m.length=c,d)for(--n;++m[--c]>n;)m[c]=0,c||(++h,m=[1].concat(m));for(p=m.length;!m[--p];);for(b=0,s="";b<=p;s+=l.charAt(m[b++]));s=Nt(s,h,l.charAt(0))}return s}}(),i=function(){function t(t,e,i){var s,o,n,r,a=0,l=t.length,c=e%Ct,h=e/Ct|0;for(t=t.slice();l--;)a=((o=c*(n=t[l]%Ct)+(s=h*n+(r=t[l]/Ct|0)*c)%Ct*Ct+a)/i|0)+(s/Ct|0)+h*r,t[l]=o%i;return a&&(t=[a].concat(t)),t}function e(t,e,i,s){var o,n;if(i!=s)n=i>s?1:-1;else for(o=n=0;oe[o]?1:-1;break}return n}function i(t,e,i,s){for(var o=0;i--;)t[i]-=o,o=t[i]1;t.splice(0,1));}return function(s,o,n,r,a){var l,c,h,p,d,u,_,g,m,f,b,v,y,w,x,$,k,S=s.s==o.s?1:-1,C=s.c,D=o.c;if(!(C&&C[0]&&D&&D[0]))return new M(s.s&&o.s&&(C?!D||C[0]!=D[0]:D)?C&&0==C[0]||!D?0*S:S/0:NaN);for(m=(g=new M(S)).c=[],S=n+(c=s.e-o.e)+1,a||(a=$t,c=Tt(s.e/kt)-Tt(o.e/kt),S=S/kt|0),h=0;D[h]==(C[h]||0);h++);if(D[h]>(C[h]||0)&&c--,S<0)m.push(1),p=!0;else{for(w=C.length,$=D.length,h=0,S+=2,(d=yt(a/(D[0]+1)))>1&&(D=t(D,d,a),C=t(C,d,a),$=D.length,w=C.length),y=$,b=(f=C.slice(0,$)).length;b<$;f[b++]=0);k=D.slice(),k=[0].concat(k),x=D[0],D[1]>=a/2&&x++;do{if(d=0,(l=e(D,f,$,b))<0){if(v=f[0],$!=b&&(v=v*a+(f[1]||0)),(d=yt(v/x))>1)for(d>=a&&(d=a-1),_=(u=t(D,d,a)).length,b=f.length;1==e(u,f,_,b);)d--,i(u,$<_?k:D,_,a),_=u.length,l=1;else 0==d&&(l=d=1),_=(u=D.slice()).length;if(_=10;S/=10,h++);z(g,n+(g.e=h+c*kt-1)+1,r,p)}else g.e=c,g.r=+p;return g}}(),a=/^(-?)0([xbo])(?=\w[\w.]*$)/i,l=/^([^.]+)\.$/,c=/^\.([^.]+)$/,h=/^-?(Infinity|NaN)$/,p=/^\s*\+(?=[\w.])|^\s+|\s+$/g,o=function(t,e,i,s){var o,n=i?e:e.replace(p,"");if(h.test(n))t.s=isNaN(n)?null:n<0?-1:1;else{if(!i&&(n=n.replace(a,function(t,e,i){return o="x"==(i=i.toLowerCase())?16:"b"==i?2:8,s&&s!=o?t:e}),s&&(o=s,n=n.replace(l,"$1").replace(c,"0.$1")),e!=n))return new M(n,o);if(M.DEBUG)throw Error(wt+"Not a"+(s?" base "+s:"")+" number: "+e);t.s=null}t.c=t.e=null},d.absoluteValue=d.abs=function(){var t=new M(this);return t.s<0&&(t.s=1),t},d.comparedTo=function(t,e){return Et(this,new M(t,e))},d.decimalPlaces=d.dp=function(t,e){var i,s,o,n=this;if(null!=t)return Pt(t,0,Dt),null==e?e=g:Pt(e,0,8),z(new M(n),t+n.e+1,e);if(!(i=n.c))return null;if(s=((o=i.length-1)-Tt(this.e/kt))*kt,o=i[o])for(;o%10==0;o/=10,s--);return s<0&&(s=0),s},d.dividedBy=d.div=function(t,e){return i(this,new M(t,e),_,g)},d.dividedToIntegerBy=d.idiv=function(t,e){return i(this,new M(t,e),0,1)},d.exponentiatedBy=d.pow=function(t,e){var i,s,o,n,r,a,l,c,h=this;if((t=new M(t)).c&&!t.isInteger())throw Error(wt+"Exponent not an integer: "+E(t));if(null!=e&&(e=new M(e)),r=t.e>14,!h.c||!h.c[0]||1==h.c[0]&&!h.e&&1==h.c.length||!t.c||!t.c[0])return c=new M(Math.pow(+E(h),r?t.s*(2-At(t)):+E(t))),e?c.mod(e):c;if(a=t.s<0,e){if(e.c?!e.c[0]:!e.s)return new M(NaN);(s=!a&&h.isInteger()&&e.isInteger())&&(h=h.mod(e))}else{if(t.e>9&&(h.e>0||h.e<-1||(0==h.e?h.c[0]>1||r&&h.c[1]>=24e7:h.c[0]<8e13||r&&h.c[0]<=9999975e7)))return n=h.s<0&&At(t)?-0:0,h.e>-1&&(n=1/n),new M(a?1/n:n);x&&(n=vt(x/kt+2))}for(r?(i=new M(.5),a&&(t.s=1),l=At(t)):l=(o=Math.abs(+E(t)))%2,c=new M(u);;){if(l){if(!(c=c.times(h)).c)break;n?c.c.length>n&&(c.c.length=n):s&&(c=c.mod(e))}if(o){if(0===(o=yt(o/2)))break;l=o%2}else if(z(t=t.times(i),t.e+1,1),t.e>14)l=At(t);else{if(0===(o=+E(t)))break;l=o%2}h=h.times(h),n?h.c&&h.c.length>n&&(h.c.length=n):s&&(h=h.mod(e))}return s?c:(a&&(c=u.div(c)),e?c.mod(e):n?z(c,x,g,void 0):c)},d.integerValue=function(t){var e=new M(this);return null==t?t=g:Pt(t,0,8),z(e,e.e+1,t)},d.isEqualTo=d.eq=function(t,e){return 0===Et(this,new M(t,e))},d.isFinite=function(){return!!this.c},d.isGreaterThan=d.gt=function(t,e){return Et(this,new M(t,e))>0},d.isGreaterThanOrEqualTo=d.gte=function(t,e){return 1===(e=Et(this,new M(t,e)))||0===e},d.isInteger=function(){return!!this.c&&Tt(this.e/kt)>this.c.length-2},d.isLessThan=d.lt=function(t,e){return Et(this,new M(t,e))<0},d.isLessThanOrEqualTo=d.lte=function(t,e){return-1===(e=Et(this,new M(t,e)))||0===e},d.isNaN=function(){return!this.s},d.isNegative=function(){return this.s<0},d.isPositive=function(){return this.s>0},d.isZero=function(){return!!this.c&&0==this.c[0]},d.minus=function(t,e){var i,s,o,n,r=this,a=r.s;if(e=(t=new M(t,e)).s,!a||!e)return new M(NaN);if(a!=e)return t.s=-e,r.plus(t);var l=r.e/kt,c=t.e/kt,h=r.c,p=t.c;if(!l||!c){if(!h||!p)return h?(t.s=-e,t):new M(p?r:NaN);if(!h[0]||!p[0])return p[0]?(t.s=-e,t):new M(h[0]?r:3==g?-0:0)}if(l=Tt(l),c=Tt(c),h=h.slice(),a=l-c){for((n=a<0)?(a=-a,o=h):(c=l,o=p),o.reverse(),e=a;e--;o.push(0));o.reverse()}else for(s=(n=(a=h.length)<(e=p.length))?a:e,a=e=0;e0)for(;e--;h[i++]=0);for(e=$t-1;s>a;){if(h[--s]
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=Qi,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=Qi,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
+ ${this._fmtLen(e,i)} · ${r}°
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=is,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=is,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
${i.map(e=>{const[i,n,r,a]="x"===e.axis?[e.at,e.from[1],e.at,t[1]+Math.sign(t[1]-e.from[1])*o]:[e.from[0],e.at,t[0]+Math.sign(t[0]-e.from[0])*o,e.at];return j``})}
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:ke(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
@@ -2133,7 +2133,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
`}}ss.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ss._touchSeen=!1,ss._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ss.styles=Ai,customElements.get("houseplan-card")||customElements.define("houseplan-card",ss),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.45.0 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=Qi,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=Qi,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
+ ${this._fmtLen(e,i)} · ${r}°
`}get _alignPoint(){if(this._markup){if("draw"===this._tool&&this._path.length&&!this._contourClosed&&this._cursorPt)return this._cursorPt;if("split"===this._tool&&this._splitSel?.pts?.length&&this._cursorPt)return this._cursorPt;if(this._drag?.id.startsWith("rl_")&&this._drag.moved){const t=this._drag.id.slice(3),e=this._spaceModel().rooms.find(e=>e.id===t);return e?(()=>{const t=this._labelPos(e,this._space);return[t.x,t.y]})():null}return null}if("devices"===this._mode&&this._drag?.moved){const t=this._devices.find(t=>t.id===this._drag.id);return t?(()=>{const e=this._pos(t);return[e.x,e.y]})():null}if("decor"===this._mode){if(this._decorDraft)return this._decorDraft.b;if(this._decorMove){const t=this._decorList.find(t=>t.id===this._decorMove.id);if(!t)return null;const e=is,i=this._decorH;return"line"===t.kind?[t.x1*e,t.y1*i]:[t.x*e,t.y*i]}return null}return null}_alignCandidates(){const t=[],e=this._spaceModel();if(this._markup){if(this._drag?.id.startsWith("rl_")){const i=this._drag.id.slice(3);for(const s of e.rooms){if(!s.name||s.id===i)continue;const e=this._labelPos(s,this._space);t.push([e.x,e.y])}return t}for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}if("draw"===this._tool)for(const e of this._path)t.push(e);if("split"===this._tool&&this._splitSel?.pts)for(const e of this._splitSel.pts)t.push(e);return t}if("devices"===this._mode){for(const e of this._devices){if(e.space!==this._space||e.id===this._drag?.id)continue;const i=this._pos(e);t.push([i.x,i.y])}return t}if("decor"===this._mode){const i=is,s=this._decorH,o=this._decorMove?.id;for(const e of this._decorList)e.id!==o&&("line"===e.kind?t.push([e.x1*i,e.y1*s],[e.x2*i,e.y2*s]):"text"===e.kind?t.push([e.x*i,e.y*s]):t.push([e.x*i,e.y*s],[(e.x+e.w)*i,e.y*s],[e.x*i,(e.y+e.h)*s],[(e.x+e.w)*i,(e.y+e.h)*s]));this._decorDraft&&t.push(this._decorDraft.a);for(const i of e.rooms){const e=we(i);if(e)for(const i of e)t.push(i)}return t}return t}_renderAlignGuides(){const t=this._alignPoint;if(!t)return j``;const e=this._drag?.id.startsWith("rl_")?.5*this._gridPitch:.05*this._gridPitch,i=function(t,e,i){let s=null,o=null;for(const n of e)if(!(Math.abs(n[0]-t[0])<1e-6&&Math.abs(n[1]-t[1])<1e-6)){if(Math.abs(n[0]-t[0])<=i){const e=Math.abs(n[1]-t[1]);e>1e-6&&(!s||e1e-6&&(!o||e
${i.map(e=>{const[i,n,r,a]="x"===e.axis?[e.at,e.from[1],e.at,t[1]+Math.sign(t[1]-e.from[1])*o]:[e.from[0],e.at,t[0]+Math.sign(t[0]-e.from[0])*o,e.at];return j``})}
`}_roomCenter(t){if(t.poly){const e=t.poly.length;return[t.poly.reduce((t,e)=>t+e[0],0)/e,t.poly.reduce((t,e)=>t+e[1],0)/e]}return[t.x+t.w/2,t.y+.1*Math.min(t.w,t.h)]}_openingAmt(t){const e=t.contact?this.hass.states[t.contact]?.state:null;return function(t,e,i=!1){return null==e||"unavailable"===e||"unknown"===e?"door"===t?1:0:ke(e)!==!!i?1:0}(t.type,e,!!t.invert)}_renderOpenings(t){const e=this._openingsR;if(!e.length)return j``;const i=t.color;return j`${e.map(t=>{const e=t.rlen/2,s=this._openingAmt(t),o=s>0&&!!t.contact?"var(--hp-open)":i,n=t.flip_h?-1:1,r=t.flip_v?-1:1;let a;if("window"===t.type){const t=Math.PI/2*e;a=j`
@@ -2133,7 +2133,7 @@ const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow
`}}ss.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},ss._touchSeen=!1,ss._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,ss.styles=Ai,customElements.get("houseplan-card")||customElements.define("houseplan-card",ss),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.45.0 ","background:#3ea6ff;color:#04121f;font-weight:700","");
+
`}}rs.properties={hass:{attribute:!1},_config:{state:!0},_space:{state:!0},_layout:{state:!0},_devices:{state:!0},_tip:{state:!0},_selId:{state:!0},_toast:{state:!0},_serverCfg:{state:!0},_mode:{state:!0},_tool:{state:!0},_path:{state:!0},_cursorPt:{state:!0},_mergeSel:{state:!0},_openingDialog:{state:!0},_openingInfo:{state:!0},_mergeDialog:{state:!0},_splitSel:{state:!0},_decorTool:{state:!0},_decorStyle:{state:!0},_decorDraft:{state:!0},_decorSel:{state:!0},_decorTextDialog:{state:!0},_kioskDialog:{state:!0},_kioskDots:{state:!0},_areaSel:{state:!0},_nameSel:{state:!0},_roomDialog:{state:!0},_roomEditId:{state:!0},_roomFill:{state:!0},_roomTempSrc:{state:!0},_roomHumSrc:{state:!0},_roomSrcOpen:{state:!0},_roomSrcFilter:{state:!0},_roomNameScale:{state:!0},_roomLabelScale:{state:!0},_spaceDialog:{state:!0},_infoCard:{state:!0},_rulesDialog:{state:!0},_settingsDialog:{state:!0},_importDialog:{state:!0},_markerDialog:{state:!0},_zoom:{state:!0},_view:{state:!0}},rs._touchSeen=!1,rs._noHoverMq="undefined"!=typeof window&&"function"==typeof window.matchMedia&&window.matchMedia("(hover: none)").matches,rs.styles=Oi,customElements.get("houseplan-card")||customElements.define("houseplan-card",rs),window.customCards=window.customCards||[],window.customCards.find(t=>"houseplan-card"===t.type)||window.customCards.push({type:"houseplan-card",name:"House Plan Card",description:"Interactive house plan: spaces, rooms and devices with live states and drag layout."}),console.info("%c HOUSEPLAN-CARD %c v1.45.1 ","background:#3ea6ff;color:#04121f;font-weight:700","");
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 9cb8bc3..4b4d6f5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -178,19 +178,26 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `..`, deletes nothing |
-| `houseplan/plan/cleanup` | `space_id`, `keep` | `{ok, removed}` — call ONLY after the config write referencing `keep` was accepted |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
-**Plan uploads are copy-on-write** (review R2-1). The file system is not part
-of the config's optimistic-locking transaction, so nothing that is currently
-referenced may be overwritten or deleted before the config CAS succeeds: the
-upload writes a new versioned name, the card commits the url, and only then
-asks for `plan/cleanup`. A crash between the two leaves one orphan file, which
-the next successful upload removes. The `.` between id and token is load-bearing
-— a space id cannot contain one, so `..` can never be
-confused with the files of a space whose name merely starts the same way.
+**Plan uploads are copy-on-write, and collection belongs to the commit**
+(reviews R2-1, R3-1). The file system is not part of the config's
+optimistic-locking transaction, so nothing referenced may be overwritten or
+deleted before the CAS succeeds: the upload writes a new versioned name and
+removes nothing. Deciding what may then go is *not* a client's call — a cleanup
+request cannot be ordered against another client's commit, and a delayed one
+deletes a plan that was just saved. So `config/set` collects itself, inside its
+write lock, from the pair of configurations that bracket the commit
+(`plans.collect_plans`): superseded files go immediately, other unreferenced
+uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
+to a transaction still in flight. The `.` between id and token is load-bearing —
+a space id cannot contain one, so `..` can never be confused
+with the files of a space whose name merely starts the same way.
-**Signed content urls are batched and aged** (review R2-2). `MAX_SIGN_PATHS`
+**Signed content urls are batched and aged** (reviews R2-2, R3-2). `ContentSigner`
+in `src/signing.ts` is the single implementation, used by both cards; the
+duplicate inside houseplan-space-card signed correctly and never handed the
+result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
request there and says nothing about the rest, so the card must chunk. Cached
signatures carry the time they were issued — an aging one keeps rendering while
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 79448d7..488fe29 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,32 @@
# Changelog
+## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
+- **Collecting old plan files moved into the config transaction (R3-1, high).**
+ v1.45.0 made the upload safe but handed the deletion to the client: after a
+ successful save the card asked the backend to remove everything except the
+ file it had just committed. Two open editors could not be ordered — a delayed
+ request from one client deleted the plan the other had just saved, and the
+ accepted configuration was left pointing at nothing, which is the exact damage
+ copy-on-write was added to prevent. The `houseplan/plan/cleanup` command is
+ gone. `config/set` now collects inside its own write lock, comparing the
+ configuration it replaced with the one it accepted: a file the old revision
+ referenced and the new one does not is removed, and any other unreferenced
+ upload is left alone until it is an hour old, because a fresh one may belong
+ to a transaction that has not committed yet.
+- **The static space card shows its plan background again (R3-2).** It signed
+ the url and then threw the result away — `getCardSize()` mutated a throwaway
+ model while `render()` rebuilt its own from the config — so the `` kept
+ requesting the protected path and got a 401 on every render. Both cards now
+ share one signer, which also gives the static card the batching, the
+ expiry handling and the periodic re-signing the main card already had. Its
+ pending set is released in `finally`, so a single failed request no longer
+ wedges a url for the life of the page.
+- New tests: five backend cases for the two-client interleavings from the report
+ (late commit, uncommitted upload, aged orphan, foreign files, rejected save),
+ the collector extracted to a pure module and unit-tested, and
+ `smoke_space_card_bg` for the signed background — it fails on v1.45.0 with the
+ raw url in the DOM.
+
## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3)
- **A rejected save can no longer damage a working plan (R2-1, high).** The
plan file was written to its final name — deleting the previous extension on
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 14b6f72..aaddb64 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -6,6 +6,33 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
+## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
+- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
+ high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
+ после успешного сохранения карточка просила бэкенд убрать всё, кроме только
+ что закоммиченного файла. Два открытых редактора невозможно упорядочить —
+ задержавшийся запрос одного клиента удалял план, который только что сохранил
+ другой, и принятая конфигурация оставалась со ссылкой в пустоту, то есть
+ ровно с тем ущербом, ради которого вводился copy-on-write. Команда
+ `houseplan/plan/cleanup` убрана. Теперь `config/set` убирает сам, под своей
+ блокировкой, сравнивая конфигурацию, которую заменил, с той, которую принял:
+ файл, на который ссылалась старая ревизия и не ссылается новая, удаляется, а
+ любая другая непривязанная загрузка не трогается, пока ей не исполнится час —
+ свежая может принадлежать ещё не завершённой чужой транзакции.
+- **Статическая карточка пространства снова показывает подложку (R3-2).** Она
+ подписывала URL и выбрасывала результат — `getCardSize()` правил временную
+ модель, а `render()` строил свою заново из конфига, — поэтому ``
+ запрашивал защищённый путь и на каждом рендере получал 401. Обе карточки
+ теперь используют один подписыватель, и статическая заодно получила батчи,
+ учёт срока годности и периодическое переподписывание, которые были только у
+ основной. Её множество ожидающих запросов освобождается в `finally`, так что
+ одна неудача больше не блокирует ссылку до конца жизни страницы.
+- Новые тесты: пять backend-сценариев чередования двух клиентов из отчёта
+ (поздний коммит, незакоммиченная загрузка, устаревший сирота, чужие файлы,
+ отвергнутое сохранение), сборщик вынесен в чистый модуль и покрыт юнит-
+ тестами, плюс `smoke_space_card_bg` на подписанный фон — он падает на
+ v1.45.0, где в DOM попадает сырой URL.
+
## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3)
- **Отвергнутое сохранение больше не может испортить рабочий план (R2-1,
high).** Файл плана записывался под финальным именем — попутно удаляя вариант
diff --git a/docs/STATUS.md b/docs/STATUS.md
index dae028c..647dc07 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
-| Version | **v1.45.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
+| Version | **v1.45.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
-| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.0** via direct copy (HACS custom repo also installed) |
+| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.1** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 11c2b1b..8c6ed5c 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -234,6 +234,18 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
are only reachable through /api/houseplan/content/… with a session; the
old /houseplan_files/plans|files paths return 404 after a restart; old
stored URLs keep working (rewritten on read) [auto+manual]
+- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
+ two tabs, attach a background in each in turn — the plan last saved is the
+ one served, and neither commit deletes the other's file. A rejected upload
+ disappears on a later save, not immediately
+ [auto: backend test_late_commit_of_one_client_never_deletes_another_client_s_plan,
+ test_commit_does_not_collect_another_client_s_uncommitted_upload,
+ test_abandoned_uploads_are_collected_once_old]
+- [ ] Static card background (v1.45.1, review R3-2): a houseplan-space-card on a
+ dashboard shows the plan image, not an empty stage; the browser never
+ requests the unsigned path and Home Assistant logs no failed login. A
+ failed signing request is retried on the next render
+ [auto: smoke_space_card_bg]
- [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new
background, make the config write fail (a second tab saving first is
enough) — the previously stored plan is still served, with the same or a
diff --git a/package.json b/package.json
index 6b6e6b8..894e75a 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
- "version": "1.45.0",
+ "version": "1.45.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 359c71a..209106c 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -21,8 +21,9 @@ import {
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
- MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS, chunk, referencedContentUrls,
+ referencedContentUrls,
} from './logic';
+import { ContentSigner } from './signing';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
import type {
OpeningCfg,
@@ -33,7 +34,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
-const CARD_VERSION = '1.45.0';
+const CARD_VERSION = '1.45.1';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -366,8 +367,7 @@ class HouseplanCard extends LitElement {
super.connectedCallback();
window.addEventListener('keydown', this._keyHandler);
// signatures expire (24 h); refresh well before that on long-lived screens
- clearInterval(this._resignTimer);
- this._resignTimer = window.setInterval(() => this._resign(), 12 * 3600 * 1000);
+ this._signer.start(() => this.hass, () => referencedContentUrls(this._serverCfg));
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
clearInterval(this._cycleTimer);
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
@@ -381,8 +381,7 @@ class HouseplanCard extends LitElement {
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);
clearTimeout(this._reloadRetry);
- clearTimeout(this._signTimer);
- clearInterval(this._resignTimer);
+ this._signer.dispose();
clearTimeout(this._toastTimer);
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
window.removeEventListener('hashchange', this._onHashChange);
@@ -789,85 +788,18 @@ class HouseplanCard extends LitElement {
* Bearer header or an `authSig` signed path, and an element sends neither.
* So the card asks the backend to sign what it is about to display.
*/
- private _signed: Record = {};
- private _signPending = new Set();
- private _signTimer?: number;
+ private _signer = new ContentSigner(() => this.requestUpdate());
/** Display url: a signature we hold and still trust, else nothing. */
private _display(url: string | null | undefined): string {
- const u = contentUrl(url);
- if (!u.startsWith('/api/houseplan/content/')) return u;
- const hit = this._signed[u];
- const age = hit ? Date.now() - hit.at : Infinity;
- // Past its lifetime the signature is worthless: serving it would 401 and
- // raise a failed-login warning, exactly what an unsigned path does.
- if (age >= SIGN_TTL_MS) delete this._signed[u];
- else if (age < SIGN_REFRESH_MS) return hit.url;
- else {
- // aging but still valid: keep showing it while a fresh one is fetched
- this._requestSignature(u);
- return hit.url;
- }
- this._requestSignature(u);
- // Empty, NOT the plain path: an unsigned request to a `requires_auth` view
- // returns 401 and Home Assistant raises a "failed login attempt" for the
- // viewer's own IP. Callers skip rendering until the signature lands.
- return '';
+ return this._signer.display(this.hass, url);
}
- private _requestSignature(url: string): void {
- if (this._signPending.has(url) || !this.hass?.callWS) return;
- this._signPending.add(url);
- clearTimeout(this._signTimer);
- // batch: a plan switch asks for several urls in the same tick
- this._signTimer = window.setTimeout(() => {
- const paths = [...this._signPending];
- this._signPending.clear();
- this._signBatches(paths);
- }, 30);
- }
-
- /**
- * Sign in batches the backend will actually answer in full. It caps a request
- * at MAX_SIGN_PATHS and drops the rest without saying so, so one oversized
- * call leaves entries that never get refreshed and silently expire (R2-2).
- */
- private _signBatches(paths: string[]): void {
- if (!paths.length || !this.hass?.callWS) return;
- for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
- this.hass
- .callWS({ type: 'houseplan/content/sign', paths: batch })
- .then((r: any) => {
- if (!r?.urls) return;
- const now = Date.now();
- const next = { ...this._signed };
- for (const [k, v] of Object.entries(r.urls)) next[k] = { url: v, at: now };
- this._signed = next;
- this.requestUpdate();
- })
- .catch(() => undefined); // unsigned urls simply keep failing; no loop
- }
- }
-
- /**
- * Re-sign periodically: a wall tablet outlives a signature.
- * The old urls are kept until the new ones arrive — dropping them first would
- * blank the plan for a round trip (and, if the socket is down, until it heals).
- */
- private _resignTimer?: number;
-
+ /** Re-sign what the live config still references (wall tablets outlive one). */
private _resign(): void {
- // prune first: an entry for a plan that was replaced months ago must not
- // consume a slot in the (capped) signing request
- const live = referencedContentUrls(this._serverCfg);
- const now = Date.now();
- const kept: Record = {};
- for (const [k, v] of Object.entries(this._signed)) {
- if (live.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
- }
- this._signed = kept;
- this._signBatches(Object.keys(kept));
+ this._signer.resign(this.hass, referencedContentUrls(this._serverCfg));
}
+
private _dirtyPos = new Set();
private _persistLayout = debounce(() => {
@@ -3057,11 +2989,11 @@ class HouseplanCard extends LitElement {
label_light: d.labelLight,
};
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
+ // Nothing to clean up from here: the backend collects the superseded
+ // file inside the same locked transaction that accepted this config
+ // (review R3-1). A cleanup driven from the client could not be ordered
+ // against another client's commit and deleted its freshly saved plan.
await this._saveConfigNow();
- // Only now is the new file authoritative: the config write was accepted,
- // so the previous plan can go. Before this point nothing on disk was
- // touched, which is what makes a rejected save harmless (review R2-1).
- if (uploaded) this._cleanupPlanFiles(spaceId, uploaded.url);
this._spaceDialog = null;
if (d.mode === 'create') this._space = sp.id;
this._regSignature = '';
@@ -3138,19 +3070,6 @@ class HouseplanCard extends LitElement {
}
- /**
- * Remove plan files superseded by `keepUrl`. Fire-and-forget on purpose: the
- * user's edit is already saved, and a failed cleanup only leaves a stray file
- * that the next successful upload collects (review R2-1).
- */
- private _cleanupPlanFiles(spaceId: string, keepUrl: string): void {
- const keep = keepUrl.split('?')[0].split('/').pop();
- if (!keep || !this.hass?.callWS) return;
- this.hass
- .callWS({ type: 'houseplan/plan/cleanup', space_id: spaceId, keep })
- .catch(() => undefined);
- }
-
// ================= FLOORS IMPORT WIZARD =================
private _startImport(): void {
diff --git a/src/signing.ts b/src/signing.ts
new file mode 100644
index 0000000..b5e8ac5
--- /dev/null
+++ b/src/signing.ts
@@ -0,0 +1,127 @@
+import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
+
+/**
+ * Signed urls for the authenticated content endpoint, shared by both cards.
+ *
+ * A browser cannot authenticate an `` or an ``: Home
+ * Assistant takes a Bearer header or an `authSig` signed path, and an element
+ * sends neither. So whatever is about to be displayed has to be signed first.
+ *
+ * This used to be implemented twice — and the second copy (houseplan-space-card)
+ * signed correctly but never handed the result to its renderer, so the plan
+ * background asked for the raw protected url and got a 401 on every render
+ * (review R3-2). One implementation, one set of rules:
+ *
+ * - requests are chunked to MAX_SIGN_PATHS, the cap the backend silently
+ * applies (an oversized call comes back partial with no way to tell what was
+ * dropped, and those entries then expire for good);
+ * - every entry carries the time it was issued: past SIGN_REFRESH_MS a
+ * replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
+ * the entry is dropped rather than served (it would 401 and raise a
+ * failed-login warning for the viewer's own IP);
+ * - `pending` is always released, so one failed request does not wedge a url
+ * forever.
+ */
+export class ContentSigner {
+ private signed: Record = {};
+ private pending = new Set();
+ private batchTimer?: ReturnType;
+ private resignTimer?: ReturnType;
+
+ /**
+ * @param onUpdate schedule a re-render (a signature arriving changes the DOM)
+ * @param now injectable clock — the tests need to age a signature
+ */
+ constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
+
+ /** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
+ start(hass: () => any, referenced: () => Set): void {
+ this.stopTimer();
+ this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
+ }
+
+ /** Release every timer; the cache survives a reconnect, the timers must not. */
+ dispose(): void {
+ this.stopTimer();
+ clearTimeout(this.batchTimer);
+ this.pending.clear();
+ }
+
+ private stopTimer(): void {
+ if (this.resignTimer !== undefined) clearInterval(this.resignTimer);
+ this.resignTimer = undefined;
+ }
+
+ /** The url to put in the DOM: a signature we hold and still trust, else ''. */
+ display(hass: any, url: string | null | undefined): string {
+ const u = contentUrl(url);
+ if (!u.startsWith('/api/houseplan/content/')) return u;
+ const hit = this.signed[u];
+ const age = hit ? this.now() - hit.at : Infinity;
+ if (age < SIGN_REFRESH_MS) return hit.url;
+ if (age < SIGN_TTL_MS) {
+ // aging but still valid: keep showing it while a fresh one is fetched
+ this.request(hass, u);
+ return hit.url;
+ }
+ if (hit) delete this.signed[u];
+ this.request(hass, u);
+ // Empty, NOT the plain path: an unsigned request to a `requires_auth` view
+ // returns 401 and Home Assistant raises a "failed login attempt".
+ return '';
+ }
+
+ private request(hass: any, url: string): void {
+ if (this.pending.has(url) || !hass?.callWS) return;
+ this.pending.add(url);
+ clearTimeout(this.batchTimer);
+ // batch: switching space asks for several urls in the same tick
+ this.batchTimer = setTimeout(() => {
+ const paths = [...this.pending];
+ this.pending.clear();
+ this.sign(hass, paths);
+ }, 30);
+ }
+
+ private sign(hass: any, paths: string[]): void {
+ if (!paths.length || !hass?.callWS) return;
+ for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
+ hass
+ .callWS({ type: 'houseplan/content/sign', paths: batch })
+ .then((r: any) => {
+ if (!r?.urls) return;
+ const at = this.now();
+ const next = { ...this.signed };
+ for (const [k, v] of Object.entries(r.urls)) next[k] = { url: v, at };
+ this.signed = next;
+ this.onUpdate();
+ })
+ .catch(() => undefined) // a retry happens on the next render
+ .finally(() => {
+ // never leave a url wedged in `pending`: the first failure would
+ // otherwise make it unrequestable for the life of the page (R3-2)
+ for (const p of batch) this.pending.delete(p);
+ });
+ }
+ }
+
+ /**
+ * Re-sign what is still in use. A wall tablet outlives a signature, and an
+ * entry for a plan replaced months ago must not consume a slot in the capped
+ * request — so prune to the urls the live config still references.
+ */
+ resign(hass: any, referenced: Set): void {
+ const now = this.now();
+ const kept: Record = {};
+ for (const [k, v] of Object.entries(this.signed)) {
+ if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
+ }
+ this.signed = kept;
+ this.sign(hass, Object.keys(kept));
+ }
+
+ /** Test/debug view of the cache. */
+ get entries(): Record {
+ return this.signed;
+ }
+}
diff --git a/src/space-card.ts b/src/space-card.ts
index 374c76b..faf4795 100644
--- a/src/space-card.ts
+++ b/src/space-card.ts
@@ -10,6 +10,8 @@ import { cardStyles } from './styles';
import { renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
+import { ContentSigner } from './signing';
+import { referencedContentUrls } from './logic';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
@@ -73,11 +75,14 @@ class HouseplanSpaceCard extends LitElement {
this._snap = null;
this.requestUpdate();
});
+ // a dashboard on a wall tablet outlives a 24 h signature
+ this._signer.start(() => this.hass, () => this._referenced());
}
public disconnectedCallback(): void {
this._unsub?.();
this._unsub = undefined;
+ this._signer.dispose();
super.disconnectedCallback();
}
@@ -109,8 +114,6 @@ class HouseplanSpaceCard extends LitElement {
public getCardSize(): number {
const models = spaceModels(this._snap?.config || null);
- // B1 follow-up: the plan needs a signed url in a browser session
- for (const m of models) if (m.bg?.href) this._signBg(m.bg);
const sp = models.find((s) => s.id === this._config?.space);
if (sp) {
const ratio = sp.vb[3] / sp.vb[2]; // h/w
@@ -123,25 +126,16 @@ class HouseplanSpaceCard extends LitElement {
return html`