fix v1.43.0: external audit P0 — data loss, split geometry, auth, dialog zombies

L2 (silent data loss): debounce gains flush()/pending(); _reloadConfigOnly
flushes a pending write and defers while one is in flight; conflict path
forces; failed reload now toasts instead of an empty catch; teardown flushes.

G1 (split corruption): same-edge cuts carve the niche properly instead of
walking the outline twice; partition invariant (parts sum to the original)
rejects anything else; +1 unit test covering 5 niche shapes and both legacy
cut shapes.

B1 (unauthenticated content): plans and marker files move to
HouseplanContentView (/api/houseplan/content/..., requires_auth); only the
card bundle stays static; contentUrl() rewrites legacy URLs on read (no
storage migration); repairs.py accepts both prefixes; +1 unit test.

L3 (dialog zombies): all four save catch-blocks guard against a closed
dialog; the card no longer blanks when a save fails after Esc.

smokes: smoke_save_race, smoke_dialog_zombie; docs (TESTING/CHANGELOG/
ARCHITECTURE incl. the optimistic-UI note) same-commit
This commit is contained in:
Matysh
2026-07-27 10:44:58 +03:00
parent 5c7d1ca8bb
commit 0fd0ba408d
21 changed files with 464 additions and 98 deletions
+7 -6
View File
@@ -28,9 +28,10 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view.""" """Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass) hp_ws.async_register(hass)
from .http_api import HouseplanUploadView from .http_api import HouseplanContentView, HouseplanUploadView
hass.http.register_view(HouseplanUploadView()) hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
return True return True
@@ -61,14 +62,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
if card_path.exists(): if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)) static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True)) # NOTE (audit B1): plans and marker files are NO LONGER static.
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True)) # They are served by HouseplanContentView, which requires auth.
await hass.http.async_register_static_paths(static_paths) # Only the card bundle stays public — Lovelace resources must be.
if static_paths:
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions except ImportError: # very old HA versions
if card_path.exists(): if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False) hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
if not card_path.exists(): if not card_path.exists():
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path) _LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
+3 -1
View File
@@ -9,9 +9,11 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans" PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files" FILES_URL = "/houseplan_files/files"
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
CONTENT_URL = "/api/houseplan/content"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.42.2" VERSION = "1.43.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+55 -2
View File
@@ -18,7 +18,7 @@ except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment] KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
from .store import get_entry from .store import get_entry
from .validation import ( from .validation import (
FILE_EXTENSIONS, FILE_EXTENSIONS,
@@ -32,6 +32,59 @@ _LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024 _CHUNK = 64 * 1024
_MIME = {
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".webp": "image/webp",
".gif": "image/gif",
".txt": "text/plain",
}
class HouseplanContentView(HomeAssistantView):
"""Authenticated read access to plans and marker files (audit B1).
The directories used to be exposed as unauthenticated static paths, so
anyone who could reach the HA endpoint could pull floor plans and uploaded
manuals without logging in. This view keeps the same URLs but requires a
Home Assistant session (or a signed path, which the frontend uses for
<image href> inside the SVG).
"""
url = "/api/houseplan/content/{kind}/{sub}/{name}"
name = "api:houseplan:content"
requires_auth = True
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
hass: HomeAssistant = request.app[KEY_HASS]
if kind not in ("plans", "files"):
return web.Response(status=404)
safe_sub = sanitize_marker_id(sub)
safe_name = sanitize_filename(name)
if not safe_sub or not safe_name:
return web.Response(status=404)
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
# plans live flat in one directory: the sub segment is a placeholder ("_")
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
# defence in depth: the sanitizers already strip separators
if not str(path).startswith(str(base)):
return web.Response(status=404)
def _read() -> bytes | None:
return path.read_bytes() if path.is_file() else None
blob = await hass.async_add_executor_job(_read)
if blob is None:
return web.Response(status=404)
return web.Response(
body=blob,
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
headers={"Cache-Control": "private, max-age=3600"},
)
class HouseplanUploadView(HomeAssistantView): class HouseplanUploadView(HomeAssistantView):
"""POST /api/houseplan/upload — save a marker file, return its URL.""" """POST /api/houseplan/upload — save a marker file, return its URL."""
@@ -99,5 +152,5 @@ class HouseplanUploadView(HomeAssistantView):
mtime = await hass.async_add_executor_job(_write) mtime = await hass.async_add_executor_job(_write)
return web.json_response( return web.json_response(
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename} {"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
) )
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.42.2" "version": "1.43.0"
} }
+9 -3
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, PLANS_DIR, PLANS_URL from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
from .store import HouseplanConfigEntry from .store import HouseplanConfigEntry
@@ -25,9 +25,15 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
res = [] res = []
for sp in spaces: for sp in spaces:
url = sp.get("plan_url") or "" url = sp.get("plan_url") or ""
if not url.startswith(PLANS_URL + "/"): # both the legacy static URL and the authenticated content URL
prefix = None
if url.startswith(PLANS_URL + "/"):
prefix = PLANS_URL + "/"
elif url.startswith(CONTENT_URL + "/plans/_/"):
prefix = CONTENT_URL + "/plans/_/"
if prefix is None:
continue # external/legacy URL — not ours to verify continue # external/legacy URL — not ours to verify
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0] fname = url[len(prefix) :].split("?", 1)[0]
if not (plans_dir / fname).is_file(): if not (plans_dir / fname).is_file():
res.append((sp.get("id", "?"), fname)) res.append((sp.get("id", "?"), fname))
return res return res
+2 -2
View File
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback
from .const import ( from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG, CONF_ADMIN_ONLY, DEFAULT_CONFIG,
PLANS_DIR, PLANS_URL, CONTENT_URL, PLANS_DIR, PLANS_URL,
) )
from .store import HouseplanData, get_data, get_entry from .store import HouseplanData, get_data, get_entry
from .validation import ( from .validation import (
@@ -290,5 +290,5 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
mtime = await hass.async_add_executor_job(_write) mtime = await hass.async_add_executor_job(_write)
connection.send_result( connection.send_result(
msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"} msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{space_id}.{msg['ext']}?v={mtime}"}
) )
+55
View File
@@ -0,0 +1,55 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
let pageError = null;
window.addEventListener('error', (e) => { pageError = String(e.message); });
// сохранение падает, а диалог закрыт до ответа — карточка не должна умереть
c.hass = { ...c.hass, callWS: async (msg) => {
if (String(msg.type).endsWith('/set')) { await new Promise((r) => setTimeout(r, 60)); throw new Error('boom'); }
return { config: c._serverCfg, rev: c._cfgRev };
} };
await c.updateComplete;
// 1) диалог общих настроек
c._openSettingsDialog(); await c.updateComplete;
const p = c._saveSettingsDialog();
c._settingsDialog = null; // Esc во время сохранения
await c.updateComplete;
await p.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
out.settingsStaysClosed = c._settingsDialog === null;
out.cardAliveAfterSettings = !!sr().querySelector('.stage');
// 2) диалог правил
c._openRulesDialog(); await c.updateComplete;
const p2 = c._saveRules();
c._rulesDialog = null; await c.updateComplete;
await p2.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
out.rulesStaysClosed = c._rulesDialog === null;
out.cardAliveAfterRules = !!sr().querySelector('.stage');
// 3) диалог устройства
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const p3 = c._saveMarker();
c._markerDialog = null; await c.updateComplete;
await p3.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
out.markerStaysClosed = c._markerDialog === null;
out.cardAliveAfterMarker = !!sr().querySelector('.stage');
out.noPageError = pageError === null;
// 4) при ОТКРЫТОМ диалоге ошибка снимает busy (поведение сохранено)
c._openSettingsDialog(); await c.updateComplete;
const p4 = c._saveSettingsDialog();
await p4.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
out.busyClearedWhenOpen = c._settingsDialog !== null && c._settingsDialog.busy === false;
c._settingsDialog = null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+36
View File
@@ -0,0 +1,36 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sent = [];
// перехват WS: config/set логируем, config/get отдаёт "серверную" копию БЕЗ локальной правки
const server = { cfg: JSON.parse(JSON.stringify(c._serverCfg)), rev: c._cfgRev };
c.hass = { ...c.hass, callWS: async (msg) => {
if (msg.type === 'houseplan/config/set') {
sent.push(JSON.parse(JSON.stringify(msg.config)));
server.cfg = JSON.parse(JSON.stringify(msg.config));
server.rev = (msg.expected_rev ?? server.rev) + 1;
return { rev: server.rev };
}
if (msg.type === 'houseplan/config/get') return { config: JSON.parse(JSON.stringify(server.cfg)), rev: server.rev };
return {};
} };
await c.updateComplete;
// локальная правка (как разметка комнаты) + дебаунс
const sp = c._curSpaceCfg;
sp.rooms.push({ id: 'race_room', name: 'RACE', area: null, poly: [[0.8, 0.8], [0.9, 0.8], [0.9, 0.9], [0.8, 0.9]] });
c._saveConfig();
out.pending = c._saveConfig.pending();
// через 100 мс приходит событие о чужой ревизии — раньше это стирало правку
c._cfgRev = server.rev; // симулируем: наша ревизия отстала
await c._reloadConfigOnly();
await new Promise((r) => setTimeout(r, 900));
// правка обязана уцелеть и уйти на сервер
out.editSent = sent.some((cf) => cf.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room')));
out.editInMemory = c._serverCfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
out.serverHasIt = server.cfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+20 -20
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -255,3 +255,20 @@ more specific tier overrides the more general one; "unset" always means
The UI will later be unified around this model; until then each tier keeps its The UI will later be unified around this model; until then each tier keeps its
own dialog (general settings gear / space gear / room-card gear / marker own dialog (general settings gear / space gear / room-card gear / marker
dialog). dialog).
## Audit follow-ups (2026-07-27)
- **Content is authenticated.** `/houseplan_files/…` now serves ONLY the card
bundle (a Lovelace resource must be public). Plans and marker files go
through `HouseplanContentView` (`/api/houseplan/content/<plans|files>/…`,
`requires_auth`). `contentUrl()` rewrites legacy stored URLs on read, so no
storage migration is needed. Static paths cannot be unregistered — the old
routes survive until the next HA restart.
- **Optimistic UI, stated explicitly (audit L7).** `_serverCfg` is mutated in
place before a fallible save in ~22 places and there is no rollback: after a
rejected save the UI shows the edit until the next reload. This is a
deliberate optimistic-UI choice, not drift. Paths where it is unacceptable
need their own rollback.
- **Split invariant.** `splitRoomPath` guarantees a partition: the two parts'
areas sum to the original (within epsilon) or the cut is rejected.
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## v1.43.0 — 2026-07-27 (external audit: P0 fixes)
An external code audit of v1.41.1 found four critical issues. All four are fixed
and covered by regression tests.
- **Silent data loss on save (L2).** A debounced config write read the config at
fire time, so a `houseplan_config_updated` event arriving in between replaced
it and the user's edit vanished with no error — reproducible in a single tab.
The debounce now supports `flush()`/`pending()`, a reload flushes the pending
write first and defers while a write is in flight, and a failed reload finally
reports instead of staying silent.
- **Split corrupted room geometry (G1).** A cut starting and ending on the SAME
wall (carving a niche — a natural action) produced two overlapping,
self-intersecting rooms whose areas summed to twice the original, and the
overlap guard did not catch it. Same-edge cuts now carve the niche correctly,
and a partition invariant (parts must sum to the original) rejects anything
else.
- **Plans and uploaded files were served without authentication (B1).** Anyone
who could reach the HA endpoint could fetch floor plans and attached manuals
without logging in. They are now served by an authenticated view; stored
legacy URLs are rewritten on read, so nothing breaks. **The old public paths
disappear after a Home Assistant restart.**
- **Dialogs could resurrect and blank the card (L3).** Closing a dialog while
its save was in flight, on a failed save, spread `null` into a truthy husk;
the renderer then threw and the card went blank until reload. Guarded in all
four save routines; the error toast still fires.
## v1.42.2 — 2026-07-26 ## v1.42.2 — 2026-07-26
- Touch devices no longer pop hover tooltips on every tap (field feedback: - Touch devices no longer pop hover tooltips on every tap (field feedback:
"extra labels appear and get in the way on a tablet"). Hover tooltips are "extra labels appear and get in the way on a tablet"). Hover tooltips are
+13
View File
@@ -140,6 +140,19 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any - [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any
dialog within 500 ms (or let another client save) — the markup edit must
survive and reach the server; a failed reload now shows a toast [auto]
- [ ] Niche split (v1.43.0, audit G1): a cut that starts AND ends on the same
wall carves a niche; the two parts' areas must sum to the original (the
invariant is enforced in code and asserted for every split test) [auto]
- [ ] Authenticated content (v1.43.0, audit B1): plan images and marker files
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]
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is
in flight and let the save fail — the dialog stays closed, the card keeps
rendering, the error toast still fires [auto]
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets, - [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
phones) taps never pop the room/device tooltip — the data lives in room phones) taps never pop the room/device tooltip — the data lives in room
cards and long-press; desktop hover tooltips unchanged [auto] cards and long-press; desktop hover tooltips unchanged [auto]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.42.2", "version": "1.43.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+89 -16
View File
@@ -14,7 +14,7 @@ import {
import { import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding, lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
snapToWall, openingAmount, snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo, averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.42.2'; const CARD_VERSION = '1.43.0';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -54,12 +54,40 @@ const navigate = (path: string) => {
fireEvent(window, 'location-changed', { replace: false }); fireEvent(window, 'location-changed', { replace: false });
}; };
const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number) => { /**
* Debounce with `flush()` and `pending`. Both are load-bearing: a pending
* config write MUST be flushed before the card adopts a server revision,
* otherwise the edit is silently dropped (audit L2, 2026-07-27).
*/
interface Debounced<T extends (...a: any[]) => void> {
(...a: Parameters<T>): void;
flush(): void;
pending(): boolean;
}
const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced<T> => {
let t: number | undefined; let t: number | undefined;
return (...a: Parameters<T>) => { let last: Parameters<T> | null = null;
const wrapped = ((...a: Parameters<T>) => {
clearTimeout(t); clearTimeout(t);
t = window.setTimeout(() => fn(...a), ms); last = a;
t = window.setTimeout(() => {
t = undefined;
const args = last;
last = null;
if (args) fn(...args);
}, ms);
}) as Debounced<T>;
wrapped.flush = () => {
if (t === undefined) return;
clearTimeout(t);
t = undefined;
const args = last;
last = null;
if (args) fn(...args);
}; };
wrapped.pending = () => t !== undefined;
return wrapped;
}; };
class HouseplanCard extends LitElement { class HouseplanCard extends LitElement {
@@ -331,6 +359,8 @@ class HouseplanCard extends LitElement {
clearInterval(this._cycleTimer); clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer); clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer); clearTimeout(this._kioskHoldTimer);
clearTimeout(this._reloadRetry);
this._saveConfig.flush(); // never leave an edit unsent on teardown
window.removeEventListener('hashchange', this._onHashChange); window.removeEventListener('hashchange', this._onHashChange);
clearTimeout(this._holdTimer); clearTimeout(this._holdTimer);
this._roViewport?.disconnect(); this._roViewport?.disconnect();
@@ -510,7 +540,7 @@ class HouseplanCard extends LitElement {
id: s.id, id: s.id,
title: s.title, title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H], vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null, bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
rooms: s.rooms.map(scale), rooms: s.rooms.map(scale),
}; };
}); });
@@ -620,6 +650,9 @@ class HouseplanCard extends LitElement {
// live sync: the config was changed in another window → re-read it // live sync: the config was changed in another window → re-read it
if (!this._unsubCfg) { if (!this._unsubCfg) {
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => { this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
// Flush a pending local edit BEFORE adopting a remote revision:
// otherwise the debounced write reads a config that this reload has
// already replaced, and the user's edit vanishes (audit L2).
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly(); if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
}, 'houseplan_config_updated'); }, 'houseplan_config_updated');
} }
@@ -657,7 +690,22 @@ class HouseplanCard extends LitElement {
this.requestUpdate(); this.requestUpdate();
} }
private async _reloadConfigOnly(): Promise<void> { /**
* Adopt the server config. Any pending local write is flushed first and, if a
* write is still in flight, the reload is deferred adopting a revision on
* top of an unsent edit is exactly how edits disappeared (audit L2).
* `force` skips the deferral (conflict path: the local edit already lost).
*/
private async _reloadConfigOnly(force = false): Promise<void> {
if (!force) {
if (this._saveConfig.pending()) this._saveConfig.flush();
if (this._cfgWriting) {
// retry once the in-flight write settles
clearTimeout(this._reloadRetry);
this._reloadRetry = window.setTimeout(() => this._reloadConfigOnly(), 400);
return;
}
}
try { try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' }); const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
const cfg = resp?.config; const cfg = resp?.config;
@@ -667,11 +715,14 @@ class HouseplanCard extends LitElement {
this._regSignature = ''; this._regSignature = '';
this._maybeRebuildDevices(); this._maybeRebuildDevices();
this.requestUpdate(); this.requestUpdate();
} catch { } catch (e: any) {
/* ignore */ // a failed reload leaves the card on its last known config; tell the user
// rather than silently diverging from the server (audit L2 note)
this._showToast(this._t('toast.cfg_reload_failed', { err: this._errText(e) }));
} }
} }
private _reloadRetry?: number;
private _dirtyPos = new Set<string>(); private _dirtyPos = new Set<string>();
private _persistLayout = debounce(() => { private _persistLayout = debounce(() => {
@@ -1383,19 +1434,25 @@ class HouseplanCard extends LitElement {
for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments; for (const sp of this._serverCfg?.spaces || []) delete (sp as any).segments;
} }
/** A config write is in flight — the card must not adopt a server revision. */
private _cfgWriting = false;
private _saveConfig = debounce(() => { private _saveConfig = debounce(() => {
if (!this._serverCfg) return; if (!this._serverCfg) return;
this._dropLegacySegments(); this._dropLegacySegments();
this._cfgWriting = true;
this.hass this.hass
.callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev }) .callWS({ type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev })
.then((r: any) => { .then((r: any) => {
this._cfgRev = r?.rev ?? this._cfgRev + 1; this._cfgRev = r?.rev ?? this._cfgRev + 1;
this._cfgWriting = false;
}) })
.catch((e: any) => { .catch((e: any) => {
this._cfgWriting = false;
if (e?.code === 'conflict') { if (e?.code === 'conflict') {
this._showToast(this._t('toast.conflict')); this._showToast(this._t('toast.conflict'));
this._cancelPath(); this._cancelPath();
this._reloadConfigOnly(); this._reloadConfigOnly(true);
} else { } else {
this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) })); this._showToast(this._t('toast.cfg_save_failed', { err: this._errText(e) }));
} }
@@ -2577,7 +2634,11 @@ class HouseplanCard extends LitElement {
this._maybeRebuildDevices(); this._maybeRebuildDevices();
this._showToast(this._t('toast.marker_saved')); this._showToast(this._t('toast.marker_saved'));
} catch (e: any) { } catch (e: any) {
this._markerDialog = { ...this._markerDialog!, busy: false }; // audit L3: the dialog may have been closed (Esc) while the save was
// in flight — spreading null yields a truthy husk and the renderer
// then crashes, blanking the whole card. The toast below is the
// only remaining signal, so it must still fire.
if (this._markerDialog) this._markerDialog = { ...this._markerDialog, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) })); this._showToast(this._t('toast.error', { err: this._errText(e) }));
} }
} }
@@ -2772,7 +2833,11 @@ class HouseplanCard extends LitElement {
this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved')); this._showToast(d.mode === 'create' ? this._t('toast.space_added') : this._t('toast.space_saved'));
} }
} catch (e: any) { } catch (e: any) {
this._spaceDialog = { ...this._spaceDialog!, busy: false }; // audit L3: the dialog may have been closed (Esc) while the save was
// in flight — spreading null yields a truthy husk and the renderer
// then crashes, blanking the whole card. The toast below is the
// only remaining signal, so it must still fire.
if (this._spaceDialog) this._spaceDialog = { ...this._spaceDialog, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) })); this._showToast(this._t('toast.error', { err: this._errText(e) }));
} }
} }
@@ -2930,7 +2995,11 @@ class HouseplanCard extends LitElement {
this.requestUpdate(); this.requestUpdate();
this._showToast(this._t('gs.saved')); this._showToast(this._t('gs.saved'));
} catch (e: any) { } catch (e: any) {
this._settingsDialog = { ...this._settingsDialog!, busy: false }; // audit L3: the dialog may have been closed (Esc) while the save was
// in flight — spreading null yields a truthy husk and the renderer
// then crashes, blanking the whole card. The toast below is the
// only remaining signal, so it must still fire.
if (this._settingsDialog) this._settingsDialog = { ...this._settingsDialog, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) })); this._showToast(this._t('toast.error', { err: this._errText(e) }));
} }
} }
@@ -3115,7 +3184,11 @@ class HouseplanCard extends LitElement {
this._maybeRebuildDevices(); this._maybeRebuildDevices();
this._showToast(this._t('rules.saved')); this._showToast(this._t('rules.saved'));
} catch (e: any) { } catch (e: any) {
this._rulesDialog = { ...this._rulesDialog!, busy: false }; // audit L3: the dialog may have been closed (Esc) while the save was
// in flight — spreading null yields a truthy husk and the renderer
// then crashes, blanking the whole card. The toast below is the
// only remaining signal, so it must still fire.
if (this._rulesDialog) this._rulesDialog = { ...this._rulesDialog, busy: false };
this._showToast(this._t('toast.error', { err: this._errText(e) })); this._showToast(this._t('toast.error', { err: this._errText(e) }));
} }
} }
@@ -4330,7 +4403,7 @@ class HouseplanCard extends LitElement {
${d.pdfs && d.pdfs.length ${d.pdfs && d.pdfs.length
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist"> ? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
${d.pdfs.map( ${d.pdfs.map(
(p) => html`<a class="pdf" href="${safeUrl(p.url) || '#'}" target="_blank" rel="noreferrer noopener"> (p) => html`<a class="pdf" href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`, <ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
)}</span></div>` )}</span></div>`
: nothing} : nothing}
@@ -4562,7 +4635,7 @@ class HouseplanCard extends LitElement {
<div class="pdfedit"> <div class="pdfedit">
${d.pdfs.map( ${d.pdfs.map(
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon> (p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
<a href="${safeUrl(p.url) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a> <a href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
<ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`, <ha-icon class="x" icon="mdi:close" @click=${() => this._removeMarkerPdf(p.url)}></ha-icon></span>`,
)} )}
<label class="btn filebtn"> <label class="btn filebtn">
+2 -1
View File
@@ -322,5 +322,6 @@
"room.sizes_section": "Font sizes", "room.sizes_section": "Font sizes",
"room.name_scale": "Room name size", "room.name_scale": "Room name size",
"room.label_scale": "Metrics size", "room.label_scale": "Metrics size",
"preview.room_name": "Living room" "preview.room_name": "Living room",
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}"
} }
+2 -1
View File
@@ -322,5 +322,6 @@
"room.sizes_section": "Размеры шрифтов", "room.sizes_section": "Размеры шрифтов",
"room.name_scale": "Размер названия", "room.name_scale": "Размер названия",
"room.label_scale": "Размер подписей", "room.label_scale": "Размер подписей",
"preview.room_name": "Гостиная" "preview.room_name": "Гостиная",
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}"
} }
+48 -2
View File
@@ -380,10 +380,40 @@ export function splitRoomPath(
acc.push(to); acc.push(to);
return dropRepeats(acc, eps); return dropRepeats(acc, eps);
}; };
const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps); let p1: number[][];
const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps); let p2: number[][];
if (ia === ib) {
// BOTH ends on the SAME edge — carving an alcove out of one wall. The walk
// above would traverse the whole outline twice and return two overlapping,
// self-intersecting rooms whose areas sum to 2x the original (audit G1,
// 2026-07-27). The niche is simply the path closed along that edge; the
// remainder is the outline with that stretch replaced by the path.
const niche = dropRepeats([...pts], eps);
if (niche.length < 3 || polygonArea(niche) <= eps) return null;
// the niche must not swallow other geometry: it stays inside the room
const rest: number[][] = [];
for (let i = 0; i < poly.length; i++) {
rest.push(poly[i]);
if (i === ia) {
// walk the cut from a to b along the edge direction
const dir = (poly[(ia + 1) % poly.length][0] - poly[ia][0]) * (b[0] - a[0])
+ (poly[(ia + 1) % poly.length][1] - poly[ia][1]) * (b[1] - a[1]);
const path = dir >= 0 ? pts : [...pts].reverse();
for (const p of path) rest.push(p);
}
}
p1 = dropRepeats(rest, eps);
p2 = niche;
} else {
p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
}
if (p1.length < 3 || p2.length < 3) return null; if (p1.length < 3 || p2.length < 3) return null;
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null; if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
// INVARIANT (audit G1): a split partitions the room — the parts must sum to
// the original. Anything else means the walk produced overlapping garbage.
if (Math.abs(polygonArea(p1) + polygonArea(p2) - polygonArea(poly)) > Math.max(eps, polygonArea(poly) * 1e-6))
return null;
return [p1, p2]; return [p1, p2];
} }
@@ -970,6 +1000,22 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
return cutSegments(edges, cuts, eps); return cutSegments(edges, cuts, eps);
} }
/**
* Legacy static URLs (/houseplan_files/plans|files/...) are rewritten to the
* authenticated content endpoint (audit B1). Applied on READ, so stored
* configs keep working without a migration.
*/
export function contentUrl(url: string | null | undefined): string {
if (!url) return '';
if (url.startsWith('/houseplan_files/plans/')) {
return '/api/houseplan/content/plans/_/' + url.slice('/houseplan_files/plans/'.length);
}
if (url.startsWith('/houseplan_files/files/')) {
return '/api/houseplan/content/files/' + url.slice('/houseplan_files/files/'.length);
}
return url;
}
// ---------------- room-level settings (tier 3) ---------------- // ---------------- room-level settings (tier 3) ----------------
/** /**
+2 -2
View File
@@ -3,7 +3,7 @@
* are directly unit-tested. Shared by the static renderer (space-render.ts) and * are directly unit-tested. Shared by the static renderer (space-render.ts) and
* mirror the full card's private math. * mirror the full card's private math.
*/ */
import { declump } from './logic'; import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types'; import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs export const NORM_W = 1000; // width of the render space for normalized configs
@@ -30,7 +30,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: s.id, id: s.id,
title: s.title, title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H], vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null, bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
rooms: (s.rooms || []).map(scale), rooms: (s.rooms || []).map(scale),
} as SpaceModel; } as SpaceModel;
}); });
+35
View File
@@ -12,6 +12,7 @@ import {
swipeTarget, clampScale, swipeTarget, clampScale,
migratePdfUrls, migratePdfUrls,
roomFillModeOf, roomFillModeOf,
contentUrl,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap, segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js'; } from '../test-build/logic.js';
@@ -809,3 +810,37 @@ test('roomFillModeOf: tier-3 override beats the space, junk inherits', () => {
assert.equal(roomFillModeOf('temp', null), 'temp'); assert.equal(roomFillModeOf('temp', null), 'temp');
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
}); });
test('splitRoomPath: both ends on the SAME edge carve a niche (audit G1)', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
const A = (p) => Math.round(polygonArea(p) * 1000) / 1000;
// ИНВАРИАНТ разреза: части в сумме дают исходную площадь
const invariant = (pts, label) => {
const r = splitRoomPath(sq, pts);
assert.ok(r, label + ': разрез должен приниматься');
assert.ok(Math.abs(A(r[0]) + A(r[1]) - A(sq)) < 1e-6,
label + ': сумма частей ' + (A(r[0]) + A(r[1])) + ' != ' + A(sq));
return r;
};
const niche = invariant([[2, 0], [2, 3], [8, 3], [8, 0]], 'ниша снизу');
assert.deepEqual([A(niche[0]), A(niche[1])].sort((x, y) => x - y), [18, 82]);
invariant([[2, 10], [2, 7], [8, 7], [8, 10]], 'ниша сверху');
invariant([[0, 2], [3, 2], [3, 8], [0, 8]], 'ниша слева');
invariant([[8, 0], [8, 3], [2, 3], [2, 0]], 'обратный порядок точек');
invariant([[1, 0], [3, 4], [5, 1], [7, 4], [9, 0]], 'зигзаг');
// вырожденная ниша нулевой площади — отказ
assert.equal(splitRoomPath(sq, [[2, 0], [2, 1e-9], [8, 0]]), null);
// старые формы разрезов сохраняют инвариант
invariant([[5, 0], [5, 10]], 'прямая хорда');
invariant([[4, 0], [4, 6], [10, 6]], 'Г-образный');
});
test('contentUrl: legacy static paths become authenticated ones (audit B1)', () => {
assert.equal(contentUrl('/houseplan_files/plans/f1.svg?v=1'), '/api/houseplan/content/plans/_/f1.svg?v=1');
assert.equal(contentUrl('/houseplan_files/files/dev1/a.pdf?v=2'), '/api/houseplan/content/files/dev1/a.pdf?v=2');
// новые и внешние адреса не трогаем
assert.equal(contentUrl('/api/houseplan/content/files/x/y.pdf'), '/api/houseplan/content/files/x/y.pdf');
assert.equal(contentUrl('https://example.com/a.pdf'), 'https://example.com/a.pdf');
assert.equal(contentUrl(''), '');
assert.equal(contentUrl(null), '');
});