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