mirror of
https://github.com/Matysh/houseplan-card
synced 2026-08-01 00:48:29 +00:00
v1.46.0: full external audit of v1.45.4 — HP-1454-01 … -10
HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a top-level document of Home Assistant's own origin, so a <script> inside it reaches the session's localStorage and API. Uploading needs write access, which by default every authenticated user has. SVG responses now carry a sandbox CSP; only SVG, because a CSP on a PDF can break the browser's viewer and a raster image has nothing to disable. Verified in Chromium both ways: the script runs without the header and does not with it. HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside the config transaction — a cancelled dialog or a rejected save left the stored url serving new bytes, and every new icon shared one 'new' folder, so two of them attaching manual.pdf pointed at one file. Uploads take a free name, a new icon gets a per-dialog staging folder promoted on an accepted save, and config/set collects superseded and aged-orphan attachments like it does plans. HP-1454-03: the debounce spaced out the starts of a write, not the writes. A save slower than 500 ms let the next edit go out with the same expected_rev; the server accepted the first, rejected the second, and the conflict handler reloaded over the local copy. Writes are chained now — one in flight, each with the revision the previous returned. HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect change or a dragged vertex left open boundaries and their glow cuts at old coordinates. It keys on the rendered model object now — the same invalidation the model cache already has, not a second strategy. The fingerprint also gained an O(1) geometry roll-up per room. HP-1454-05: outer collections were capped, inner ones were not. Limits for poly points, open_to, controls, pdfs, text and url lengths, plus a total serialized size cap; legacy is dropped server-side. HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a 50 MB manual no longer costs ~100 MB of RSS per transfer. HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the per-room fill override. HP-1454-08: layout had no revision on point-wise writes and no event, leaving static cards stale forever; it now keeps a revision, returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only walked existing spaces, so a deleted space kept its warning. HP-1454-10: serialize-javascript pinned past two advisories. Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and smoke_render_parity (both verified failing against a v1.45.4 build), six pure tests for attachment collection and inner limits, four HA-harness tests for the CSP, non-overwriting uploads, the size cap and layout revisions. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
+10
-4
@@ -47,11 +47,17 @@ async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
|
||||
};
|
||||
if (!subscribed && hass.connection?.subscribeEvents) {
|
||||
subscribed = true;
|
||||
const invalidate = () => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
try {
|
||||
await hass.connection.subscribeEvents(() => {
|
||||
cache = null; // invalidate; listeners reload
|
||||
listeners.forEach((l) => l());
|
||||
}, 'houseplan_config_updated');
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_config_updated');
|
||||
// Layout is separate state: dragging an icon on the full card writes only
|
||||
// the layout, so a static card on the same dashboard kept showing the old
|
||||
// position until the config changed or the page was reloaded — possibly
|
||||
// forever on a wall tablet (HP-1454-08).
|
||||
await hass.connection.subscribeEvents(invalidate, 'houseplan_layout_updated');
|
||||
} catch {
|
||||
subscribed = false;
|
||||
}
|
||||
|
||||
+94
-53
@@ -35,7 +35,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.45.4';
|
||||
const CARD_VERSION = '1.46.0';
|
||||
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';
|
||||
@@ -234,6 +234,15 @@ class HouseplanCard extends LitElement {
|
||||
private _infoCard: DevItem | null = null;
|
||||
private _markerDialog: {
|
||||
devId?: string; // the icon being edited (if any)
|
||||
/**
|
||||
* Folder attachments are uploaded into while this dialog is open. For a NEW
|
||||
* icon there is no marker id yet; every one of them used to upload into a
|
||||
* shared `files/new/`, so two markers attaching `manual.pdf` ended up
|
||||
* pointing at the same bytes (HP-1454-02). A per-dialog id keeps them
|
||||
* apart, and the files are moved to the real marker id once the config
|
||||
* write is accepted — the same copy→save→cleanup order as a rebind.
|
||||
*/
|
||||
uploadId?: string;
|
||||
name: string;
|
||||
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' | '' (not chosen yet)
|
||||
bindingMode: 'virtual' | 'ha';
|
||||
@@ -556,9 +565,17 @@ class HouseplanCard extends LitElement {
|
||||
for (const x of sp as any[]) {
|
||||
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||
for (const r of x.rooms || [])
|
||||
for (const r of x.rooms || []) {
|
||||
// O(1) geometry roll-up per room: the count alone said nothing about
|
||||
// where the room actually is, so a moved rectangle or a dragged first/
|
||||
// last vertex looked identical (HP-1454-04). The epoch remains the
|
||||
// primary signal — this is the belt for a mutation that forgot to bump it.
|
||||
const p0 = r.poly?.[0], pn = r.poly?.[r.poly.length - 1];
|
||||
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
||||
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + ';';
|
||||
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + '.'
|
||||
+ (r.x ?? '') + ',' + (r.y ?? '') + ',' + (r.w ?? '') + ',' + (r.h ?? '') + ','
|
||||
+ (p0 ? p0[0] + '/' + p0[1] : '') + ',' + (pn ? pn[0] + '/' + pn[1] : '') + ';';
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -1530,8 +1547,44 @@ class HouseplanCard extends LitElement {
|
||||
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config writes are serialized (HP-1454-03).
|
||||
*
|
||||
* The debounce only spaced out the *starts*. If a write took longer than
|
||||
* 500 ms — a busy instance, a slow link — the next edit went out with the
|
||||
* same `expected_rev`, the server accepted the first and rejected the second
|
||||
* as a conflict, and the conflict handler reloaded the server copy over the
|
||||
* local one. The user's second edit was gone, with a toast that blamed
|
||||
* another window when there was none.
|
||||
*
|
||||
* One chain, one write in flight. A write always reads `_serverCfg` at the
|
||||
* moment it runs, so edits made while another write was out are carried by
|
||||
* the next one, with the revision that write returned.
|
||||
*/
|
||||
private _writesPending = 0;
|
||||
private _writeChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/** A config write is in flight — the card must not adopt a server revision. */
|
||||
private _cfgWriting = false;
|
||||
private get _cfgWriting(): boolean {
|
||||
return this._writesPending > 0;
|
||||
}
|
||||
|
||||
private _writeConfig(): Promise<void> {
|
||||
this._writesPending++;
|
||||
this._writeChain = this._writeChain
|
||||
.catch(() => undefined) // a failed write must not poison the queue
|
||||
.then(async () => {
|
||||
if (!this._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
});
|
||||
const mine = this._writeChain.finally(() => { this._writesPending--; });
|
||||
// keep the chain itself unadorned so the next link waits for the write only
|
||||
return mine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mutation path ends here, so this is the one place that can invalidate
|
||||
@@ -1546,24 +1599,16 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _saveConfigDebounced = debounce(() => {
|
||||
if (!this._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
this._cfgWriting = true;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
|
||||
.then((r: any) => {
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
this._cfgWriting = false;
|
||||
})
|
||||
.catch((e: any) => {
|
||||
this._cfgWriting = false;
|
||||
if (e?.code === 'conflict') {
|
||||
this._showToast(this._t('toast.conflict'));
|
||||
this._cancelPath();
|
||||
this._reloadConfigOnly(true);
|
||||
} else {
|
||||
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
||||
}
|
||||
});
|
||||
this._writeConfig().catch((e: any) => {
|
||||
if (e?.code === 'conflict') {
|
||||
// a real one now: another window wrote between our read and our write
|
||||
this._showToast(this._t('toast.conflict'));
|
||||
this._cancelPath();
|
||||
this._reloadConfigOnly(true);
|
||||
} else {
|
||||
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
|
||||
/**
|
||||
@@ -1961,21 +2006,24 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** All open-boundary pairs of the current space with their shared segments. */
|
||||
private _openPairsCache: { key: string; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||
private _openPairsCache: { model: SpaceModel; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||
|
||||
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
|
||||
// collinear-overlap math on every HA state push). Memoized on the config
|
||||
// epoch + current space.
|
||||
// The key includes the open_to links themselves: _serverCfg is mutated in
|
||||
// place in ~22 places (audit L7), so an epoch counter alone is not a
|
||||
// trustworthy cache key — a missed bump would render a stale plan, which is
|
||||
// far worse than recomputing a short string here.
|
||||
// collinear-overlap math on every HA state push), so it is memoized.
|
||||
//
|
||||
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
|
||||
// string of room ids and open_to links, which said nothing about geometry:
|
||||
// change the space's aspect, or drag a vertex, and the shared segments were
|
||||
// recomputed for the outlines but the open boundaries — and the glow cuts
|
||||
// that follow them — kept their old coordinates until a full reload.
|
||||
// `_model` is already rebuilt whenever the epoch or the config fingerprint
|
||||
// moves, and everything below derives from it, so its identity is an exact
|
||||
// and cheaper key. One cache invalidation strategy, not two.
|
||||
const sp = this._spaceModel();
|
||||
const key = this._space + '|' + sp.rooms.map((r) => r.id + ':' + ((r as any).open_to || []).join(',')).join(';');
|
||||
if (this._openPairsCache && this._openPairsCache.key === key) return this._openPairsCache.pairs;
|
||||
if (this._openPairsCache && this._openPairsCache.model === sp) return this._openPairsCache.pairs;
|
||||
const pairs = this._computeOpenPairs();
|
||||
this._openPairsCache = { key, pairs };
|
||||
this._openPairsCache = { model: sp, pairs };
|
||||
return pairs;
|
||||
}
|
||||
|
||||
@@ -2508,6 +2556,7 @@ class HouseplanCard extends LitElement {
|
||||
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
|
||||
glowRadius: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', busy: false,
|
||||
uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2619,7 +2668,7 @@ class HouseplanCard extends LitElement {
|
||||
const files = input.files ? [...input.files] : [];
|
||||
input.value = '';
|
||||
if (!files.length || !this._markerDialog) return;
|
||||
const mid = this._markerDialog.devId || 'new';
|
||||
const mid = this._markerDialog.uploadId || this._markerDialog.devId || 'new';
|
||||
const uploaded: PdfRef[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
@@ -2728,13 +2777,16 @@ class HouseplanCard extends LitElement {
|
||||
// stored config still resolve — the files never left. A failed copy
|
||||
// leaves the urls untouched and tells the user (review CR-3).
|
||||
let cleanupOldFiles = false;
|
||||
if (oldId && oldId !== id && marker.pdfs?.length) {
|
||||
// a new icon uploaded into its own staging folder; an edited one into its
|
||||
// own id. Either way the files move to the final id here.
|
||||
const fileSrc = dlg.uploadId || oldId;
|
||||
if (fileSrc && fileSrc !== id && marker.pdfs?.length) {
|
||||
try {
|
||||
const res: any = await this.hass.callWS({
|
||||
type: 'houseplan/files/migrate', from_id: oldId, to_id: id,
|
||||
type: 'houseplan/files/migrate', from_id: fileSrc, to_id: id,
|
||||
});
|
||||
const mapping = res?.mapping || {};
|
||||
marker.pdfs = migratePdfUrls(marker.pdfs, oldId, id, mapping);
|
||||
marker.pdfs = migratePdfUrls(marker.pdfs, fileSrc, id, mapping);
|
||||
cleanupOldFiles = Object.keys(mapping).length > 0;
|
||||
} catch (e: any) {
|
||||
this._showToast(this._t('toast.files_migrate_failed', { err: this._errText(e) }));
|
||||
@@ -2786,9 +2838,9 @@ class HouseplanCard extends LitElement {
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined);
|
||||
}
|
||||
// the config is committed — now it is safe to drop the old folder
|
||||
if (cleanupOldFiles && oldId) {
|
||||
if (cleanupOldFiles && fileSrc) {
|
||||
await this.hass
|
||||
.callWS({ type: 'houseplan/files/cleanup', marker_id: oldId })
|
||||
.callWS({ type: 'houseplan/files/cleanup', marker_id: fileSrc })
|
||||
.catch(() => undefined); // leftovers are harmless; broken links are not
|
||||
}
|
||||
this._markerDialog = null;
|
||||
@@ -3048,25 +3100,14 @@ class HouseplanCard extends LitElement {
|
||||
user's retry starts from the fresh config instead of hitting the same
|
||||
conflict again. */
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
this._dropLegacySegments();
|
||||
this._cfgEpoch++;
|
||||
// same flag the debounced writer uses: while it is set, an incoming
|
||||
// `houseplan_config_updated` defers its reload instead of replacing the
|
||||
// config under an unfinished write (audit L2, extended to this path)
|
||||
this._cfgWriting = true;
|
||||
try {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
// same queue as the debounced writer: a dialog saving while a background
|
||||
// write is still out must not race it into a self-inflicted conflict
|
||||
await this._writeConfig();
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'conflict') {
|
||||
this._cfgWriting = false;
|
||||
await this._reloadConfigOnly();
|
||||
}
|
||||
if (e?.code === 'conflict') await this._reloadConfigOnly();
|
||||
throw e;
|
||||
} finally {
|
||||
this._cfgWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
area: r.area ?? null,
|
||||
// carried, not dropped: the static card renders from this model too, and
|
||||
// without them it ignored the room-level fill override and drew a room the
|
||||
// full card leaves transparent (HP-1454-07)
|
||||
open_to: r.open_to || undefined,
|
||||
settings: r.settings || undefined,
|
||||
x: r.x != null ? r.x * NORM_W : undefined,
|
||||
y: r.y != null ? r.y * H : undefined,
|
||||
w: r.w != null ? r.w * NORM_W : undefined,
|
||||
|
||||
+8
-6
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { html, svg, nothing, type TemplateResult } from 'lit';
|
||||
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
|
||||
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import type { ServerConfig } from './types';
|
||||
@@ -73,16 +73,18 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
.map((r) => {
|
||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
|
||||
let style = '';
|
||||
if (disp.showBorders || disp.fill !== 'none') {
|
||||
// tier 3 wins over the space, exactly as on the full card (HP-1454-07)
|
||||
const fill = roomFillModeOf(disp.fill, r);
|
||||
if (disp.showBorders || fill !== 'none') {
|
||||
cls += ' styled';
|
||||
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
|
||||
// fill rendered exactly as configured on the full card (snapshot of current states)
|
||||
const fillC = r.area
|
||||
? roomFillStyle(
|
||||
disp.fill,
|
||||
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||
fill,
|
||||
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
|
||||
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
|
||||
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
fillColorsOf(o.cfg?.settings),
|
||||
|
||||
Reference in New Issue
Block a user