v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2

R3-1 (high): v1.45.0 made the upload safe but left deletion to the client —
after a successful save the card asked the backend to remove everything but the
file it had just committed. Two open editors cannot be ordered: a delayed
request from one deleted the plan the other had just saved, leaving the
accepted configuration pointing at nothing, the exact damage copy-on-write was
introduced to prevent.

houseplan/plan/cleanup is removed. config/set collects inside its own write
lock from the two configurations that bracket the commit (plans.collect_plans):
a file the old revision referenced and the new one does not is superseded and
goes; any other unreferenced upload waits out PLAN_ORPHAN_TTL_S, because a
fresh one may belong to a transaction that has not committed yet. The collector
lives in a pure module so it can be reasoned about and unit-tested without the
HA harness.

R3-2: houseplan-space-card signed its plan url and threw the result away —
getCardSize() mutated a throwaway model while render() rebuilt its own from the
config, so the <image> requested the protected path and got 401 on every
render. Both cards now share ContentSigner (src/signing.ts), which also gives
the static card batching, expiry handling and periodic re-signing.  is
released in finally: one failed request no longer wedges a url for the life of
the page.

Tests: five backend interleaving cases from the report, six unit tests for the
pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build:
the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22
backend-pure.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-27 22:01:41 +03:00
parent f1b501a956
commit c749b52a0d
24 changed files with 875 additions and 375 deletions
+14 -95
View File
@@ -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<string, { url: string; at: number }> = {};
private _signPending = new Set<string>();
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<string>(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<string, { url: string; at: number }> = {};
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<string>();
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 {
+127
View File
@@ -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 `<image href>` or an `<a href>`: 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<string, { url: string; at: number }> = {};
private pending = new Set<string>();
private batchTimer?: ReturnType<typeof setTimeout>;
private resignTimer?: ReturnType<typeof setInterval>;
/**
* @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<string>): 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<string>(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<string>): void {
const now = this.now();
const kept: Record<string, { url: string; at: number }> = {};
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<string, { url: string; at: number }> {
return this.signed;
}
}
+16 -20
View File
@@ -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 <image> 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`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
}
private _signedBg: Record<string, string> = {};
private _signBgPending = new Set<string>();
/**
* Same signer as the main card (review R3-2). The previous copy here signed
* the url and then threw it away: `getCardSize()` mutated a throwaway model
* while `render()` rebuilt its own from the config, so the <image> kept
* asking for the raw protected path and got a 401 on every render.
*/
private _signer = new ContentSigner(() => this.requestUpdate());
/** Ask the backend for a signed content url and swap it in when it arrives. */
private _signBg(bg: { href: string }): void {
const raw = bg.href.split('?authSig=')[0];
if (!raw.startsWith('/api/houseplan/content/')) return;
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
this._signBgPending.add(raw);
this.hass
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
.then((r: any) => {
const url = r?.urls?.[raw];
if (!url) return;
this._signedBg = { ...this._signedBg, [raw]: url };
this.requestUpdate();
})
.catch(() => undefined);
private _referenced(): Set<string> {
return referencedContentUrls(this._snap?.config);
}
protected render(): TemplateResult | typeof nothing {
@@ -159,6 +153,8 @@ class HouseplanSpaceCard extends LitElement {
spaceId,
iconSize: this._config.icon_size,
lang: this._lang,
// resolved at render time: a url baked in earlier would be the unsigned one
displayUrl: (raw) => this._signer.display(this.hass, raw),
});
if (!stage) {
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
+11 -2
View File
@@ -25,6 +25,13 @@ export interface StaticRenderOpts {
spaceId: string;
iconSize?: number;
lang: Lang;
/**
* Resolve a stored content url to what the DOM may actually request the
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
* Returning '' means "not signed yet": the caller must render no <image>
* rather than an unsigned one, which would 401 (review R3-2).
*/
displayUrl?: (raw: string) => string;
}
/**
@@ -118,11 +125,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
})
: [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
${roomShapes}
</svg>