mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
perf/fix v1.43.1: external audit P1 — render cost, drag threshold, geometry, backend
L1: memoized space model + open pairs (structural fingerprint key, epoch bumped synchronously at mutation time, not inside the debounce); hoisted per-room geometry out of the render loop; smoke asserts zero recomputation across state pushes. L4: openings get the 3 px drag threshold used by every other pipeline and only write when the geometry actually changed — taps open the dialog again. G2: interiorPoint() replaces the vertex mean, so island rooms inside concave (U/L) parents are accepted and their evenodd holes render; traced duplicates still are not containment. G3: segKey rounds before ordering — one shared wall, one key. B2: _check_write fails closed when the entry is unavailable. B3: layout/set honours expected_rev and returns the new rev. B4: config/set without expected_rev over a non-empty store logs a warning. B5: coordinates reject NaN/Infinity; spaces/rooms/markers/decor/layout capped. +2 unit tests (118), +2 backend tests (14), smoke_render_perf; docs same-commit
This commit is contained in:
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.43.0"
|
||||
VERSION = "1.43.1"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.43.0"
|
||||
"version": "1.43.1"
|
||||
}
|
||||
|
||||
@@ -47,11 +47,31 @@ def valid_space_id(value: str) -> bool:
|
||||
|
||||
|
||||
# ---------- voluptuous schemas ----------
|
||||
def _finite(value):
|
||||
"""Coerce to float and reject NaN/Infinity (audit B5).
|
||||
|
||||
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
|
||||
silently corrupting a stored position forever.
|
||||
"""
|
||||
f = float(value)
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
raise vol.Invalid("coordinate must be a finite number")
|
||||
return f
|
||||
|
||||
|
||||
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||
MAX_SPACES = 50
|
||||
MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_LAYOUT = 5000
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
|
||||
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
|
||||
@@ -142,8 +162,8 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("decor"): [DECOR_SCHEMA],
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): [
|
||||
vol.Schema(
|
||||
{
|
||||
@@ -199,8 +219,8 @@ MARKER_SCHEMA = vol.Schema(
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from pathlib import Path
|
||||
@@ -22,6 +24,9 @@ from .validation import (
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
@@ -49,8 +54,17 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
||||
|
||||
|
||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
"""May this connection write?
|
||||
|
||||
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
|
||||
while the integration is disabled — the policy is unknown, and "unknown" is
|
||||
not the same as "permissive". Previously this returned True and ws_plan_set,
|
||||
which never touches the runtime helper, accepted uploads in that window.
|
||||
"""
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if entry is None:
|
||||
return bool(getattr(connection.user, "is_admin", False))
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return connection.user.is_admin if admin_only else True
|
||||
|
||||
|
||||
@@ -69,11 +83,21 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "houseplan/layout/set", vol.Required("layout"): LAYOUT_SCHEMA}
|
||||
{
|
||||
vol.Required("type"): "houseplan/layout/set",
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Replace the layout entirely."""
|
||||
"""Replace the layout entirely, with optimistic locking (audit B3).
|
||||
|
||||
Wholesale layout writes used to have no revision check at all, so two
|
||||
clients silently overwrote each other. `expected_rev` is optional for
|
||||
backwards compatibility with older cards, but when supplied it is enforced
|
||||
exactly like the config store does.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
||||
return
|
||||
@@ -81,8 +105,15 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
await rt.store.async_save({"layout": msg["layout"]})
|
||||
connection.send_result(msg["id"], {"ok": True})
|
||||
data = await rt.store.async_load() or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||
)
|
||||
return
|
||||
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -227,6 +258,15 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
current_rev = data.get("rev", 0)
|
||||
if "expected_rev" not in msg and current_rev:
|
||||
# audit B4: expected_rev stays optional for old cards mid-upgrade,
|
||||
# but a blind overwrite of a non-empty store is worth a warning —
|
||||
# it is exactly how a stale client silently discards someone's work.
|
||||
_LOGGER.warning(
|
||||
"House Plan: config/set without expected_rev over rev %s — "
|
||||
"the client bypasses conflict detection (outdated card?)",
|
||||
current_rev,
|
||||
)
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
|
||||
@@ -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;
|
||||
// счётчики вызовов тяжёлой геометрии
|
||||
let pairsCalls = 0, buildCalls = 0;
|
||||
const origPairs = c._computeOpenPairs.bind(c);
|
||||
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
|
||||
const origBuild = c._buildModel.bind(c);
|
||||
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
|
||||
// открыть границу, чтобы pairs было что считать
|
||||
const sp = c._curSpaceCfg;
|
||||
const r1 = sp.rooms[0], r2 = sp.rooms[1];
|
||||
r1.open_to = [r2.id]; r2.open_to = [r1.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
pairsCalls = 0; buildCalls = 0;
|
||||
// 10 «пушей состояния» от HA без изменения конфига
|
||||
for (let i = 0; i < 10; i++) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } };
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
|
||||
out.modelBuildsPer10Renders = buildCalls;
|
||||
// правка конфига обязана инвалидировать кэш
|
||||
const before = pairsCalls;
|
||||
const r3 = sp.rooms[2] || sp.rooms[0];
|
||||
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
out.invalidatesOnEdit = pairsCalls > before;
|
||||
// геометрия по-прежнему правильная: пунктир на месте
|
||||
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
|
||||
return out;
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 1));
|
||||
await browser.close();
|
||||
@@ -21,7 +21,7 @@ const res = await page.evaluate(async () => {
|
||||
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();
|
||||
out.pending = c._saveConfigDebounced.pending();
|
||||
// через 100 мс приходит событие о чужой ревизии — раньше это стирало правку
|
||||
c._cfgRev = server.rev; // симулируем: наша ревизия отстала
|
||||
await c._reloadConfigOnly();
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+38
-38
File diff suppressed because one or more lines are too long
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## v1.43.1 — 2026-07-27 (external audit: P1 fixes)
|
||||
|
||||
- **Render cost (L1).** Home Assistant replaces `hass` on every state change in
|
||||
the home, and each of those renders recomputed the whole plan geometry —
|
||||
`_openPairs()` ran once per room (O(rooms³) collinear-overlap math) and the
|
||||
space model was rebuilt twice. Both are now memoized on the config's
|
||||
structural fingerprint and hoisted out of the per-room loop; cache
|
||||
invalidation happens synchronously at mutation time, not inside the debounce.
|
||||
- **Opening tap vs drag (L4).** Dragging a door/window had no movement
|
||||
threshold, so any pointer jitter counted as a drag: the properties dialog
|
||||
never opened and an unchanged config was written (which then fed the L2 race).
|
||||
Now the same 3 px threshold as every other drag pipeline, and the write only
|
||||
happens when the geometry actually changed.
|
||||
- **Concave rooms (G2).** Containment used the arithmetic mean of the vertices
|
||||
as an "interior point" — which lies OUTSIDE U- and L-shaped rooms, so island
|
||||
rooms in them were rejected as overlaps and their holes never rendered. A
|
||||
real interior point is computed instead (`interiorPoint`).
|
||||
- **Wall dedup (G3).** `segKey` ordered endpoints by raw floats but printed
|
||||
rounded ones, so one shared wall could produce two keys and be drawn twice.
|
||||
Rounds first, then orders.
|
||||
- **Backend hardening (B2–B5).** The write-authorization check now fails
|
||||
**closed** when the config entry is unavailable (it used to allow writes
|
||||
during a reload); `layout/set` supports `expected_rev` and conflicts like the
|
||||
config store; a `config/set` without `expected_rev` over a non-empty store
|
||||
logs a warning; coordinates reject NaN/Infinity, and spaces/rooms/markers/
|
||||
decor/layout have generous size caps.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -140,6 +140,20 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
(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
|
||||
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
|
||||
computed once per config change, not per HA state push — smoke asserts
|
||||
zero recomputations across 10 state pushes and recomputation after an
|
||||
edit; the plan still renders dashes/islands correctly [auto]
|
||||
- [ ] Opening tap vs drag (v1.43.1, audit L4): a tap on a door in the Plan
|
||||
editor opens its properties (3 px threshold like the other pipelines) and
|
||||
writes nothing; a real drag that ends where it started also writes nothing [auto]
|
||||
- [ ] Concave containment (v1.43.1, audit G2): an island room inside a U- or
|
||||
L-shaped parent is accepted and punches the evenodd hole; a traced
|
||||
duplicate outline is still NOT containment [auto]
|
||||
- [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed
|
||||
when the entry is unavailable; layout/set honours expected_rev; a
|
||||
config/set without expected_rev over a non-empty store logs a warning;
|
||||
NaN/Infinity coordinates and oversized collections are rejected [auto]
|
||||
- [ ] 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]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.43.0",
|
||||
"version": "1.43.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+97
-18
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.43.0';
|
||||
const CARD_VERSION = '1.43.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';
|
||||
@@ -177,7 +177,7 @@ class HouseplanCard extends LitElement {
|
||||
x: number; y: number; angle: number; // render units (from the wall snap)
|
||||
} | null = null;
|
||||
private _openingInfo: OpeningCfg | null = null;
|
||||
private _opDrag: { id: string; moved: boolean } | null = null;
|
||||
private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean } | null = null;
|
||||
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
||||
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
|
||||
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
||||
@@ -360,7 +360,7 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
clearTimeout(this._reloadRetry);
|
||||
this._saveConfig.flush(); // never leave an edit unsent on teardown
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
this._roViewport?.disconnect();
|
||||
@@ -480,6 +480,7 @@ class HouseplanCard extends LitElement {
|
||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
||||
if (c && c.config && Array.isArray(c.config.spaces)) {
|
||||
this._serverCfg = c.config;
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = c.rev || 0;
|
||||
this._layout = c.layout || {};
|
||||
this._serverStorage = true;
|
||||
@@ -520,7 +521,36 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
||||
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */
|
||||
private _cfgEpoch = 0;
|
||||
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||
|
||||
/** Cheap structural fingerprint of the config (audit L1 cache key). */
|
||||
private _cfgFingerprint(): string {
|
||||
const sp = this._serverCfg?.spaces || [];
|
||||
let s = sp.length + ':';
|
||||
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 || [])
|
||||
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
||||
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + ';';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private get _model(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
// same reasoning as _openPairs: mutations in place mean the epoch can lag,
|
||||
// so the key also carries the config's structural fingerprint
|
||||
const key = this._cfgEpoch + '|' + this._cfgFingerprint();
|
||||
if (this._modelCache && this._modelCache.key === key) return this._modelCache.model;
|
||||
const built = this._buildModel();
|
||||
this._modelCache = { key, model: built };
|
||||
return built;
|
||||
}
|
||||
|
||||
private _buildModel(): SpaceModel[] {
|
||||
if (!this._serverCfg) return [];
|
||||
return this._serverCfg.spaces.map((s: any) => {
|
||||
const H = NORM_W / s.aspect;
|
||||
@@ -645,6 +675,7 @@ class HouseplanCard extends LitElement {
|
||||
this._serverStorage = true;
|
||||
const cfg = cfgResp?.config;
|
||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = cfgResp?.rev || 0;
|
||||
this._layout = layResp?.layout || {};
|
||||
// live sync: the config was changed in another window → re-read it
|
||||
@@ -698,7 +729,7 @@ class HouseplanCard extends LitElement {
|
||||
*/
|
||||
private async _reloadConfigOnly(force = false): Promise<void> {
|
||||
if (!force) {
|
||||
if (this._saveConfig.pending()) this._saveConfig.flush();
|
||||
if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush();
|
||||
if (this._cfgWriting) {
|
||||
// retry once the in-flight write settles
|
||||
clearTimeout(this._reloadRetry);
|
||||
@@ -710,6 +741,7 @@ class HouseplanCard extends LitElement {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
||||
const cfg = resp?.config;
|
||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = resp?.rev || 0;
|
||||
this._cacheSnapshot();
|
||||
this._regSignature = '';
|
||||
@@ -1437,7 +1469,18 @@ class HouseplanCard extends LitElement {
|
||||
/** A config write is in flight — the card must not adopt a server revision. */
|
||||
private _cfgWriting = false;
|
||||
|
||||
private _saveConfig = debounce(() => {
|
||||
/**
|
||||
* Every mutation path ends here, so this is the one place that can invalidate
|
||||
* the geometry caches synchronously. The WRITE stays debounced; the epoch bump
|
||||
* must not be (audit L1: a bump inside the debounce arrives 500 ms after the
|
||||
* render that needed it, and the plan renders stale geometry).
|
||||
*/
|
||||
private _saveConfig(): void {
|
||||
this._cfgEpoch++;
|
||||
this._saveConfigDebounced();
|
||||
}
|
||||
|
||||
private _saveConfigDebounced = debounce(() => {
|
||||
if (!this._serverCfg) return;
|
||||
this._dropLegacySegments();
|
||||
this._cfgWriting = true;
|
||||
@@ -1844,7 +1887,25 @@ 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 _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.
|
||||
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;
|
||||
const pairs = this._computeOpenPairs();
|
||||
this._openPairsCache = { key, pairs };
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private _computeOpenPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||
const rooms = this._spaceModel().rooms.filter((r) => r.id);
|
||||
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
|
||||
for (let i = 0; i < rooms.length; i++)
|
||||
@@ -1955,11 +2016,16 @@ class HouseplanCard extends LitElement {
|
||||
} catch {
|
||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||
}
|
||||
this._opDrag = { id: o.id, moved: false };
|
||||
this._opDrag = { id: o.id, moved: false, sx: ev.clientX, sy: ev.clientY, dirty: false };
|
||||
}
|
||||
|
||||
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||
// audit L4: the other drag pipelines require 3 px before calling it a drag.
|
||||
// Without it every tap counted as a drag: the properties dialog never
|
||||
// opened and an unchanged config was written (which then broadcast the
|
||||
// event behind the L2 data loss).
|
||||
if (Math.abs(ev.clientX - this._opDrag.sx) + Math.abs(ev.clientY - this._opDrag.sy) <= 3) return;
|
||||
const raw = this._svgPoint(ev);
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
||||
if (!snap) return; // too far from any wall: the opening stays where it was
|
||||
@@ -1967,8 +2033,11 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._curSpaceCfg;
|
||||
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
||||
if (!cfg) return;
|
||||
cfg.x = snap.x / NORM_W;
|
||||
cfg.y = snap.y / this._spaceH;
|
||||
const nx = snap.x / NORM_W;
|
||||
const ny = snap.y / this._spaceH;
|
||||
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
|
||||
cfg.x = nx;
|
||||
cfg.y = ny;
|
||||
cfg.angle = snap.angle;
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -1976,7 +2045,8 @@ class HouseplanCard extends LitElement {
|
||||
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||
const moved = this._opDrag.moved;
|
||||
if (moved) this._saveConfig();
|
||||
// only write when the geometry actually changed (audit L4)
|
||||
if (moved && this._opDrag.dirty) this._saveConfig();
|
||||
// keep the flag until the click event that follows pointerup, then let it go
|
||||
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
|
||||
else this._opDrag = null;
|
||||
@@ -2867,6 +2937,7 @@ class HouseplanCard extends LitElement {
|
||||
conflict again. */
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
this._dropLegacySegments();
|
||||
this._cfgEpoch++;
|
||||
try {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
@@ -3418,7 +3489,18 @@ class HouseplanCard extends LitElement {
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${this._renderDecorLayer()}
|
||||
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||
${(() => {
|
||||
// audit L1: hoisted out of the per-room map — these depend on the
|
||||
// config, not on entity state, and were recomputed per room.
|
||||
const allPairs = this._openPairs();
|
||||
const polyCache = new Map<any, number[][] | null>();
|
||||
const polyOf = (rr: any) => {
|
||||
if (!polyCache.has(rr)) polyCache.set(rr, roomPoly(rr));
|
||||
return polyCache.get(rr)!;
|
||||
};
|
||||
const otherPolys = (rr: any) =>
|
||||
space.rooms.filter((o) => o !== rr).map(polyOf).filter(Boolean) as number[][][];
|
||||
return space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||
cls += ' picked';
|
||||
@@ -3465,16 +3547,12 @@ class HouseplanCard extends LitElement {
|
||||
// amber highlight — the merge/split selection must stay visible).
|
||||
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
|
||||
const openCuts = r.id && !isPicked
|
||||
? this._openPairs()
|
||||
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
|
||||
.flatMap((pp) => pp.segs)
|
||||
? allPairs.filter((pp) => pp.a.id === r.id || pp.b.id === r.id).flatMap((pp) => pp.segs)
|
||||
: [];
|
||||
if (openCuts.length) cls += ' noedge';
|
||||
// island rooms punch holes in their parent's fill (evenodd)
|
||||
const myPoly = roomPoly(r);
|
||||
const holes = myPoly
|
||||
? islandsOf(myPoly, space.rooms.filter((o) => o !== r).map((o) => roomPoly(o)!).filter(Boolean))
|
||||
: [];
|
||||
const myPoly = polyOf(r);
|
||||
const holes = myPoly ? islandsOf(myPoly, otherPolys(r)) : [];
|
||||
const pathD = (pts: number[][]) =>
|
||||
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
|
||||
const shape = holes.length && myPoly
|
||||
@@ -3499,7 +3577,8 @@ class HouseplanCard extends LitElement {
|
||||
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
|
||||
: nothing;
|
||||
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||
${this._renderOpenWalls(disp)}
|
||||
${this._editing ? this._renderAlignGuides() : nothing}
|
||||
|
||||
+44
-13
@@ -38,8 +38,14 @@ export function formatLength(cm: number, imperial: boolean): string {
|
||||
* normalized (0..1) coordinates need more (see roomEdges).
|
||||
*/
|
||||
export function segKey(a: number[], b: number[], prec = 1): string {
|
||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
||||
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
||||
// audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
|
||||
// rounded ones let one wall produce two different keys, so shared walls were
|
||||
// emitted twice and the dedup invariant quietly broke.
|
||||
const ax = a[0].toFixed(prec), ay = a[1].toFixed(prec);
|
||||
const bx = b[0].toFixed(prec), by = b[1].toFixed(prec);
|
||||
const first = ax < bx || (ax === bx && ay <= by);
|
||||
const [px, py, qx, qy] = first ? [ax, ay, bx, by] : [bx, by, ax, ay];
|
||||
return `${px},${py}-${qx},${qy}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +230,36 @@ export function segmentsProperlyCross(
|
||||
}
|
||||
|
||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||
/**
|
||||
* A point guaranteed to lie strictly inside the polygon (audit G2).
|
||||
* The arithmetic mean of the vertices lies OUTSIDE concave shapes (U/L rooms
|
||||
* are common in hand-drawn plans), which made containment tests misfire.
|
||||
* Strategy: try the midpoints of the diagonals from each vertex, then a
|
||||
* triangle centroid of consecutive vertices — the first point that passes
|
||||
* pointStrictlyInside wins.
|
||||
*/
|
||||
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||
if (!poly || poly.length < 3) return null;
|
||||
const n = poly.length;
|
||||
const mean = [
|
||||
poly.reduce((s, p) => s + p[0], 0) / n,
|
||||
poly.reduce((s, p) => s + p[1], 0) / n,
|
||||
];
|
||||
if (pointStrictlyInside(mean, poly, eps)) return mean;
|
||||
for (let i = 0; i < n; i++) {
|
||||
// centroid of the ear at vertex i
|
||||
const a = poly[(i - 1 + n) % n], b = poly[i], c = poly[(i + 1) % n];
|
||||
const cand = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3];
|
||||
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||
}
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = i + 2; j < n; j++) {
|
||||
const cand = [(poly[i][0] + poly[j][0]) / 2, (poly[i][1] + poly[j][1]) / 2];
|
||||
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
let allOnBoundary = true;
|
||||
for (const v of a) {
|
||||
@@ -232,11 +268,8 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||
}
|
||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||
if (allOnBoundary) {
|
||||
const c = [
|
||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
||||
];
|
||||
return pointStrictlyInside(c, b, eps);
|
||||
const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
|
||||
return !!c && pointStrictlyInside(c, b, eps);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -250,12 +283,10 @@ export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-
|
||||
return false;
|
||||
for (const v of inner)
|
||||
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
||||
// identical/traced outlines are NOT containment — probe the centroid strictness both ways
|
||||
const c = [
|
||||
inner.reduce((s, p) => s + p[0], 0) / inner.length,
|
||||
inner.reduce((s, p) => s + p[1], 0) / inner.length,
|
||||
];
|
||||
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||
// identical/traced outlines are NOT containment — probe a real interior point
|
||||
// (audit G2: the vertex mean lies outside concave rooms)
|
||||
const c = interiorPoint(inner, eps);
|
||||
return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
migratePdfUrls,
|
||||
roomFillModeOf,
|
||||
contentUrl,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||
} from '../test-build/logic.js';
|
||||
@@ -844,3 +845,25 @@ test('contentUrl: legacy static paths become authenticated ones (audit B1)', ()
|
||||
assert.equal(contentUrl(''), '');
|
||||
assert.equal(contentUrl(null), '');
|
||||
});
|
||||
|
||||
test('interiorPoint / polyContainsPoly on concave rooms (audit G2)', () => {
|
||||
// U-образная комната: среднее вершин лежит СНАРУЖИ
|
||||
const u = [[0, 0], [10, 0], [10, 10], [9, 10], [9, 2], [1, 2], [1, 10], [0, 10]];
|
||||
const mean = [u.reduce((s, p) => s + p[0], 0) / u.length, u.reduce((s, p) => s + p[1], 0) / u.length];
|
||||
assert.equal(pointStrictlyInside(mean, u), false, 'предпосылка: среднее снаружи');
|
||||
const ip = interiorPoint(u);
|
||||
assert.ok(ip && pointStrictlyInside(ip, u), 'interiorPoint обязана быть внутри');
|
||||
// вложенность вогнутых теперь распознаётся
|
||||
const outer = [[-1, -1], [11, -1], [11, 11], [8.5, 11], [8.5, 2.5], [1.5, 2.5], [1.5, 11], [-1, 11]];
|
||||
assert.equal(polyContainsPoly(outer, u), true);
|
||||
assert.equal(roomsOverlap(outer, u), false);
|
||||
// дубликат по-прежнему НЕ вложенность
|
||||
assert.equal(polyContainsPoly(u, u), false);
|
||||
});
|
||||
|
||||
test('segKey: one wall, one key at any precision (audit G3)', () => {
|
||||
assert.equal(segKey([1.000001, 1], [1.000002, 2]), segKey([1.000002, 1], [1.000001, 2]));
|
||||
assert.equal(segKey([0, 0], [1, 1], 3), segKey([1, 1], [0, 0], 3));
|
||||
// разные стены — разные ключи
|
||||
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
|
||||
});
|
||||
|
||||
@@ -125,3 +125,18 @@ def test_space_temp_bounds():
|
||||
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
||||
}
|
||||
v.SPACE_SCHEMA(ok)
|
||||
|
||||
|
||||
def test_finite_coordinates_rejected():
|
||||
"""audit B5: NaN/Infinity coordinates must not reach storage."""
|
||||
for bad in ("NaN", "Infinity", "-Infinity", float("nan"), float("inf")):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA({"dev1": {"x": bad, "y": 0.5}})
|
||||
assert v.LAYOUT_SCHEMA({"dev1": {"x": 0.5, "y": 0.25}})
|
||||
|
||||
|
||||
def test_collection_caps():
|
||||
"""audit B5: unbounded collections are capped."""
|
||||
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA(big)
|
||||
|
||||
Reference in New Issue
Block a user