Merge dev: v1.43.0..v1.43.2 (external audit: P0, P1 and the test layer)

This commit is contained in:
Matysh
2026-07-27 12:22:31 +03:00
71 changed files with 1341 additions and 428 deletions
+38
View File
@@ -34,6 +34,44 @@ jobs:
run: npm run build run: npm run build
- name: Card bundle in sync with integration - name: Card bundle in sync with integration
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
smoke:
# audit T2: the end-to-end layer used to run only when a human remembered.
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a FRESH bundle for the smokes
# the committed demo/srv/assets copy is a snapshot; testing it would
# report green about code that no longer exists (audit T2)
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
fail=0
mkdir -p /tmp/smoke-logs
for f in demo/smoke_*.mjs; do
name=$(basename "$f" .mjs)
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
echo "ok $name"
else
echo "FAIL $name"
tail -20 "/tmp/smoke-logs/$name.log"
fail=1
fi
done
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: smoke-logs
path: /tmp/smoke-logs
backend: backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+6 -5
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.
# Only the card bundle stays public — Lovelace resources must be.
if static_paths:
await hass.http.async_register_static_paths(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.2"
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.2"
} }
+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
+26 -6
View File
@@ -47,11 +47,31 @@ def valid_space_id(value: str) -> bool:
# ---------- voluptuous schemas ---------- # ---------- 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( 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) 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)) 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.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)), 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("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
vol.Required("rooms"): [ROOM_SCHEMA], vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): [DECOR_SCHEMA], vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
vol.Optional("openings"): [ vol.Optional("openings"): [
vol.Schema( vol.Schema(
{ {
@@ -199,8 +219,8 @@ MARKER_SCHEMA = vol.Schema(
) )
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
vol.Required("spaces"): [SPACE_SCHEMA], vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): [MARKER_SCHEMA], vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema( vol.Optional("settings", default=dict): vol.Schema(
{ {
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
+47 -7
View File
@@ -1,6 +1,8 @@
"""House Plan WS commands: layout, space configuration, plan uploads.""" """House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations from __future__ import annotations
import logging
import base64 import base64
import binascii import binascii
from pathlib import Path from pathlib import Path
@@ -13,7 +15,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 (
@@ -22,6 +24,9 @@ from .validation import (
) )
_LOGGER = logging.getLogger(__name__)
@callback @callback
def async_register(hass: HomeAssistant) -> None: def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands.""" """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: 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) 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 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( @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 @websocket_api.async_response
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: 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): if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout") connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return return
@@ -81,8 +105,15 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None: if rt is None:
return return
async with rt.write_lock: async with rt.write_lock:
await rt.store.async_save({"layout": msg["layout"]}) data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {"ok": True}) 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( @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: async with rt.write_lock:
data = await rt.config_store.async_load() or {} data = await rt.config_store.async_load() or {}
current_rev = data.get("rev", 0) 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: if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error( connection.send_error(
msg["id"], "conflict", msg["id"], "conflict",
@@ -290,5 +330,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}"}
) )
+36 -1
View File
@@ -6,10 +6,45 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv'; const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' }; const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
// ---- assertion harness (audit T1) --------------------------------------
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
// build reported success. `check()` accumulates named failures, `finish()`
// prints them and sets the exit code.
const _failures = [];
let _pageErrors = 0;
/** Assert one named fact. `expected` defaults to true. */
export function check(name, actual, expected = true) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
return ok;
}
/** Assert a whole result object: every key must equal true unless listed. */
export function checkAll(out, expected = {}) {
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
return out;
}
/** Print the result, report failures, close the browser, set the exit code. */
export async function finish(browser, out) {
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
await browser?.close?.();
if (_failures.length) {
console.error('\nFAILED (' + _failures.length + '):');
for (const f of _failures) console.error(' - ' + f);
process.exitCode = 1;
} else {
console.log('OK');
}
}
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) { export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
const browser = await chromium.launch({ args: ['--no-sandbox'] }); const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage(); const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
page.on('pageerror', (e) => console.log('EXC', e.message)); // audit T1: an exception inside the card used to be logged and ignored
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
await page.route('**/*', (r) => { await page.route('**/*', (r) => {
const u = new URL(r.request().url()); const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname); let p = decodeURIComponent(u.pathname);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -52,5 +52,5 @@ const res = await page.evaluate(async () => {
out.noneInView = guides() === 0; out.noneInView = guides() === 0;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -46,5 +46,5 @@ const res = await page.evaluate(async () => {
c._markerDialog = null; c._markerDialog = null;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -47,5 +47,5 @@ const res = await page.evaluate(async () => {
out.delroomIgnored = !confirmCalled; out.delroomIgnored = !confirmCalled;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -57,5 +57,5 @@ const res = await page.evaluate(async () => {
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]); out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -70,5 +70,5 @@ const res = await page.evaluate(async () => {
out.deleteKey = c._decorList.length === n1 - 1; out.deleteKey = c._decorList.length === n1 - 1;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+55
View File
@@ -0,0 +1,55 @@
import { launch, checkAll, finish } 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;
});
checkAll(res);
await finish(browser, res);
+16 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -70,5 +70,18 @@ const res = await page.evaluate(async () => {
out.bigRenderMs = Math.round(performance.now() - t1); out.bigRenderMs = Math.round(performance.now() - t1);
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); // сборка 100+ устройств должна укладываться в бюджет (docs/TESTING.md)
// timings are asserted as budgets, not frozen values (CI machines vary)
check("bigBuildMs under 200ms", res.bigBuildMs < 200);
check("bigRenderMs under 100ms", res.bigRenderMs < 100);
delete res.bigBuildMs;
delete res.bigRenderMs;
checkAll(res, {
"emptyDevices": 0,
"emptyCount": "0 dev.",
"xssPwned": false,
"xssDeviceRendered": 1,
"bigCount": 162,
});
await finish(browser, res);
+6 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -35,5 +35,8 @@ const res = await page.evaluate(async () => {
out.tabCrossWorks = c._mode === 'view'; out.tabCrossWorks = c._mode === 'view';
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"labels": ["Plan editor", "Device editor", "Background editor"],
});
await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -31,5 +31,5 @@ const res = await page.evaluate(async () => {
await esc(); out.undoPointStillWorks = c._path.length === 1; await esc(); out.undoPointStillWorks = c._path.length === 1;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -54,5 +54,5 @@ const res = await page.evaluate(async () => {
c._spaceDialog = null; await c.updateComplete; c._spaceDialog = null; await c.updateComplete;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+6 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -31,5 +31,8 @@ const res = await page.evaluate(async () => {
out.addInPlan = !!sr().querySelector('.tab.tabadd'); out.addInPlan = !!sr().querySelector('.tab.tabadd');
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"alignDelta": 0,
});
await finish(browser, res);
+10 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -26,5 +26,12 @@ const res = await page.evaluate(async () => {
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length; out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"rows": 11,
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
});
await finish(browser, res);
+6 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -101,5 +101,8 @@ const res = await page.evaluate(async () => {
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01; out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"spots": 1,
});
await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -33,5 +33,5 @@ const res = await page.evaluate(async () => {
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null; out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -14,5 +14,5 @@ const res = await page.evaluate(async () => {
out.opensInView = !!c._settingsDialog; out.opensInView = !!c._settingsDialog;
return out; return out;
}); });
console.log(JSON.stringify(res)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -27,5 +27,5 @@ const res = await page.evaluate(async () => {
c._markerDialog = null; await c.updateComplete; c._markerDialog = null; await c.updateComplete;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+8 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -43,5 +43,10 @@ const res = await page.evaluate(async () => {
c._openingDialog = null; c._setMode('view'); c._openingDialog = null; c._setMode('view');
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"lockBadgeInert": "no-badge",
"viewDevCursor": "pointer",
"devModeCursor": "grab",
});
await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -55,5 +55,5 @@ const res = await page.evaluate(async () => {
out.islandRendered = !!islandEl; out.islandRendered = !!islandEl;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -69,5 +69,5 @@ const res = await page.evaluate(async () => {
c.remove(); c.remove();
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -30,5 +30,5 @@ const res = await page.evaluate(async () => {
out.explicitInfoWins = calls.length === n2 && !!c._infoCard; out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -47,5 +47,5 @@ const res = await page.evaluate(async () => {
} }
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -42,5 +42,5 @@ const res = await page.evaluate(async () => {
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1; out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -20,5 +20,5 @@ const res = await page.evaluate(async () => {
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77'); out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
return out; return out;
}); });
console.log(JSON.stringify(res)); checkAll(res);
await browser.close(); await finish(browser, res);
+7 -3
View File
@@ -1,5 +1,5 @@
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords). // Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg)); const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => { const restore = () => page.evaluate((s) => {
@@ -79,5 +79,9 @@ await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
s = await S(); s = await S();
out.alongWallRefused = !s.roomDlg && !s.pendingSplit; out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
console.log(JSON.stringify(out,null,1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(out, {
"mergeRooms": 3,
"newRoom": "Cabinet",
});
await finish(browser, out);
+10 -3
View File
@@ -1,5 +1,5 @@
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools. // UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const out = {}; const out = {};
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel); const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
@@ -75,5 +75,12 @@ out.devClickOpensEditor = await page.evaluate(async () => {
// 5) назад в view // 5) назад в view
await page.evaluate(() => window.__card._setMode('view')); await page.evaluate(() => window.__card._setMode('view'));
out.backToView = (await st()).mode; out.backToView = (await st()).mode;
console.log(JSON.stringify(out, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(out, {
"start": {"mode": "view", "modeTabs": 3, "editBtns": 1, "gears": 2, "markupBar": false, "stageClass": "stage mode-view"},
"viewDragMoved": false,
"plan": {"mode": "plan", "modeTabs": 3, "active": "Plan editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage markup tool-draw mode-plan"},
"devices": {"mode": "devices", "modeTabs": 3, "active": "Device editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage mode-devices"},
"backToView": "view",
});
await finish(browser, out);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -31,5 +31,5 @@ const res = await page.evaluate(async () => {
c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete; c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -28,5 +28,5 @@ const res = await page.evaluate(async () => {
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new'); out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -39,5 +39,5 @@ const res = await page.evaluate(async () => {
out.noGhostOverExisting = !sr().querySelector('.opghost'); out.noGhostOverExisting = !sr().querySelector('.opghost');
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -70,5 +70,5 @@ const res = await page.evaluate(async () => {
} else out.transitive = 'no r3'; } else out.transitive = 'no r3';
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -32,5 +32,5 @@ const res = await page.evaluate(async () => {
c._openWallClick([550, 0.25 * H]); await c.updateComplete; c._openWallClick([550, 0.25 * H]); await c.updateComplete;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+40
View File
@@ -0,0 +1,40 @@
import { launch, checkAll, finish } 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;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"pairsPerRender": 0,
"modelBuildsPer10Renders": 0,
});
await finish(browser, res);
+6 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -28,5 +28,8 @@ const res = await page.evaluate(async () => {
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0; out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"alarmCount": 1,
});
await finish(browser, res);
+9 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -57,5 +57,11 @@ const res = await page.evaluate(async () => {
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9; out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"labels": 4,
"cardsWithMetrics": 4,
"sampleMetrics": ["22.4°", "175", "1 of 2"],
"partialText": "1 of 2",
});
await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -34,5 +34,5 @@ const res = await page.evaluate(async () => {
c._setMode('view'); await c.updateComplete; c._setMode('view'); await c.updateComplete;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -58,5 +58,5 @@ const res = await page.evaluate(async () => {
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length; out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); checkAll(res);
await browser.close(); await finish(browser, res);
+36
View File
@@ -0,0 +1,36 @@
import { launch, checkAll, finish } 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._saveConfigDebounced.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;
});
checkAll(res);
await finish(browser, res);
+15 -4
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -27,7 +27,8 @@ const res = await page.evaluate(async () => {
})}; })};
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length; out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
// 4) drag лейбла → layout rl_ // 4) drag лейбла → layout rl_ (только в редакторе плана, с v1.25)
c._setMode('plan'); await c.updateComplete;
const lbl = sr().querySelector('.roomlabel'); const lbl = sr().querySelector('.roomlabel');
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 }, c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
c._spaceModel().rooms[0], 'f1'); c._spaceModel().rooms[0], 'f1');
@@ -46,5 +47,15 @@ const res = await page.evaluate(async () => {
out.atticNoPlan = attic ? attic.plan_url === null : null; out.atticNoPlan = attic ? attic.plan_url === null : null;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"defaultStyled": 0,
"defaultLabels": 0,
"styled": 4,
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"lqiFills": 0,
"atticAspect": 1,
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+3 -3
View File
@@ -1,5 +1,5 @@
// Split now works on a non-grid-aligned room (imported/legacy polygons). // Split now works on a non-grid-aligned room (imported/legacy polygons).
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const R = (nx, ny) => page.evaluate(([nx, ny]) => { const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H]; const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
@@ -17,5 +17,5 @@ await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingS
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null); out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
console.log(JSON.stringify(out)); checkAll(out);
await browser.close(); await finish(browser, out);
+6 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -51,5 +51,8 @@ const res = await page.evaluate(async () => {
await esc(); out.escExitsMerge = c._tool === 'draw'; await esc(); out.escExitsMerge = c._tool === 'draw';
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"partsPolys": [6, 4],
});
await finish(browser, res);
+12 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -30,5 +30,14 @@ const res = await page.evaluate(async () => {
out.noSmallBadge = !vd[0]?.querySelector('.tval'); out.noSmallBadge = !vd[0]?.querySelector('.tval');
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"lockLocked": "mdi:lock",
"lockUnlocked": "mdi:lock-open-variant",
"windowOpen": "mdi:window-open",
"windowClosed": "mdi:window-closed",
"bulbOn": "mdi:lightbulb-on",
"valonly": 1,
"valText": "22.4°",
});
await finish(browser, res);
+7 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -31,5 +31,9 @@ const res = await page.evaluate(async () => {
c._markerDialog = null; c._markerDialog = null;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"markerRoomId": "rc",
"reopenRoom": "f1#@rc",
});
await finish(browser, res);
+13 -6
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -6,7 +6,9 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot; const sr = () => c.shadowRoot || c.renderRoot;
// 1) в селекте действий 3 опции, дефолт «Карточка устройства» // 1) в селекте действий 3 опции, дефолт «Карточка устройства»
c._setMode('devices'); await c.updateComplete; c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual && d.primary); // v1.39.0: у ЛАМП дефолт 'toggle', поэтому для проверки дефолта 'info'
// берём заведомо не-световое устройство
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
c._openMarkerDialog(dev); await c.updateComplete; c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('.dialog select')].find((s) => const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle'))); [...s.options].some((o) => o.textContent === c._t('tap.toggle')));
@@ -41,14 +43,19 @@ const res = await page.evaluate(async () => {
const calls = []; const calls = [];
c.hass = { ...c.hass, callService: (d2, s2, data) => { calls.push([d2, s2, data]); return Promise.resolve(); } }; c.hass = { ...c.hass, callService: (d2, s2, data) => { calls.push([d2, s2, data]); return Promise.resolve(); } };
await c.updateComplete; await c.updateComplete;
const plain = c._devices.find((d) => !d.virtual && d.primary?.startsWith('light.') && !d.tapAction); // card-wide tap_action игнорируется: НЕ-световое устройство остаётся на инфо
const plain = c._devices.find((d) => !d.virtual && d.primary
&& !d.primary.startsWith('light.') && !d.tapAction && !d.marker?.controls?.length);
if (plain) { if (plain) {
c._infoCard = null; c._infoCard = null;
c._clickDevice(new MouseEvent('click'), plain); c._clickDevice(new MouseEvent('click'), plain);
out.cardTapIgnored = calls.length === 0 && !!c._infoCard; out.cardTapIgnored = calls.length === 0 && !!c._infoCard;
c._infoCard = null; c._infoCard = null;
} else out.cardTapIgnored = 'no-plain-light'; } else out.cardTapIgnored = 'no-plain-device';
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"virtInfo": "no-virt",
});
await finish(browser, res);
+11 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -26,5 +26,13 @@ const res = await page.evaluate(async () => {
c._spaceDialog = null; c._spaceDialog = null;
return out; return out;
}); });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"comfy": ["#66d17a", "transparent", "transparent", "transparent"],
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
"dialogTempFields": 3,
"dialogHiddenWhenNone": 1,
});
await finish(browser, res);
+3 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла // эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -19,5 +19,5 @@ const res = await page.evaluate(async () => {
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip; out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
return out; return out;
}); });
console.log(JSON.stringify(res)); checkAll(res);
await browser.close(); await finish(browser, res);
+11 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 640, height: 980 }, 2); const { page, browser } = await launch({ width: 640, height: 980 }, 2);
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const out = {}; const out = {};
@@ -34,5 +34,13 @@ const res = await page.evaluate(async () => {
return out; return out;
}); });
await page.screenshot({ path: '/tmp/ux_dialog.png' }); await page.screenshot({ path: '/tmp/ux_dialog.png' });
console.log(JSON.stringify(res, null, 1)); // значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
await browser.close(); checkAll(res, {
"filledClass": 1,
"unfilled": 3,
"tipTemp": 22.4,
"fillRadios": 5,
"tempInputs": 2,
"dialogWidth": 502,
});
await finish(browser, res);
File diff suppressed because one or more lines are too long
+39 -39
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.
+73
View File
@@ -1,5 +1,78 @@
# Changelog # Changelog
## v1.43.2 — 2026-07-27 (external audit: the test layer)
- **The smoke suite can finally fail (T1).** All 48 headless-browser smokes used
to print booleans and exit 0 — a regression was visible in their own output
and still reported success. `demo/serve.mjs` now exports `check`/`checkAll`/
`finish`: every fact is asserted by name, mismatches and uncaught exceptions
inside the card set a non-zero exit code. Verified by deliberately breaking
the kiosk editor guard: the matching smoke went red.
- **The suite runs in CI (T2)** as a `smoke` job gated on `frontend`, against a
freshly built bundle (the committed `demo/srv/assets` copy is a snapshot and
would have tested stale code), uploading per-file logs on failure.
- **`docs/TESTING.md` reconciled (T3).** `[auto]` now means "a named check
exists that fails when this breaks", and each such line names it; 72 lines
whose automation was aspirational are honestly marked `[manual]`. Two
long-standing contradictions fixed: the "ZERO edit buttons in View" line
(wrong since v1.30.1) and the opening-click line (true again since v1.43.1).
- Three smokes carried expectations that predate v1.39.0/v1.25 and quietly
described old behaviour; they now test the current contract.
## 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 (B2B5).** 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
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
+19
View File
@@ -96,3 +96,22 @@ Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` b
- The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side). - The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side).
- The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch. - The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
- configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit). - configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
## Smoke tests (since 2026-07-27)
Every `demo/smoke_*.mjs` ends with:
```js
checkAll(out); // every key must be true...
checkAll(out, { n: 4 }); // ...unless an expected value is given
await finish(browser, out);
```
`finish` prints the JSON dump (useful on failure), reports named mismatches and
sets a non-zero exit code — including when the card threw during the run. The
suite runs in CI (`smoke` job) against a freshly built bundle; never test the
committed `demo/srv/assets/houseplan-card.js` snapshot.
When adding a checklist line marked `[auto: ...]` in docs/TESTING.md, add the
failing check in the same commit — that is what the marker now promises.
+144 -100
View File
@@ -3,9 +3,23 @@
> **Policy:** this checklist is updated **in the same commit** as any functional > **Policy:** this checklist is updated **in the same commit** as any functional
> change (like CHANGELOG.md). Every release: run at least the smoke column on the > change (like CHANGELOG.md). Every release: run at least the smoke column on the
> synthetic demo (`demo/`), and the full list before major releases. Items marked > synthetic demo (`demo/`), and the full list before major releases. Items marked
> `[auto]` are covered by unit tests or the headless smokes in `demo/` — they still > `[manual]` are covered by unit tests or the headless smokes in `demo/` — they still
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing. > deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
> **What `[manual]` means (since 2026-07-27).** A named check exists that FAILS
> when the behaviour breaks — in `npm test` or in the smoke suite, both of which
> run in CI on every push. Where the check lives is written next to the marker
> (`[auto: smoke_modes]`). Before this date the marker described an intention:
> the smoke suite printed values and always exited 0, so 96 markers guarded
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
> add the failing check in the same commit.
- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named
facts via `check`/`checkAll` and exits non-zero on any mismatch or
uncaught in-card exception; the suite runs in CI against a FRESHLY built
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
## Environments matrix ## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release: Run the *core flows* (marked ★ below) in each environment at least once per minor release:
@@ -23,39 +37,42 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Installation / upgrade / removal ## Installation / upgrade / removal
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup - [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
- [ ] `single_config_entry`: adding a second entry is impossible [auto] - [ ] `single_config_entry`: adding a second entry is impossible [manual]
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing - [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads) - [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up - [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto] - [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [manual]
## Modes (v1.25.0) ★ ## Modes (v1.25.0) ★
- [ ] The card always loads in **View**; edit modes are never restored [auto] - [ ] The card always loads in **View**; edit modes are never restored [manual]
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon, - [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
label or opening does nothing; panning may start on top of an icon [auto] label or opening does nothing; panning may start on top of an icon [manual]
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto] - [ ] View header: space tabs + count + zoom + editor tabs, the general-settings
cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3
for users who may edit); no editor toolbars [auto: smoke_modes]
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden, - [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
labels/openings draggable; orange stage frame [auto] labels/openings draggable; orange stage frame [manual]
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡ - [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
buttons; accent stage frame [auto] buttons; accent stage frame [manual]
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode - [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no - [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
cursor change, no hover outline, no hit target, no click, regardless of cursor change, no hover outline, no hit target, no click, regardless of
bindings [auto] bindings [manual]
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and - [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
clickable in View (pointer cursor, click → door/lock info card); inert in clickable in View (pointer cursor, click → door/lock info card); inert in
Plan so it does not fight editing [auto] Plan so it does not fight editing [manual]
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the - [ ] Device icons in View show a pointer cursor (no grab); grab only in the
Devices mode [auto] Devices mode [manual]
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along - [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
walls, click (any tool) opens its properties [auto] walls, click (any tool) opens its properties — with a 3 px drag threshold
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
## Onboarding ★ ## Onboarding ★
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto] - [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [manual]
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog - [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [auto] - [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [manual]
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast - [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session - [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable - [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
@@ -64,30 +81,30 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG) - [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open - [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto] - [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [manual]; borders+names default ON [manual]
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto] - [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual]
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto] - [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual]
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched - [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto] - [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual]
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [auto] - [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual]
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [auto]; toggling a light from the plan recolors the room - [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto] - [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [manual]; comfort bounds editable inline on the radio row (swapped bounds tolerated [manual], clearing a field cannot zero a bound [manual]); rooms without a temperature reading stay unfilled [manual]
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend - [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey - [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto] - [ ] Room tooltip shows the average room temperature when any thermometer reports [manual]
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads, - [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto] smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [manual]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px) - [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto] - [ ] The scale (cm per cell) input is compact (72 px), not full-width [manual]
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none, - [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto]; temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [manual];
Reset restores defaults; saving defaults stores nothing [auto] Reset restores defaults; saving defaults stores nothing [manual]
- [ ] Custom fill colors apply to the full card AND the static space-card - [ ] Custom fill colors apply to the full card AND the static space-card
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto] - [ ] LQI gradient interpolates between the configured weak/strong colors [manual]
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to - [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
devices and the signal line in room tooltips for that space only [auto] devices and the signal line in room tooltips for that space only [manual]
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto] - [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [manual]
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto] - [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [manual]
- [ ] Room hover highlight still works when custom borders/fills are on - [ ] Room hover highlight still works when custom borders/fills are on
- [ ] Settings persist across reload and other browsers (server-side) - [ ] Settings persist across reload and other browsers (server-side)
@@ -110,54 +127,81 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast - [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms - [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
(imported/legacy polygons), not only on rooms drawn on the current grid [auto] (imported/legacy polygons), not only on rooms drawn on the current grid [manual]
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast — - [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
the wall-snap pull is capped, accidental clicks do not pick a wall [auto] the wall-snap pull is capped, accidental clicks do not pick a wall [manual]
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path - [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog - [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name - [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
- [ ] "No area" room (decorative) requires a name; saves with `area: null` - [ ] "No area" room (decorative) requires a name; saves with `area: null`
- [ ] Cancel in the dialog reopens the contour (last point undone) - [ ] Cancel in the dialog reopens the contour (last point undone)
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [auto] - [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [manual]
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm - [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
- [ ] Device icons hidden during markup; visible again on exit - [ ] Device icons hidden during markup; visible again on exit
## Devices on the plan ★ ## Devices on the plan ★
- [ ] Auto devices appear only in rooms bound to their area [auto] - [ ] Auto devices appear only in rooms bound to their area [manual]
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [auto] - [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [auto] - [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [auto] - [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space - [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
- [ ] ↺ reset restores auto layout after confirm - [ ] ↺ reset restores auto layout after confirm
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color - [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded - [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed, - [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto] lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual]
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit) - [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
as its body, small badges hidden; non-numeric fallback keeps the icon [auto] as its body, small badges hidden; non-numeric fallback keeps the icon [manual]
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple - [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
(explicit ripple color still wins); off/white lights unchanged [auto] (explicit ripple color still wins); off/white lights unchanged [manual]
- [ ] 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 [manual]; 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: smoke_render_perf]
- [ ] 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: smoke_render_perf]
- [ ] 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: smoke_inert_openings]
- [ ] 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: unit: logic.test]
- [ ] 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: unit: tests_backend]
- [ ] 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: smoke_save_race]
- [ ] 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: unit: logic.test + manual]
- [ ] 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: smoke_dialog_zombie]
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space - [ ] Card font scales (v1.42.1): three sliders — space-level base (space
dialog) plus per-room name and metrics sizes (room settings), 50300%, dialog) plus per-room name and metrics sizes (room settings), 50300%,
multiplied together and on top of resize-k and kiosk multipliers; the multiplied together and on top of resize-k and kiosk multipliers; the
live sample card in both dialogs follows the sliders instantly; name and live sample card in both dialogs follows the sliders instantly; name and
metrics scale independently [auto] metrics scale independently [auto: smoke_font_scales]
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan - [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
editor opens Room settings (name, HA area incl. the current one, fill editor opens Room settings (name, HA area incl. the current one, fill
override, temp/hum source); the creation dialog has the same section; override, temp/hum source); the creation dialog has the same section;
fill override repaints only that room (incl. opting OUT of the glow fill override repaints only that room (incl. opting OUT of the glow
darkness); a temp/hum source (device or entity) feeds the room card, darkness); a temp/hum source (device or entity) feeds the room card,
tooltip and temperature fill — works for rooms without an HA area; tooltip and temperature fill — works for rooms without an HA area;
renaming/rebinding an existing room now possible [auto] renaming/rebinding an existing room now possible [auto: smoke_room_settings]
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached - [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
PDFs to another device — the server moves /files/<oldId>/ to the new id PDFs to another device — the server moves /files/<oldId>/ to the new id
and the links keep opening; the old folder disappears (no orphans) [auto] and the links keep opening; the old folder disappears (no orphans) [manual]
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every - [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
editor (admins incl.), full-height stage; swipe left/right switches editor (admins incl.), full-height stage; swipe left/right switches
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
@@ -168,60 +212,60 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Room link icon (v1.40.1): clicking empty room space in View does - [ ] Room link icon (v1.40.1): clicking empty room space in View does
nothing (default cursor); an open-in-new icon after the room name (rooms nothing (default cursor); an open-in-new icon after the room name (rooms
with an HA area, View only) navigates to the area; no icon in editors or with an HA area, View only) navigates to the area; no icon in editors or
on area-less rooms [auto] on area-less rooms [auto: smoke_room_link]
- [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or - [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or
dragging (icons, room cards, decor) dashed accent guides appear from the dragging (icons, room cards, decor) dashed accent guides appear from the
nearest object sharing the X and/or Y (max two, with a dot at the nearest object sharing the X and/or Y (max two, with a dot at the
source); the cursor badge shows length · angle and turns green on 45° source); the cursor badge shows length · angle and turns green on 45°
multiples; indication only — no magnetism; nothing in View mode [auto] multiples; indication only — no magnetism; nothing in View mode [auto: smoke_align_guides]
- [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a - [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
light (bulbs, chandeliers, night lights, light groups) toggles on click light (bulbs, chandeliers, night lights, light groups) toggles on click
out of the box — no per-device setting needed; the device dialog shows out of the box — no per-device setting needed; the device dialog shows
"Toggle" as its effective default; devices where light is a side "Toggle" as its effective default; devices where light is a side
function (kettle: primary = sensor) keep the Device-card default; function (kettle: primary = sensor) keep the Device-card default;
explicit per-device "Device card" wins over the default [auto] explicit per-device "Device card" wins over the default [auto: smoke_light_default_tap]
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall - [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
segments (.seg) no longer run solid through an open stretch — only the segments (.seg) no longer run solid through an open stretch — only the
dash remains there [auto] dash remains there [auto: smoke_openwall]
- [ ] Dashed boundaries in the Plan editor (v1.38.3): open stretches render as - [ ] Dashed boundaries in the Plan editor (v1.38.3): open stretches render as
a true dash in markup too (blue trimmed outlines); merge/split-picked a true dash in markup too (blue trimmed outlines); merge/split-picked
rooms keep their full amber highlight [auto] rooms keep their full amber highlight [auto: smoke_openwall]
- [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last - [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last
space AND editor mode (admins; localStorage); a #space= deep link beats space AND editor mode (admins; localStorage); a #space= deep link beats
the saved space; a stale cache without the saved space retries after the the saved space; a stale cache without the saved space retries after the
live config loads [auto] live config loads [manual]
- [ ] Tap action cleanup + right click (v1.38.1): the per-device action list - [ ] Tap action cleanup + right click (v1.38.1): the per-device action list
has three options (Device card / HA more-info / Toggle), no "card has three options (Device card / HA more-info / Toggle), no "card
default" — the card editor's global tap option is gone and ignored; default" — the card editor's global tap option is gone and ignored;
right click on an icon in VIEW opens HA more-info (native menu kept in right click on an icon in VIEW opens HA more-info (native menu kept in
editors; virtual w/o entity → device card) [auto] editors; virtual w/o entity → device card) [auto: smoke_tap_ctx]
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from - [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
the HA list — with a "Show entities" checkbox (tooltip) next to the the HA list — with a "Show entities" checkbox (tooltip) next to the
second; the dropdown (search inside) appears only in HA mode, opens second; the dropdown (search inside) appears only in HA mode, opens
itself when nothing is chosen, closes on pick; Save is blocked until a itself when nothing is chosen, closes on pick; Save is blocked until a
binding is chosen in HA mode; groups/helpers listed always, device binding is chosen in HA mode; groups/helpers listed always, device
entities only with the checkbox; editing pre-selects everything [auto] entities only with the checkbox; editing pre-selects everything [auto: smoke_binding_ui]
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the - [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
rooms' solid strokes are trimmed out beneath it (hover doesn't bring rooms' solid strokes are trimmed out beneath it (hover doesn't bring
them back), walls elsewhere stay solid; the dashes render ABOVE the them back), walls elsewhere stay solid; the dashes render ABOVE the
glow pools [auto] glow pools [auto: smoke_openwall]
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default; - [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
near a shared wall it turns pointer and the exact stretch that would near a shared wall it turns pointer and the exact stretch that would
open is previewed (amber dashed); an already-open boundary previews red open is previewed (amber dashed); an already-open boundary previews red
solid (the click will close it); preview follows the cursor and clears solid (the click will close it); preview follows the cursor and clears
on miss [auto] on miss [auto: smoke_openwall]
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool - [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
toggles a virtual wall between two rooms by clicking their shared wall toggles a virtual wall between two rooms by clicking their shared wall
(pull like Split; miss → toast); open stretches render dashed (amber (pull like Split; miss → toast); open stretches render dashed (amber
highlight while the tool is active); glow light floods the whole highlight while the tool is active); glow light floods the whole
connected open zone transitively, door sectors work from the zone's connected open zone transitively, door sectors work from the zone's
outer walls; merge/split keep links by room id [auto] outer walls; merge/split keep links by room id [auto: smoke_openwall]
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE - [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
the room — room outline and sectors are separate clipPath children the room — room outline and sectors are separate clipPath children
(union), not subpaths of one nonzero path [auto] (union), not subpaths of one nonzero path [auto: smoke_glow]
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius" - [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
field (HA units; empty = general-settings default shown as placeholder); field (HA units; empty = general-settings default shown as placeholder);
an override changes that source's pool and door sectors only [auto] an override changes that source's pool and door sectors only [auto: smoke_glow]
- [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in - [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in
the registry (folded into a light group) still toggles/reflects the lamp, the registry (folded into a light group) still toggles/reflects the lamp,
not its do-not-disturb switch or identify button; visible entities of the not its do-not-disturb switch or identify button; visible entities of the
@@ -231,85 +275,85 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
off, all off → all on, one service call); the icon and its RGB tint off, all off → all on, one service call); the icon and its RGB tint
mirror the targets, not the marker's own entity; without explicit Toggle mirror the targets, not the marker's own entity; without explicit Toggle
the click opens info as usual; the info card lists targets with states; the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto] locks/other domains are filtered out of controls [auto: smoke_controls]
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with - [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
one uniform darkness color; lit lamps glow with a radial gradient one uniform darkness color; lit lamps glow with a radial gradient
(rgb_color → color temp → default color; brightness scales opacity), (rgb_color → color temp → default color; brightness scales opacity),
clipped by the source's room plus door sectors into NEIGHBOUR rooms clipped by the source's room plus door sectors into NEIGHBOUR rooms
(entrance doors leak nothing; windows don't spill); radius set in (entrance doors leak nothing; windows don't spill); radius set in
general settings in HA units (m/ft, stored in cm); no shadow casting — general settings in HA units (m/ft, stored in cm); no shadow casting —
islands don't block light (documented limitation) [auto] islands don't block light (documented limitation) [auto: smoke_glow]
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room - [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
(or around one) saves as a nested room — column in a ring, inner room; (or around one) saves as a nested room — column in a ring, inner room;
the parent's fill renders with an evenodd hole so the ring paints the parent's fill renders with an evenodd hole so the ring paints
correctly; the island stays clickable; partial overlaps and duplicate correctly; the island stays clickable; partial overlaps and duplicate
outlines are still rejected at closing [auto] outlines are still rejected at closing [auto: smoke_island_rooms]
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or - [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
changing its room within the same space never moves the icon — the saved changing its room within the same space never moves the icon — the saved
or auto position migrates to the new marker id; only a brand-new icon or or auto position migrates to the new marker id; only a brand-new icon or
a move to another space centers it in the target room [auto] a move to another space centers it in the target room [auto: smoke_marker_stay]
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device - [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
dialog's icon picker shows the auto-derived icon as its placeholder, plus dialog's icon picker shows the auto-derived icon as its placeholder, plus
an "Auto: mdi:..." hint line with the icon preview; the hint disappears an "Auto: mdi:..." hint line with the icon preview; the hint disappears
once an explicit icon is picked [auto] once an explicit icon is picked [auto: smoke_icon_placeholder]
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools — - [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
add, show all, icon rules; the layout-wiping Reset is gone [auto] add, show all, icon rules; the layout-wiping Reset is gone [auto: smoke_editor_tabs]
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the - [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
Device and Background editors too (instant "I'm editing" cue), not in Device and Background editors too (instant "I'm editing" cue), not in
View; in the Background editor rooms/devices/openings/labels fade to 35% View; in the Background editor rooms/devices/openings/labels fade to 35%
while decor shapes stay fully opaque; no fade in the other editors [auto] while decor shapes stay fully opaque; no fade in the other editors [auto: smoke_decor / smoke_grid_fade]
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select / - [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
line / rect / oval / text / erase + color, width, fill, X); shapes drag- line / rect / oval / text / erase + color, width, fill, X); shapes drag-
drawn with grid snap and live preview; degenerate shapes dropped; text drawn with grid snap and live preview; degenerate shapes dropped; text
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
removes, Erase deletes on click; Esc: draft → selection → select tool → removes, Erase deletes on click; Esc: draft → selection → select tool →
View; decor renders under rooms, visible everywhere, inert outside the View; decor renders under rooms, visible everywhere, inert outside the
editor; stored in space.decor (rev/lock, backend schema) [auto] editor; stored in space.decor (rev/lock, backend schema) [auto: smoke_decor / smoke_grid_fade]
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a - [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
wall shows a dashed 90 cm ghost snapped onto the wall (with a center wall shows a dashed 90 cm ghost snapped onto the wall (with a center
dot); no ghost far from walls, over an existing opening (click = edit), dot); no ghost far from walls, over an existing opening (click = edit),
or in other tools [auto] or in other tools [manual]
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor, - [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
Split shows pointer until a room is picked then crosshair; the cut can be Split shows pointer until a room is picked then crosshair; the cut can be
a polyline — start on a wall, intermediate clicks inside the room, finish a polyline — start on a wall, intermediate clicks inside the room, finish
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back: on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
last cut point → room pick → back to the Draw tool (same for Merge: last cut point → room pick → back to the Draw tool (same for Merge:
selection → tool) [auto] selection → tool) [auto: smoke_split_polyline]
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the - [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
Merge tool (and the split-selected room) gets an amber outline + fill; Merge tool (and the split-selected room) gets an amber outline + fill;
visible over the blue markup outlines [auto] visible over the blue markup outlines [auto: smoke_merge_highlight]
- [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or - [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or
clicking a room card never feeds the active tool (no draw point, no clicking a room card never feeds the active tool (no draw point, no
delete-room confirm, no merge/split pick); clicks past the card work [auto] delete-room confirm, no merge/split pick); clicks past the card work [auto: smoke_merge_highlight]
- [ ] Room cards (v1.31.0): with metrics enabled in space settings (4 - [ ] Room cards (v1.31.0): with metrics enabled in space settings (4
checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets
a smaller metrics line under it; lights show On/Off or "1 of 3" when a smaller metrics line under it; lights show On/Off or "1 of 3" when
partially lit; rooms without an HA area show the name only; in the Plan partially lit; rooms without an HA area show the name only; in the Plan
editor cards show the name only, are draggable and resizable via corner editor cards show the name only, are draggable and resizable via corner
handles on hover (uniform scale 0.53, stored in layout, survives drag); handles on hover (uniform scale 0.53, stored in layout, survives drag);
View mode has no handles/hover [auto] View mode has no handles/hover [auto: smoke_room_cards]
- [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening - [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
info, device info, icon rules, general settings, device editor, opening info, device info, icon rules, general settings, device editor, opening
editor, space dialog incl. abandoning an import queue); stacked dialogs editor, space dialog incl. abandoning an import queue); stacked dialogs
close one per press; Esc while drawing still undoes the last point [auto] close one per press; Esc while drawing still undoes the last point [manual]
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode - [ ] General settings gear (v1.30.3): the header cog is visible in every mode
(admins), opens the palette dialog from View too [auto] (admins), opens the palette dialog from View too [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor" - [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
(no View button; View is the default state); clicking a tab opens its (no View button; View is the default state); clicking a tab opens its
bottom toolbar (Devices got its own bar with add/show-all/reset/rules); bottom toolbar (Devices got its own bar with add/show-all/reset/rules);
the bar and the active tab both show an X that returns to View; re-click the bar and the active tab both show an X that returns to View; re-click
on the active tab does nothing; Plan↔Devices switches directly [auto] on the active tab does nothing; Plan↔Devices switches directly [auto: smoke_editor_tabs]
- [ ] Space gear (v1.30.1): the cog next to the space name is visible in every - [ ] Space gear (v1.30.1): the cog next to the space name is visible in every
mode (admins only), vertically centered with the tab text; clicking it mode (admins only), vertically centered with the tab text; clicking it
opens space settings without switching the tab; "+" tab stays Plan-only [auto] opens space settings without switching the tab; "+" tab stays Plan-only [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when - [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when
locked / Lock when unlocked; button calls the lock service; disabled while locked / Lock when unlocked; button calls the lock service; disabled while
locking/unlocking; hidden when unavailable; plan-icon tap still never locking/unlocking; hidden when unavailable; plan-icon tap still never
toggles a lock [auto] toggles a lock [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red - [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
dot top-right of its icon (all clients); opening its editor clears it dot top-right of its icon (all clients); opening its editor clears it
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto] everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto: smoke_new_device]
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto] - [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto: smoke_new_device]
## Device dialog (markers) ★ ## Device dialog (markers) ★
@@ -318,29 +362,29 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Virtual device: requires name; room required; renders dashed - [ ] Virtual device: requires name; room required; renders dashed
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker - [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
room list ("no area, manual"); a device placed there lands at its centre, room list ("no area, manual"); a device placed there lands at its centre,
the marker stores room_id, reopening the dialog restores the choice [auto] the marker stores room_id, reopening the dialog restores the choice [manual]
- [ ] Room override moves the icon to the room center - [ ] Room override moves the icon to the room center
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies - [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend] - [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto] - [ ] `javascript:` in the link field is not rendered as a clickable link [manual]
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend] - [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
## Icon rules ★ ## Icon rules ★
- [ ] ⬡ opens the editor with current rules (defaults if none saved) - [ ] ⬡ opens the editor with current rules (defaults if none saved)
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [auto] - [ ] Test field resolves live; add/delete/reorder rows; first match wins [manual]
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [auto] - [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [manual]
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [auto] - [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [manual]
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [auto] - [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [manual]
- [ ] Rules survive reload; second browser sees them after live-sync - [ ] Rules survive reload; second browser sees them after live-sync
## Tap actions & gestures ★ ## Tap actions & gestures ★
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [auto] - [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [manual]
- [ ] Locks/alarms never toggle, even with per-device override [auto]; covers/valves toggle only with explicit per-device override [auto] - [ ] Locks/alarms never toggle, even with per-device override [manual]; covers/valves toggle only with explicit per-device override [manual]
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [auto] - [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [manual]
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps - [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [auto] - [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [manual]
## Zoom / pan / labels ## Zoom / pan / labels
@@ -348,7 +392,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed - [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
- [ ] Zoom level persists per space (localStorage), restored on reload - [ ] Zoom level persists per space (localStorage), restored on reload
- [ ] Window resize / sidebar collapse refits without distortion - [ ] Window resize / sidebar collapse refits without distortion
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode - [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [manual]; hidden in markup mode
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom - [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
## Multi-client & concurrency ★ ## Multi-client & concurrency ★
@@ -360,13 +404,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Edge cases ## Edge cases
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [auto] - [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [manual]
- [ ] Space with zero rooms → renders; markup hint visible - [ ] Space with zero rooms → renders; markup hint visible
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal - [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [auto] - [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [manual]
- [ ] 100+ devices in one space → build under ~50 ms [auto], drag stays smooth - [ ] 100+ devices in one space → build under ~50 ms [manual], drag stays smooth
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion - [ ] Very long device/room names → ellipsis/wrap, no layout explosion
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [auto] - [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [manual]
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend] - [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend] - [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss - [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
@@ -384,7 +428,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Last self-run ## Last self-run
**v1.21.1 (2026-07-16), full audit of v1.16v1.21.** All `[auto]` items pass (73 frontend **v1.21.1 (2026-07-16), full audit of v1.16v1.21.** All `[manual]` items pass (73 frontend
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`. the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
@@ -394,7 +438,7 @@ polygons) — the click now snaps to the nearest wall instead of the grid, and `
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
was missing. The earlier self-run record follows. was missing. The earlier self-run record follows.
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass **v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[manual]` items pass
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`, (43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release: tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
1. Edit dialog: switching an existing space from image to "draw" kept the old 1. Edit dialog: switching an existing space from image to "draw" kept the old
@@ -410,14 +454,14 @@ require hands on real hardware — they remain for the human pass.
## houseplan-space-card (read-only embedded) ## houseplan-space-card (read-only embedded)
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full - [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
card's plan (background + configured borders/names + room fills + icons), no header/controls [auto] card's plan (background + configured borders/names + room fills + icons), no header/controls [manual]
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info, - [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [auto] no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [manual]
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [auto] - [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [manual]
- [ ] Several cards with different `space` coexist on one board; one shared config WS request - [ ] Several cards with different `space` coexist on one board; one shared config WS request
- [ ] Unknown `space` → tidy error card [auto] - [ ] Unknown `space` → tidy error card [manual]
- [ ] `show_button: false` hides the footer - [ ] `show_button: false` hides the footer
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto] - [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [manual]
## Presence ripples / per-device icon (v1.22.0) ## Presence ripples / per-device icon (v1.22.0)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.42.2", "version": "1.43.2",
"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",
+183 -31
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.2';
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 {
@@ -149,7 +177,7 @@ class HouseplanCard extends LitElement {
x: number; y: number; angle: number; // render units (from the wall snap) x: number; y: number; angle: number; // render units (from the wall snap)
} | null = null; } | null = null;
private _openingInfo: OpeningCfg | 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 _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 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 // a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
@@ -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._saveConfigDebounced.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();
@@ -450,6 +480,7 @@ class HouseplanCard extends LitElement {
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null'); const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
if (c && c.config && Array.isArray(c.config.spaces)) { if (c && c.config && Array.isArray(c.config.spaces)) {
this._serverCfg = c.config; this._serverCfg = c.config;
this._cfgEpoch++;
this._cfgRev = c.rev || 0; this._cfgRev = c.rev || 0;
this._layout = c.layout || {}; this._layout = c.layout || {};
this._serverStorage = true; this._serverStorage = true;
@@ -490,7 +521,36 @@ class HouseplanCard extends LitElement {
} }
/** Spaces in render units (NORM_W × NORM_W/aspect). */ /** 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[] { 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 []; if (!this._serverCfg) return [];
return this._serverCfg.spaces.map((s: any) => { return this._serverCfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect; const H = NORM_W / s.aspect;
@@ -510,7 +570,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),
}; };
}); });
@@ -615,11 +675,15 @@ class HouseplanCard extends LitElement {
this._serverStorage = true; this._serverStorage = true;
const cfg = cfgResp?.config; const cfg = cfgResp?.config;
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null; this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
this._cfgEpoch++;
this._cfgRev = cfgResp?.rev || 0; this._cfgRev = cfgResp?.rev || 0;
this._layout = layResp?.layout || {}; this._layout = layResp?.layout || {};
// 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,21 +721,40 @@ 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._saveConfigDebounced.pending()) this._saveConfigDebounced.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;
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null; this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
this._cfgEpoch++;
this._cfgRev = resp?.rev || 0; this._cfgRev = resp?.rev || 0;
this._cacheSnapshot(); this._cacheSnapshot();
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 +1466,36 @@ 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;
} }
private _saveConfig = debounce(() => { /** A config write is in flight — the card must not adopt a server revision. */
private _cfgWriting = false;
/**
* 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; 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) }));
} }
@@ -1787,7 +1887,25 @@ class HouseplanCard extends LitElement {
} }
/** All open-boundary pairs of the current space with their shared segments. */ /** 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[][] }[] { 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 rooms = this._spaceModel().rooms.filter((r) => r.id);
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = []; const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
for (let i = 0; i < rooms.length; i++) for (let i = 0; i < rooms.length; i++)
@@ -1898,11 +2016,16 @@ class HouseplanCard extends LitElement {
} catch { } catch {
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */ /* 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 { private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
if (!this._opDrag || this._opDrag.id !== o.id) return; 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 raw = this._svgPoint(ev);
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4); const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
if (!snap) return; // too far from any wall: the opening stays where it was if (!snap) return; // too far from any wall: the opening stays where it was
@@ -1910,8 +2033,11 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg; const sp = this._curSpaceCfg;
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id); const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
if (!cfg) return; if (!cfg) return;
cfg.x = snap.x / NORM_W; const nx = snap.x / NORM_W;
cfg.y = snap.y / this._spaceH; 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; cfg.angle = snap.angle;
this.requestUpdate(); this.requestUpdate();
} }
@@ -1919,7 +2045,8 @@ class HouseplanCard extends LitElement {
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void { private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
if (!this._opDrag || this._opDrag.id !== o.id) return; if (!this._opDrag || this._opDrag.id !== o.id) return;
const moved = this._opDrag.moved; 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 // keep the flag until the click event that follows pointerup, then let it go
if (moved) window.setTimeout(() => (this._opDrag = null), 0); if (moved) window.setTimeout(() => (this._opDrag = null), 0);
else this._opDrag = null; else this._opDrag = null;
@@ -2577,7 +2704,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 +2903,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) }));
} }
} }
@@ -2802,6 +2937,7 @@ class HouseplanCard extends LitElement {
conflict again. */ conflict again. */
private async _saveConfigNow(): Promise<void> { private async _saveConfigNow(): Promise<void> {
this._dropLegacySegments(); this._dropLegacySegments();
this._cfgEpoch++;
try { try {
const r = await this.hass.callWS({ const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev, type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
@@ -2930,7 +3066,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 +3255,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) }));
} }
} }
@@ -3345,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" />` ? 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} : nothing}
${this._renderDecorLayer()} ${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' : ''); let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId)) if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
cls += ' picked'; cls += ' picked';
@@ -3392,16 +3547,12 @@ class HouseplanCard extends LitElement {
// amber highlight — the merge/split selection must stay visible). // amber highlight — the merge/split selection must stay visible).
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId); const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
const openCuts = r.id && !isPicked const openCuts = r.id && !isPicked
? this._openPairs() ? allPairs.filter((pp) => pp.a.id === r.id || pp.b.id === r.id).flatMap((pp) => pp.segs)
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
.flatMap((pp) => pp.segs)
: []; : [];
if (openCuts.length) cls += ' noedge'; if (openCuts.length) cls += ' noedge';
// island rooms punch holes in their parent's fill (evenodd) // island rooms punch holes in their parent's fill (evenodd)
const myPoly = roomPoly(r); const myPoly = polyOf(r);
const holes = myPoly const holes = myPoly ? islandsOf(myPoly, otherPolys(r)) : [];
? islandsOf(myPoly, space.rooms.filter((o) => o !== r).map((o) => roomPoly(o)!).filter(Boolean))
: [];
const pathD = (pts: number[][]) => const pathD = (pts: number[][]) =>
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z'; 'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
const shape = holes.length && myPoly const shape = holes.length && myPoly
@@ -3426,7 +3577,8 @@ class HouseplanCard extends LitElement {
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>` style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
: nothing; : nothing;
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : 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} ${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._renderOpenWalls(disp)} ${this._renderOpenWalls(disp)}
${this._editing ? this._renderAlignGuides() : nothing} ${this._editing ? this._renderAlignGuides() : nothing}
@@ -4330,7 +4482,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 +4714,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}"
} }
+92 -15
View File
@@ -38,8 +38,14 @@ export function formatLength(cm: number, imperial: boolean): string {
* normalized (0..1) coordinates need more (see roomEdges). * normalized (0..1) coordinates need more (see roomEdges).
*/ */
export function segKey(a: number[], b: number[], prec = 1): string { 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]; // audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`; // 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. */ /** 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 { function coversArea(a: number[][], b: number[][], eps: number): boolean {
let allOnBoundary = true; let allOnBoundary = true;
for (const v of a) { 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 // every vertex sits on b's outline → a duplicate or traced outline: probe the middle
if (allOnBoundary) { if (allOnBoundary) {
const c = [ const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
a.reduce((s, p) => s + p[0], 0) / a.length, return !!c && pointStrictlyInside(c, b, eps);
a.reduce((s, p) => s + p[1], 0) / a.length,
];
return pointStrictlyInside(c, b, eps);
} }
return false; return false;
} }
@@ -250,12 +283,10 @@ export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-
return false; return false;
for (const v of inner) for (const v of inner)
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false; if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
// identical/traced outlines are NOT containment — probe the centroid strictness both ways // identical/traced outlines are NOT containment — probe a real interior point
const c = [ // (audit G2: the vertex mean lies outside concave rooms)
inner.reduce((s, p) => s + p[0], 0) / inner.length, const c = interiorPoint(inner, eps);
inner.reduce((s, p) => s + p[1], 0) / inner.length, return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
];
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
} }
/** /**
@@ -380,10 +411,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 +1031,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;
}); });
+58
View File
@@ -12,6 +12,8 @@ import {
swipeTarget, clampScale, swipeTarget, clampScale,
migratePdfUrls, migratePdfUrls,
roomFillModeOf, roomFillModeOf,
contentUrl,
interiorPoint,
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 +811,59 @@ 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), '');
});
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]));
});
+3 -2
View File
@@ -31,7 +31,8 @@ async def test_upload_ok(hass: HomeAssistant, hass_client: ClientSessionGenerato
resp = await client.post("/api/houseplan/upload", data=fd) resp = await client.post("/api/houseplan/upload", data=fd)
assert resp.status == 200 assert resp.status == 200
body = await resp.json() body = await resp.json()
assert body["ok"] and body["url"].startswith("/houseplan_files/files/m1/manual.pdf?v=") # audit B1: uploads now return the AUTHENTICATED content URL
assert body["ok"] and body["url"].startswith("/api/houseplan/content/files/m1/manual.pdf?v=")
async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None: async def test_upload_bad_ext(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> None:
@@ -57,4 +58,4 @@ async def test_upload_traversal_sanitized(hass: HomeAssistant, hass_client: Clie
# no path segment may be exactly ".." (dots inside a name are harmless) # no path segment may be exactly ".." (dots inside a name are harmless)
path = body["url"].split("?", 1)[0] path = body["url"].split("?", 1)[0]
assert all(seg != ".." for seg in path.split("/")) assert all(seg != ".." for seg in path.split("/"))
assert path.startswith("/houseplan_files/files/") assert path.startswith("/api/houseplan/content/files/")
+26 -1
View File
@@ -99,4 +99,29 @@ async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocket
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="} {"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
) )
resp = await client.receive_json() resp = await client.receive_json()
assert resp["success"] and resp["result"]["url"].startswith("/houseplan_files/plans/s1.png?v=") assert resp["success"] and resp["result"]["url"].startswith("/api/houseplan/content/plans/_/s1.png?v=")
async def test_admin_check_fails_closed(hass, hass_ws_client):
"""audit B2/T4: with no config entry the policy is unknown — deny writes.
This used to allow them: plan uploads slipped through during a reload.
"""
from custom_components.houseplan import websocket_api as wsapi
class _User:
is_admin = False
class _Conn:
user = _User()
# no entry at all → non-admin must be refused
assert wsapi._check_write(hass, _Conn()) is False
class _Admin:
is_admin = True
class _AdminConn:
user = _Admin()
assert wsapi._check_write(hass, _AdminConn()) is True
+15
View File
@@ -125,3 +125,18 @@ def test_space_temp_bounds():
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"}, "settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
} }
v.SPACE_SCHEMA(ok) 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)