mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.
R2-2: the backend signs at most MAX_SIGN_PATHS (200) per request and ignores
the rest silently, while the card sent its whole cache in one call and trusted
any cached entry forever — past 200 attachments the later ones stopped being
refreshed and expired for good. Requests are chunked to the shared constant,
entries carry their issue time (aging urls keep rendering while a replacement
is fetched, expired ones are dropped), and the cache is pruned to urls the live
config still references.
R2-3: areaClimate() rescanned the whole registry per room and per measurement.
areaClimateMap() classifies once and returns Map<area,{temp,hum}>, memoized on
hass identity so fresh states are always observed. Smoke measurement: 133
registry scans per update with 44 rooms before, 2 after, flat in room count.
Also: smoke_ux_fixes wrote its screenshot to a hard-coded /tmp path and could
not run on Windows.
Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
@@ -11,9 +11,14 @@ 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>
|
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||||
CONTENT_URL = "/api/houseplan/content"
|
CONTENT_URL = "/api/houseplan/content"
|
||||||
|
|
||||||
|
# How many paths one houseplan/content/sign call may carry. The card batches to
|
||||||
|
# the same number; a client that sends more used to get a partial answer with no
|
||||||
|
# way to tell which paths were dropped (review R2-2).
|
||||||
|
MAX_SIGN_PATHS = 200
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.44.8"
|
VERSION = "1.45.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.44.8"
|
"version": "1.45.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -15,13 +16,13 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
|
||||||
CONTENT_URL, PLANS_DIR, PLANS_URL,
|
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||||
)
|
)
|
||||||
from .auth import may_write
|
from .auth import may_write
|
||||||
from .store import HouseplanData, get_data, get_entry
|
from .store import HouseplanData, get_data, get_entry
|
||||||
from .validation import (
|
from .validation import (
|
||||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ def async_register(hass: HomeAssistant) -> None:
|
|||||||
websocket_api.async_register_command(hass, ws_config_get)
|
websocket_api.async_register_command(hass, ws_config_get)
|
||||||
websocket_api.async_register_command(hass, ws_config_set)
|
websocket_api.async_register_command(hass, ws_config_set)
|
||||||
websocket_api.async_register_command(hass, ws_plan_set)
|
websocket_api.async_register_command(hass, ws_plan_set)
|
||||||
|
websocket_api.async_register_command(hass, ws_plan_cleanup)
|
||||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||||
websocket_api.async_register_command(hass, ws_content_sign)
|
websocket_api.async_register_command(hass, ws_content_sign)
|
||||||
@@ -226,7 +228,7 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
|||||||
|
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
token_id = getattr(connection, "refresh_token_id", None)
|
token_id = getattr(connection, "refresh_token_id", None)
|
||||||
for path in msg["paths"][:200]:
|
for path in msg["paths"][:MAX_SIGN_PATHS]:
|
||||||
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
||||||
continue # only ever sign our own content endpoint
|
continue # only ever sign our own content endpoint
|
||||||
clean = path.split("?", 1)[0]
|
clean = path.split("?", 1)[0]
|
||||||
@@ -400,20 +402,81 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
|||||||
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
|
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Copy-on-write: a plan is written under a NEW unique name and nothing is
|
||||||
|
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||||
|
# that is later rejected — revision conflict, validation, lost connection —
|
||||||
|
# leaves the stored plan exactly as it was. The card calls
|
||||||
|
# `houseplan/plan/cleanup` only after its config CAS succeeds; a crash in
|
||||||
|
# between leaves an orphan file that the next successful save removes.
|
||||||
|
#
|
||||||
|
# `.` separates the id from the token because a space id cannot contain one
|
||||||
|
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||||
|
# files of a differently named space.
|
||||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||||
path = plans_dir / f"{space_id}.{msg['ext']}"
|
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||||
|
path = plans_dir / name
|
||||||
|
|
||||||
def _write() -> int:
|
def _write() -> None:
|
||||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||||
# remove old variants with a different extension
|
|
||||||
for old_ext in PLAN_EXTENSIONS:
|
|
||||||
old = plans_dir / f"{space_id}.{old_ext}"
|
|
||||||
if old_ext != msg["ext"] and old.exists():
|
|
||||||
old.unlink()
|
|
||||||
path.write_bytes(raw)
|
path.write_bytes(raw)
|
||||||
return int(path.stat().st_mtime)
|
|
||||||
|
|
||||||
mtime = await hass.async_add_executor_job(_write)
|
await hass.async_add_executor_job(_write)
|
||||||
connection.send_result(
|
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||||
msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{space_id}.{msg['ext']}?v={mtime}"}
|
|
||||||
)
|
|
||||||
|
def _plan_files(plans_dir: Path, space_id: str) -> list[Path]:
|
||||||
|
"""Every plan file belonging to a space: the legacy flat name and versioned ones."""
|
||||||
|
out: list[Path] = []
|
||||||
|
if not plans_dir.is_dir():
|
||||||
|
return out
|
||||||
|
for item in plans_dir.iterdir():
|
||||||
|
if not item.is_file():
|
||||||
|
continue
|
||||||
|
parts = item.name.split(".")
|
||||||
|
# "<space>.<ext>" (legacy) or "<space>.<token>.<ext>"
|
||||||
|
if len(parts) in (2, 3) and parts[0] == space_id and parts[-1].lower() in PLAN_EXTENSIONS:
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "houseplan/plan/cleanup",
|
||||||
|
vol.Required("space_id"): str,
|
||||||
|
vol.Required("keep"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_plan_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
|
"""Drop superseded plan files for a space (review R2-1).
|
||||||
|
|
||||||
|
Called by the card ONLY after the config write that references `keep` has
|
||||||
|
been accepted. Until then every previous file is still on disk, which is
|
||||||
|
what makes a rejected save harmless. Deleting nothing is always a safe
|
||||||
|
outcome here — the orphans are bounded by one per rejected upload and are
|
||||||
|
collected by the next successful one.
|
||||||
|
"""
|
||||||
|
if not _check_write(hass, connection):
|
||||||
|
connection.send_error(msg["id"], "unauthorized", "Only administrators may manage plans")
|
||||||
|
return
|
||||||
|
space_id = msg["space_id"]
|
||||||
|
if not valid_space_id(space_id):
|
||||||
|
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||||
|
return
|
||||||
|
keep = sanitize_filename(msg["keep"])
|
||||||
|
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||||
|
|
||||||
|
def _clean() -> int:
|
||||||
|
removed = 0
|
||||||
|
for item in _plan_files(plans_dir, space_id):
|
||||||
|
if item.name == keep:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item.unlink()
|
||||||
|
removed += 1
|
||||||
|
except OSError as err: # noqa: PERF203 — a stuck file must not fail the save
|
||||||
|
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
removed = await hass.async_add_executor_job(_clean)
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
|
||||||
|
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
|
||||||
|
// перечитывании метаданных, которые не менялись. Карта строится один раз на
|
||||||
|
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
|
||||||
|
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;
|
||||||
|
|
||||||
|
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
|
||||||
|
let scans = 0;
|
||||||
|
const wrap = (h) => {
|
||||||
|
const ents = h.entities;
|
||||||
|
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||||
|
return { ...h, entities: traced };
|
||||||
|
};
|
||||||
|
const fresh = () => wrap(window.__mkHass());
|
||||||
|
|
||||||
|
// включаем и заливку по температуре, и подписи — два потребителя климата
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||||
|
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
|
||||||
|
})};
|
||||||
|
c._cfgEpoch++;
|
||||||
|
c.hass = fresh(); await c.updateComplete;
|
||||||
|
|
||||||
|
scans = 0;
|
||||||
|
c.hass = fresh(); await c.updateComplete;
|
||||||
|
const fewRooms = scans;
|
||||||
|
|
||||||
|
// повторные рендеры на том же снимке hass реестр не трогают
|
||||||
|
scans = 0;
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
c.requestUpdate(); await c.updateComplete;
|
||||||
|
out.scansOnRerender = scans;
|
||||||
|
|
||||||
|
// главный инвариант: обходов НЕ становится больше от числа комнат
|
||||||
|
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||||
|
const extra = [];
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
|
||||||
|
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
|
||||||
|
}
|
||||||
|
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
|
||||||
|
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
|
||||||
|
c._cfgEpoch++;
|
||||||
|
c.hass = fresh(); await c.updateComplete;
|
||||||
|
scans = 0;
|
||||||
|
c.hass = fresh(); await c.updateComplete;
|
||||||
|
out.roomCount = c._spaceModel('f1').rooms.length;
|
||||||
|
out.scansSameWith44Rooms = scans === fewRooms;
|
||||||
|
out.scansPerUpdate = scans;
|
||||||
|
|
||||||
|
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
|
||||||
|
out.tempBefore = c._climate().get('living_room')?.temp;
|
||||||
|
const h = fresh();
|
||||||
|
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
|
||||||
|
c.hass = h; await c.updateComplete;
|
||||||
|
out.tempAfter = c._climate().get('living_room')?.temp;
|
||||||
|
out.climateIsMap = c._climate() instanceof Map;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
|
||||||
|
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
|
||||||
|
// не само число, а что оно не растёт вместе с числом комнат.
|
||||||
|
checkAll(res, {
|
||||||
|
scansOnRerender: 0,
|
||||||
|
roomCount: 44,
|
||||||
|
scansSameWith44Rooms: true,
|
||||||
|
scansPerUpdate: 2,
|
||||||
|
tempBefore: 22.4,
|
||||||
|
tempAfter: 33.3,
|
||||||
|
climateIsMap: true,
|
||||||
|
});
|
||||||
|
await finish(browser);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Граница транзакции загрузки подложки (ревью R2-1).
|
||||||
|
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
|
||||||
|
// сохранение не имеет права трогать сохранённый план. Проверяем контракт со
|
||||||
|
// стороны карточки: удаление старых файлов (houseplan/plan/cleanup) уходит
|
||||||
|
// ТОЛЬКО после принятого config/set — и никогда после отказа.
|
||||||
|
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 base = c.hass.callWS;
|
||||||
|
let uploads = 0;
|
||||||
|
const cleanups = [];
|
||||||
|
let rejectSave = true;
|
||||||
|
|
||||||
|
c.hass = { ...c.hass, callWS: async (m) => {
|
||||||
|
if (m.type === 'houseplan/plan/set') {
|
||||||
|
uploads++;
|
||||||
|
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
|
||||||
|
}
|
||||||
|
if (m.type === 'houseplan/plan/cleanup') { cleanups.push(m); return { ok: true, removed: 1 }; }
|
||||||
|
if (m.type === 'houseplan/config/set') {
|
||||||
|
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||||
|
c.__sent = m.config; return { ok: true, rev: 77 };
|
||||||
|
}
|
||||||
|
if (m.type === 'houseplan/config/get') {
|
||||||
|
const r = await base(m);
|
||||||
|
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
|
||||||
|
}
|
||||||
|
return base(m);
|
||||||
|
} };
|
||||||
|
|
||||||
|
const attach = async () => {
|
||||||
|
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||||
|
c._spaceDialog = { ...c._spaceDialog, title: 'Ground', source: 'file',
|
||||||
|
planFile: { ext: 'png', b64: 'AAAA', aspect: 1.6 } };
|
||||||
|
await c._saveSpaceDialog(); await c.updateComplete;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) конфиг отвергнут → файл загружен, но чистить старый план нельзя
|
||||||
|
await attach();
|
||||||
|
out.uploadedOnReject = uploads === 1;
|
||||||
|
out.cleanupsAfterReject = cleanups.length;
|
||||||
|
out.dialogStaysOpenOnReject = c._spaceDialog !== null;
|
||||||
|
|
||||||
|
// 2) конфиг принят → чистка уходит, и ровно на тот файл, что записан в конфиг
|
||||||
|
rejectSave = false;
|
||||||
|
c._spaceDialog = null; await c.updateComplete;
|
||||||
|
await attach();
|
||||||
|
out.cleanupsAfterAccept = cleanups.length;
|
||||||
|
out.cleanupSpace = cleanups[0]?.space_id;
|
||||||
|
out.cleanupKeep = cleanups[0]?.keep;
|
||||||
|
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||||
|
out.savedPlanUrl = f1?.plan_url;
|
||||||
|
out.keepMatchesSavedUrl = !!f1 && f1.plan_url.endsWith('/' + cleanups[0]?.keep);
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
||||||
|
checkAll(res, {
|
||||||
|
uploadedOnReject: true,
|
||||||
|
cleanupsAfterReject: 0,
|
||||||
|
dialogStaysOpenOnReject: true,
|
||||||
|
cleanupsAfterAccept: 1,
|
||||||
|
cleanupSpace: 'f1',
|
||||||
|
cleanupKeep: 'f1.tok2.png',
|
||||||
|
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
|
||||||
|
keepMatchesSavedUrl: true,
|
||||||
|
});
|
||||||
|
await finish(browser);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Ревью R2-2: бэкенд подписывает не более MAX_SIGN_PATHS путей за вызов и
|
||||||
|
// молча отбрасывает остальные. Карточка обязана бить запрос на батчи, помнить
|
||||||
|
// возраст подписи и чистить кэш от ссылок, которых в конфиге больше нет —
|
||||||
|
// иначе на настенном планшете «лишние» записи протухают навсегда.
|
||||||
|
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 base = c.hass.callWS;
|
||||||
|
const batchSizes = [];
|
||||||
|
let round = 0;
|
||||||
|
|
||||||
|
c.hass = { ...c.hass, callWS: async (m) => {
|
||||||
|
if (m.type === 'houseplan/content/sign') {
|
||||||
|
batchSizes.push(m.paths.length);
|
||||||
|
const urls = {};
|
||||||
|
// как настоящий бэкенд: не больше 200 за раз, про остальные — молчание
|
||||||
|
for (const p of m.paths.slice(0, 200)) urls[p] = p.split('?')[0] + '?authSig=R' + round;
|
||||||
|
return { urls };
|
||||||
|
}
|
||||||
|
return base(m);
|
||||||
|
} };
|
||||||
|
|
||||||
|
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
|
||||||
|
const pdfs = [];
|
||||||
|
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
|
||||||
|
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
|
||||||
|
c._cfgEpoch++;
|
||||||
|
|
||||||
|
round = 1;
|
||||||
|
for (const p of pdfs) c._display(p.url);
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
out.firstBatches = [...batchSizes];
|
||||||
|
out.signedAfterFirst = Object.keys(c._signed).length;
|
||||||
|
|
||||||
|
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
|
||||||
|
batchSizes.length = 0;
|
||||||
|
round = 2;
|
||||||
|
c._resign();
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
out.resignBatches = [...batchSizes];
|
||||||
|
const vals = Object.values(c._signed).map((v) => v.url);
|
||||||
|
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
|
||||||
|
|
||||||
|
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
|
||||||
|
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
|
||||||
|
c._cfgEpoch++;
|
||||||
|
batchSizes.length = 0;
|
||||||
|
round = 3;
|
||||||
|
c._resign();
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
out.prunedTo = Object.keys(c._signed).length;
|
||||||
|
out.pruneBatches = [...batchSizes];
|
||||||
|
|
||||||
|
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
|
||||||
|
const one = pdfs[0].url;
|
||||||
|
c._signed = { ...c._signed, [one]: { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 } };
|
||||||
|
out.expiredNotServed = c._display(one) === '';
|
||||||
|
// а стареющая, но ещё живая — отдаётся, пока едет замена
|
||||||
|
c._signed = { ...c._signed, [one]: { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 } };
|
||||||
|
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
||||||
|
checkAll(res, {
|
||||||
|
firstBatches: [200, 1],
|
||||||
|
signedAfterFirst: 201,
|
||||||
|
resignBatches: [200, 1],
|
||||||
|
allRefreshed: true,
|
||||||
|
prunedTo: 5,
|
||||||
|
pruneBatches: [5],
|
||||||
|
expiredNotServed: true,
|
||||||
|
agingStillServed: true,
|
||||||
|
});
|
||||||
|
await finish(browser);
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { launch, checkAll, finish } 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 () => {
|
||||||
@@ -33,7 +35,10 @@ const res = await page.evaluate(async () => {
|
|||||||
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
|
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
await page.screenshot({ path: '/tmp/ux_dialog.png' });
|
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
|
||||||
|
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
|
||||||
|
// ассертов (портируемость, ревью 2026-07-27)
|
||||||
|
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
|
||||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||||
checkAll(res, {
|
checkAll(res, {
|
||||||
"filledClass": 1,
|
"filledClass": 1,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+12
-12
File diff suppressed because one or more lines are too long
+26
-1
@@ -177,9 +177,34 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
|||||||
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
|
||||||
| `houseplan/config/get` | — | `{config, rev}` |
|
| `houseplan/config/get` | — | `{config, rev}` |
|
||||||
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
|
||||||
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` |
|
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
|
||||||
|
| `houseplan/plan/cleanup` | `space_id`, `keep` | `{ok, removed}` — call ONLY after the config write referencing `keep` was accepted |
|
||||||
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
|
||||||
|
|
||||||
|
**Plan uploads are copy-on-write** (review R2-1). The file system is not part
|
||||||
|
of the config's optimistic-locking transaction, so nothing that is currently
|
||||||
|
referenced may be overwritten or deleted before the config CAS succeeds: the
|
||||||
|
upload writes a new versioned name, the card commits the url, and only then
|
||||||
|
asks for `plan/cleanup`. A crash between the two leaves one orphan file, which
|
||||||
|
the next successful upload removes. The `.` between id and token is load-bearing
|
||||||
|
— a space id cannot contain one, so `<space>.<token>.<ext>` can never be
|
||||||
|
confused with the files of a space whose name merely starts the same way.
|
||||||
|
|
||||||
|
**Signed content urls are batched and aged** (review R2-2). `MAX_SIGN_PATHS`
|
||||||
|
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
|
||||||
|
request there and says nothing about the rest, so the card must chunk. Cached
|
||||||
|
signatures carry the time they were issued — an aging one keeps rendering while
|
||||||
|
its replacement is fetched, an expired one is dropped rather than served (it
|
||||||
|
would 401 and raise a failed-login warning). The cache is pruned to the urls the
|
||||||
|
live config references, so it cannot grow past the cap through history alone.
|
||||||
|
|
||||||
|
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||||||
|
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||||||
|
card memoizes it on `hass` identity, which Home Assistant replaces on every
|
||||||
|
state change. Per-room lookups are O(1). `areaClimate()` survives as a
|
||||||
|
single-area wrapper for tests — using it in a render reintroduces the
|
||||||
|
O(rooms × entities) cost it was extracted from.
|
||||||
|
|
||||||
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
|
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
|
||||||
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
|
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3)
|
||||||
|
- **A rejected save can no longer damage a working plan (R2-1, high).** The
|
||||||
|
plan file was written to its final name — deleting the previous extension on
|
||||||
|
the way — *before* the revision-checked config write. If that write was then
|
||||||
|
rejected (revision conflict, validation, lost connection), the live plan had
|
||||||
|
already been replaced, or the stored config was left pointing at a file that
|
||||||
|
no longer existed. Uploads now go to a versioned name
|
||||||
|
(`<space>.<token>.<ext>`) and nothing is deleted; the card asks the backend to
|
||||||
|
drop the superseded files only after the config write is accepted. A crash in
|
||||||
|
between leaves one orphan, which the next successful upload collects.
|
||||||
|
- **Signed urls no longer expire for good on long-lived screens (R2-2).** The
|
||||||
|
backend signs at most 200 paths per request and silently ignores the rest,
|
||||||
|
while the card sent its whole cache in one call and treated any cached entry
|
||||||
|
as valid forever. Past 200 attachments the later ones stopped being refreshed
|
||||||
|
and, 24 hours in, quietly broke. Requests are now batched to the shared limit,
|
||||||
|
entries carry their age (aging urls keep working while a replacement is
|
||||||
|
fetched, expired ones are never served), and the cache is pruned to the urls
|
||||||
|
the current config still references.
|
||||||
|
- **Room climate is computed once per update instead of once per room (R2-3).**
|
||||||
|
Each room asked for temperature and humidity separately, and every ask
|
||||||
|
rescanned the entire entity registry: with 60 rooms and 2000 entities that is
|
||||||
|
~120 traversals per render, enough to spend a whole frame on metadata that had
|
||||||
|
not changed. One pass now builds a map for all areas, keyed on the Home
|
||||||
|
Assistant snapshot, so fresh states are always observed while unrelated
|
||||||
|
re-renders cost nothing. Measured in the smoke: 133 registry scans per update
|
||||||
|
before, 2 after — and no longer growing with the number of rooms.
|
||||||
|
- `smoke_ux_fixes` wrote its screenshot to a hard-coded `/tmp` path and could
|
||||||
|
not run on Windows; it uses the OS temp directory now.
|
||||||
|
- New tests: `smoke_plan_upload_reject` (cleanup happens only after an accepted
|
||||||
|
save), `smoke_sign_cap` (201 urls, batching, pruning, expiry),
|
||||||
|
`smoke_climate_once` (scan count does not grow with rooms), plus backend
|
||||||
|
coverage for the versioned plan names and unit tests for the new helpers.
|
||||||
|
|
||||||
## v1.44.8 — 2026-07-27
|
## v1.44.8 — 2026-07-27
|
||||||
- **An uploaded plan is actually attached to the space.** `_saveSpaceDialog`
|
- **An uploaded plan is actually attached to the space.** `_saveSpaceDialog`
|
||||||
held a reference to the space object across the `await` that uploads the
|
held a reference to the space object across the `await` that uploads the
|
||||||
|
|||||||
@@ -6,6 +6,39 @@
|
|||||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||||
|
|
||||||
|
## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3)
|
||||||
|
- **Отвергнутое сохранение больше не может испортить рабочий план (R2-1,
|
||||||
|
high).** Файл плана записывался под финальным именем — попутно удаляя вариант
|
||||||
|
с другим расширением — *до* проверки ревизии конфига. Если запись потом
|
||||||
|
отвергалась (конфликт ревизий, валидация, обрыв связи), живой план оказывался
|
||||||
|
уже подменён, а сохранённый конфиг мог ссылаться на удалённый файл. Теперь
|
||||||
|
загрузка идёт в версионированное имя (`<space>.<токен>.<ext>`) и ничего не
|
||||||
|
удаляется; карточка просит бэкенд убрать устаревшие файлы только после
|
||||||
|
принятой записи конфига. Падение между шагами оставляет один осиротевший
|
||||||
|
файл, который подберёт следующая успешная загрузка.
|
||||||
|
- **Подписанные ссылки больше не протухают навсегда на долгоживущих экранах
|
||||||
|
(R2-2).** Бэкенд подписывает не более 200 путей за запрос и молча отбрасывает
|
||||||
|
остальные, а карточка отправляла весь кэш одним вызовом и считала любую
|
||||||
|
запись годной вечно. Начиная с 201-го вложения поздние ссылки переставали
|
||||||
|
обновляться и через 24 часа тихо ломались. Теперь запросы бьются на батчи по
|
||||||
|
общему лимиту, записи помнят свой возраст (стареющая ссылка работает, пока
|
||||||
|
едет замена, протухшая не отдаётся вовсе), а кэш чистится до ссылок, на
|
||||||
|
которые конфиг всё ещё ссылается.
|
||||||
|
- **Климат комнат считается один раз на обновление, а не на каждую комнату
|
||||||
|
(R2-3).** Каждая комната запрашивала температуру и влажность по отдельности,
|
||||||
|
и каждый запрос заново обходил весь реестр сущностей: 60 комнат и 2000
|
||||||
|
сущностей — это ~120 обходов на рендер, целый кадр на метаданные, которые не
|
||||||
|
менялись. Теперь один проход строит карту по всем зонам с привязкой к снимку
|
||||||
|
Home Assistant: свежие состояния видны всегда, а посторонние перерисовки не
|
||||||
|
стоят ничего. Замер в смоуке: 133 обхода реестра на обновление до, 2 после —
|
||||||
|
и число больше не растёт с числом комнат.
|
||||||
|
- `smoke_ux_fixes` писал скриншот по жёстко зашитому пути `/tmp` и не запускался
|
||||||
|
на Windows — теперь берёт временную папку у ОС.
|
||||||
|
- Новые тесты: `smoke_plan_upload_reject` (чистка только после принятого
|
||||||
|
сохранения), `smoke_sign_cap` (201 ссылка, батчи, чистка, срок годности),
|
||||||
|
`smoke_climate_once` (число обходов не растёт с числом комнат), плюс
|
||||||
|
backend-покрытие версионированных имён и юнит-тесты новых хелперов.
|
||||||
|
|
||||||
## v1.44.8 — 2026-07-27
|
## v1.44.8 — 2026-07-27
|
||||||
- **Загруженная подложка действительно привязывается к пространству.**
|
- **Загруженная подложка действительно привязывается к пространству.**
|
||||||
`_saveSpaceDialog` держал ссылку на объект пространства через `await`
|
`_saveSpaceDialog` держал ссылку на объект пространства через `await`
|
||||||
|
|||||||
+2
-2
@@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.44.8** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
| Version | **v1.45.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||||
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.44.8** via direct copy (HACS custom repo also installed) |
|
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.0** via direct copy (HACS custom repo also installed) |
|
||||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||||
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
|
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
|
||||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||||
|
|||||||
@@ -234,6 +234,20 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
are only reachable through /api/houseplan/content/… with a session; the
|
are only reachable through /api/houseplan/content/… with a session; the
|
||||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||||
stored URLs keep working (rewritten on read) [auto+manual]
|
stored URLs keep working (rewritten on read) [auto+manual]
|
||||||
|
- [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new
|
||||||
|
background, make the config write fail (a second tab saving first is
|
||||||
|
enough) — the previously stored plan is still served, with the same or a
|
||||||
|
different extension; after a successful save the old files are gone
|
||||||
|
[auto: smoke_plan_upload_reject + backend test_plan_upload_does_not_touch_the_previous_file]
|
||||||
|
- [ ] Signature cache on a wall tablet (v1.45.0, review R2-2): with more than
|
||||||
|
200 signed urls every one of them is refreshed (batched), entries for
|
||||||
|
files no longer in the config are dropped, an expired signature is never
|
||||||
|
served and an aging one keeps working while its replacement arrives
|
||||||
|
[auto: smoke_sign_cap]
|
||||||
|
- [ ] Climate cost does not grow with rooms (v1.45.0, review R2-3): on a plan
|
||||||
|
with dozens of rooms an unrelated HA state update triggers ONE registry
|
||||||
|
pass, repeated renders on the same snapshot trigger none, and a changed
|
||||||
|
sensor value is still visible immediately [auto: smoke_climate_once]
|
||||||
- [ ] Plan upload survives a concurrent config revision (v1.44.8): with a second
|
- [ ] Plan upload survives a concurrent config revision (v1.44.8): with a second
|
||||||
tab open on the same plan, attach a background image in space settings —
|
tab open on the same plan, attach a background image in space settings —
|
||||||
the plan shows immediately, `plan_url` is in `.storage/houseplan.config`,
|
the plan shows immediately, `plan_url` is in `.storage/houseplan.config`,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.44.8",
|
"version": "1.45.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",
|
||||||
|
|||||||
+54
-21
@@ -425,15 +425,27 @@ const NON_AIR_RE = new RegExp(
|
|||||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||||
* a device measures.
|
* a device measures.
|
||||||
*/
|
*/
|
||||||
export function areaClimate(
|
export interface AreaClimate { temp: number | null; hum: number | null }
|
||||||
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
|
||||||
): number | null {
|
/**
|
||||||
if (!area || !hass?.entities) return null;
|
* Climate for EVERY area in one registry pass (review R2-3).
|
||||||
const groups = new Map<string, { name: string; model?: string; ents: string[] }>();
|
*
|
||||||
|
* The per-area version below rescanned the whole registry for each room and
|
||||||
|
* each measurement: with 60 rooms and 2000 entities that is 120 traversals per
|
||||||
|
* render — an entire frame spent re-reading metadata that did not change. The
|
||||||
|
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
|
||||||
|
*/
|
||||||
|
export function areaClimateMap(
|
||||||
|
hass: any, rules?: CompiledIconRule[],
|
||||||
|
): Map<string, AreaClimate> {
|
||||||
|
const out = new Map<string, AreaClimate>();
|
||||||
|
if (!hass?.entities) return out;
|
||||||
|
// area -> device (or lone entity) -> the entities that belong to it
|
||||||
|
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
|
||||||
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
|
||||||
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
||||||
const entArea = reg.area_id || dev?.area_id || null;
|
const area = reg.area_id || dev?.area_id || null;
|
||||||
if (entArea !== area) continue;
|
if (!area) continue;
|
||||||
// Not every "temperature" is room air. Real finds on a live install: the
|
// Not every "temperature" is room air. Real finds on a live install: the
|
||||||
// NAS processor temperature, the water in a smart kettle, a sauna heater at
|
// NAS processor temperature, the water in a smart kettle, a sauna heater at
|
||||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||||
@@ -441,30 +453,51 @@ export function areaClimate(
|
|||||||
if (reg.entity_category) continue; // diagnostic/config readings
|
if (reg.entity_category) continue; // diagnostic/config readings
|
||||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
||||||
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
|
||||||
|
let groups = byArea.get(area);
|
||||||
|
if (!groups) { groups = new Map(); byArea.set(area, groups); }
|
||||||
const key = reg.device_id || eid;
|
const key = reg.device_id || eid;
|
||||||
if (!groups.has(key)) {
|
let g = groups.get(key);
|
||||||
|
if (!g) {
|
||||||
const st = hass.states?.[eid];
|
const st = hass.states?.[eid];
|
||||||
groups.set(key, {
|
g = {
|
||||||
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
||||||
model: dev?.model,
|
model: dev?.model,
|
||||||
ents: [],
|
ents: [],
|
||||||
});
|
};
|
||||||
|
groups.set(key, g);
|
||||||
}
|
}
|
||||||
groups.get(key)!.ents.push(eid);
|
g.ents.push(eid);
|
||||||
}
|
}
|
||||||
const vals: number[] = [];
|
for (const [area, groups] of byArea) {
|
||||||
|
const temps: number[] = [];
|
||||||
|
const hums: number[] = [];
|
||||||
for (const g of groups.values()) {
|
for (const g of groups.values()) {
|
||||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||||
const ok = kind === 'temp'
|
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
|
||||||
? icon === 'mdi:thermometer' || icon === 'mdi:air-filter'
|
if (air) {
|
||||||
: icon === 'mdi:thermometer' || icon === 'mdi:air-filter' || icon === 'mdi:water-percent';
|
const t = tempFor(hass, g.ents);
|
||||||
if (!ok) continue;
|
if (t != null) temps.push(t);
|
||||||
const v = kind === 'temp' ? tempFor(hass, g.ents) : humFor(hass, g.ents);
|
|
||||||
if (v != null) vals.push(v);
|
|
||||||
}
|
}
|
||||||
if (!vals.length) return null;
|
if (air || icon === 'mdi:water-percent') {
|
||||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
const h = humFor(hass, g.ents);
|
||||||
return kind === 'temp' ? Math.round(avg * 10) / 10 : Math.round(avg);
|
if (h != null) hums.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!temps.length && !hums.length) continue;
|
||||||
|
out.set(area, {
|
||||||
|
temp: temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null,
|
||||||
|
hum: hums.length ? Math.round(hums.reduce((a, b) => a + b, 0) / hums.length) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One area's reading. Convenience wrapper — prefer the map for many areas. */
|
||||||
|
export function areaClimate(
|
||||||
|
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
|
||||||
|
): number | null {
|
||||||
|
if (!area) return null;
|
||||||
|
return areaClimateMap(hass, rules).get(area)?.[kind] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
/** How many of the area's lights are on: {on, total}, or null without lights. */
|
||||||
|
|||||||
+86
-26
@@ -21,8 +21,9 @@ import {
|
|||||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||||
|
MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS, chunk, referencedContentUrls,
|
||||||
} from './logic';
|
} from './logic';
|
||||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimate } from './devices';
|
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
|
||||||
import type {
|
import type {
|
||||||
OpeningCfg,
|
OpeningCfg,
|
||||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||||
@@ -32,7 +33,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.44.8';
|
const CARD_VERSION = '1.45.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';
|
||||||
@@ -788,15 +789,25 @@ class HouseplanCard extends LitElement {
|
|||||||
* Bearer header or an `authSig` signed path, and an element sends neither.
|
* Bearer header or an `authSig` signed path, and an element sends neither.
|
||||||
* So the card asks the backend to sign what it is about to display.
|
* So the card asks the backend to sign what it is about to display.
|
||||||
*/
|
*/
|
||||||
private _signed: Record<string, string> = {};
|
private _signed: Record<string, { url: string; at: number }> = {};
|
||||||
private _signPending = new Set<string>();
|
private _signPending = new Set<string>();
|
||||||
private _signTimer?: number;
|
private _signTimer?: number;
|
||||||
|
|
||||||
/** Display url: the signed variant when we have one, else the plain path. */
|
/** Display url: a signature we hold and still trust, else nothing. */
|
||||||
private _display(url: string | null | undefined): string {
|
private _display(url: string | null | undefined): string {
|
||||||
const u = contentUrl(url);
|
const u = contentUrl(url);
|
||||||
if (!u.startsWith('/api/houseplan/content/')) return u;
|
if (!u.startsWith('/api/houseplan/content/')) return u;
|
||||||
if (this._signed[u]) return this._signed[u];
|
const hit = this._signed[u];
|
||||||
|
const age = hit ? Date.now() - hit.at : Infinity;
|
||||||
|
// Past its lifetime the signature is worthless: serving it would 401 and
|
||||||
|
// raise a failed-login warning, exactly what an unsigned path does.
|
||||||
|
if (age >= SIGN_TTL_MS) delete this._signed[u];
|
||||||
|
else if (age < SIGN_REFRESH_MS) return hit.url;
|
||||||
|
else {
|
||||||
|
// aging but still valid: keep showing it while a fresh one is fetched
|
||||||
|
this._requestSignature(u);
|
||||||
|
return hit.url;
|
||||||
|
}
|
||||||
this._requestSignature(u);
|
this._requestSignature(u);
|
||||||
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
|
||||||
// returns 401 and Home Assistant raises a "failed login attempt" for the
|
// returns 401 and Home Assistant raises a "failed login attempt" for the
|
||||||
@@ -812,35 +823,50 @@ class HouseplanCard extends LitElement {
|
|||||||
this._signTimer = window.setTimeout(() => {
|
this._signTimer = window.setTimeout(() => {
|
||||||
const paths = [...this._signPending];
|
const paths = [...this._signPending];
|
||||||
this._signPending.clear();
|
this._signPending.clear();
|
||||||
this.hass
|
this._signBatches(paths);
|
||||||
.callWS({ type: 'houseplan/content/sign', paths })
|
|
||||||
.then((r: any) => {
|
|
||||||
if (!r?.urls) return;
|
|
||||||
this._signed = { ...this._signed, ...r.urls };
|
|
||||||
this.requestUpdate();
|
|
||||||
})
|
|
||||||
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
|
||||||
}, 30);
|
}, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-sign everything periodically: a wall tablet outlives a signature.
|
* Sign in batches the backend will actually answer in full. It caps a request
|
||||||
|
* at MAX_SIGN_PATHS and drops the rest without saying so, so one oversized
|
||||||
|
* call leaves entries that never get refreshed and silently expire (R2-2).
|
||||||
|
*/
|
||||||
|
private _signBatches(paths: string[]): void {
|
||||||
|
if (!paths.length || !this.hass?.callWS) return;
|
||||||
|
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||||
|
this.hass
|
||||||
|
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!r?.urls) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const next = { ...this._signed };
|
||||||
|
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at: now };
|
||||||
|
this._signed = next;
|
||||||
|
this.requestUpdate();
|
||||||
|
})
|
||||||
|
.catch(() => undefined); // unsigned urls simply keep failing; no loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-sign periodically: a wall tablet outlives a signature.
|
||||||
* The old urls are kept until the new ones arrive — dropping them first would
|
* The old urls are kept until the new ones arrive — dropping them first would
|
||||||
* blank the plan for a round trip (and, if the socket is down, until it heals).
|
* blank the plan for a round trip (and, if the socket is down, until it heals).
|
||||||
*/
|
*/
|
||||||
private _resignTimer?: number;
|
private _resignTimer?: number;
|
||||||
|
|
||||||
private _resign(): void {
|
private _resign(): void {
|
||||||
const paths = Object.keys(this._signed);
|
// prune first: an entry for a plan that was replaced months ago must not
|
||||||
if (!paths.length || !this.hass?.callWS) return;
|
// consume a slot in the (capped) signing request
|
||||||
this.hass
|
const live = referencedContentUrls(this._serverCfg);
|
||||||
.callWS({ type: 'houseplan/content/sign', paths })
|
const now = Date.now();
|
||||||
.then((r: any) => {
|
const kept: Record<string, { url: string; at: number }> = {};
|
||||||
if (!r?.urls) return;
|
for (const [k, v] of Object.entries(this._signed)) {
|
||||||
this._signed = { ...this._signed, ...r.urls };
|
if (live.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||||
this.requestUpdate();
|
}
|
||||||
})
|
this._signed = kept;
|
||||||
.catch(() => undefined);
|
this._signBatches(Object.keys(kept));
|
||||||
}
|
}
|
||||||
private _dirtyPos = new Set<string>();
|
private _dirtyPos = new Set<string>();
|
||||||
|
|
||||||
@@ -3032,6 +3058,10 @@ class HouseplanCard extends LitElement {
|
|||||||
};
|
};
|
||||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||||
await this._saveConfigNow();
|
await this._saveConfigNow();
|
||||||
|
// Only now is the new file authoritative: the config write was accepted,
|
||||||
|
// so the previous plan can go. Before this point nothing on disk was
|
||||||
|
// touched, which is what makes a rejected save harmless (review R2-1).
|
||||||
|
if (uploaded) this._cleanupPlanFiles(spaceId, uploaded.url);
|
||||||
this._spaceDialog = null;
|
this._spaceDialog = null;
|
||||||
if (d.mode === 'create') this._space = sp.id;
|
if (d.mode === 'create') this._space = sp.id;
|
||||||
this._regSignature = '';
|
this._regSignature = '';
|
||||||
@@ -3108,6 +3138,19 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove plan files superseded by `keepUrl`. Fire-and-forget on purpose: the
|
||||||
|
* user's edit is already saved, and a failed cleanup only leaves a stray file
|
||||||
|
* that the next successful upload collects (review R2-1).
|
||||||
|
*/
|
||||||
|
private _cleanupPlanFiles(spaceId: string, keepUrl: string): void {
|
||||||
|
const keep = keepUrl.split('?')[0].split('/').pop();
|
||||||
|
if (!keep || !this.hass?.callWS) return;
|
||||||
|
this.hass
|
||||||
|
.callWS({ type: 'houseplan/plan/cleanup', space_id: spaceId, keep })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
// ================= FLOORS IMPORT WIZARD =================
|
// ================= FLOORS IMPORT WIZARD =================
|
||||||
|
|
||||||
private _startImport(): void {
|
private _startImport(): void {
|
||||||
@@ -3881,14 +3924,31 @@ class HouseplanCard extends LitElement {
|
|||||||
const src = r.settings?.temp_source;
|
const src = r.settings?.temp_source;
|
||||||
if (src) return sourceValue(this.hass, src, 'temp');
|
if (src) return sourceValue(this.hass, src, 'temp');
|
||||||
// every sensor of the area, placed on the plan or not (field report)
|
// every sensor of the area, placed on the plan or not (field report)
|
||||||
return r.area ? areaClimate(this.hass, r.area, 'temp', this._iconRules) : null;
|
return r.area ? this._climate().get(r.area)?.temp ?? null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Room humidity honouring the tier-3 source override. */
|
/** Room humidity honouring the tier-3 source override. */
|
||||||
private _roomHum(r: RoomCfg): number | null {
|
private _roomHum(r: RoomCfg): number | null {
|
||||||
const src = r.settings?.hum_source;
|
const src = r.settings?.hum_source;
|
||||||
if (src) return sourceValue(this.hass, src, 'hum');
|
if (src) return sourceValue(this.hass, src, 'hum');
|
||||||
return r.area ? areaClimate(this.hass, r.area, 'hum', this._iconRules) : null;
|
return r.area ? this._climate().get(r.area)?.hum ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _climateCache: { h: any; r: any; m: Map<string, AreaClimate> } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Climate for every area, computed ONCE per hass snapshot (review R2-3).
|
||||||
|
* Home Assistant hands out a new `hass` object on every state change, so
|
||||||
|
* identity is exactly the right cache key: fresh states always recompute,
|
||||||
|
* and the 60 rooms of one render share a single registry pass instead of
|
||||||
|
* triggering one each (two, with humidity on).
|
||||||
|
*/
|
||||||
|
private _climate(): Map<string, AreaClimate> {
|
||||||
|
const c = this._climateCache;
|
||||||
|
if (c && c.h === this.hass && c.r === this._iconRules) return c.m;
|
||||||
|
const m = areaClimateMap(this.hass, this._iconRules);
|
||||||
|
this._climateCache = { h: this.hass, r: this._iconRules, m };
|
||||||
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _resetRoomDialogFields(): void {
|
private _resetRoomDialogFields(): void {
|
||||||
|
|||||||
@@ -1036,6 +1036,48 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
|||||||
* authenticated content endpoint (audit B1). Applied on READ, so stored
|
* authenticated content endpoint (audit B1). Applied on READ, so stored
|
||||||
* configs keep working without a migration.
|
* configs keep working without a migration.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* How many paths one `houseplan/content/sign` call may carry. The backend caps
|
||||||
|
* the request at the same number and silently ignores the rest, so a client
|
||||||
|
* that sends more gets a partial answer with no way to tell which paths were
|
||||||
|
* dropped — on a wall tablet those entries then expire for good (review R2-2).
|
||||||
|
* Keep in sync with MAX_SIGN_PATHS in custom_components/houseplan/const.py.
|
||||||
|
*/
|
||||||
|
export const MAX_SIGN_PATHS = 200;
|
||||||
|
|
||||||
|
/** A signature is valid for 24 h; refresh once two thirds of it is gone. */
|
||||||
|
export const SIGN_TTL_MS = 24 * 3600 * 1000;
|
||||||
|
export const SIGN_REFRESH_MS = 16 * 3600 * 1000;
|
||||||
|
|
||||||
|
/** Split a list into chunks of at most `size` (used for signing batches). */
|
||||||
|
export function chunk<T>(items: T[], size: number): T[][] {
|
||||||
|
const n = Math.max(1, Math.floor(size));
|
||||||
|
const out: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += n) out.push(items.slice(i, i + n));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every content url the given config still refers to, normalised through
|
||||||
|
* `contentUrl`. The signature cache is pruned to this set: without it the cache
|
||||||
|
* only grows — replaced plans and deleted attachments keep their entries, and
|
||||||
|
* the total can cross the per-request cap even when the live config is small.
|
||||||
|
*/
|
||||||
|
export function referencedContentUrls(cfg: any): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
const add = (u: unknown) => {
|
||||||
|
if (typeof u !== 'string' || !u) return;
|
||||||
|
const c = contentUrl(u);
|
||||||
|
if (c.startsWith('/api/houseplan/content/')) out.add(c);
|
||||||
|
};
|
||||||
|
for (const sp of cfg?.spaces || []) {
|
||||||
|
add(sp?.plan_url);
|
||||||
|
for (const m of sp?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||||
|
}
|
||||||
|
for (const m of cfg?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function contentUrl(url: string | null | undefined): string {
|
export function contentUrl(url: string | null | undefined): string {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
if (url.startsWith('/houseplan_files/plans/')) {
|
if (url.startsWith('/houseplan_files/plans/')) {
|
||||||
|
|||||||
+48
-1
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate } from '../test-build/devices.js';
|
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue , areaClimate, areaClimateMap } from '../test-build/devices.js';
|
||||||
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
import { compileIconRules, iconFor } from '../test-build/rules.js';
|
||||||
|
|
||||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||||
@@ -436,3 +436,50 @@ test('areaClimate: only ROOM AIR counts (field question, 2026-07-27)', () => {
|
|||||||
// остаётся ровно один настоящий датчик воздуха
|
// остаётся ровно один настоящий датчик воздуха
|
||||||
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
|
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('areaClimateMap: one registry pass for all areas (review R2-3)', () => {
|
||||||
|
// 60 зон × 2000 сущностей — размер боевой установки из отчёта
|
||||||
|
const devices = {}; const entities = {}; const states = {};
|
||||||
|
for (let a = 0; a < 60; a++) {
|
||||||
|
for (let i = 0; i < 33; i++) {
|
||||||
|
const dev = `d${a}_${i}`;
|
||||||
|
const eid = `sensor.d${a}_${i}_temperature`;
|
||||||
|
devices[dev] = { id: dev, name: `Датчик температуры ${a}.${i}`, area_id: `area${a}` };
|
||||||
|
entities[eid] = { device_id: dev, platform: 'mqtt' };
|
||||||
|
states[eid] = { state: String(20 + a * 0.1), attributes: { device_class: 'temperature', unit_of_measurement: '°C' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// считаем ОБХОДЫ реестра: Object.entries дёргает ownKeys ровно один раз
|
||||||
|
let scans = 0;
|
||||||
|
const traced = new Proxy(entities, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||||
|
const hass = { devices, states, entities: traced };
|
||||||
|
|
||||||
|
|
||||||
|
const map = areaClimateMap(hass);
|
||||||
|
assert.equal(scans, 1, 'реестр обходится один раз на снимок hass');
|
||||||
|
assert.equal(map.size, 60);
|
||||||
|
assert.equal(map.get('area0').temp, 20);
|
||||||
|
assert.equal(map.get('area59').temp, 25.9);
|
||||||
|
assert.equal(map.get('area0').hum, null);
|
||||||
|
assert.equal(map.get('nope'), undefined);
|
||||||
|
|
||||||
|
// старый путь: отдельный обход на каждую комнату и каждую величину
|
||||||
|
scans = 0;
|
||||||
|
for (let a = 0; a < 60; a++) { areaClimate(hass, `area${a}`, 'temp'); areaClimate(hass, `area${a}`, 'hum'); }
|
||||||
|
assert.equal(scans, 120, 'wrapper считает по одной зоне — им нельзя пользоваться в рендере');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('areaClimateMap: температура и влажность живут в одной записи', () => {
|
||||||
|
const hass = {
|
||||||
|
devices: { q: { id: 'q', name: 'Qingping Air Monitor', area_id: 'hall' } },
|
||||||
|
entities: {
|
||||||
|
'sensor.q_t': { device_id: 'q', platform: 'xiaomi' },
|
||||||
|
'sensor.q_h': { device_id: 'q', platform: 'xiaomi' },
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
'sensor.q_t': { state: '21.4', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||||
|
'sensor.q_h': { state: '48', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.deepEqual(areaClimateMap(hass).get('hall'), { temp: 21.4, hum: 48 });
|
||||||
|
});
|
||||||
|
|||||||
+28
-1
@@ -12,7 +12,7 @@ import {
|
|||||||
swipeTarget, clampScale,
|
swipeTarget, clampScale,
|
||||||
migratePdfUrls,
|
migratePdfUrls,
|
||||||
roomFillModeOf,
|
roomFillModeOf,
|
||||||
contentUrl,
|
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||||
interiorPoint,
|
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,
|
||||||
@@ -881,3 +881,30 @@ test('segKey: one wall, one key at any precision (audit G3)', () => {
|
|||||||
// разные стены — разные ключи
|
// разные стены — разные ключи
|
||||||
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
|
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('chunk / referencedContentUrls: signing batches and cache pruning (review R2-2)', () => {
|
||||||
|
assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]);
|
||||||
|
assert.deepEqual(chunk([], 200), []);
|
||||||
|
assert.equal(chunk(new Array(201).fill(0), MAX_SIGN_PATHS).length, 2);
|
||||||
|
assert.deepEqual(chunk([1, 2, 3], 0), [[1], [2], [3]]); // never an infinite loop
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
spaces: [
|
||||||
|
{ id: 'f1', plan_url: '/houseplan_files/plans/f1.svg' }, // legacy, rewritten on read
|
||||||
|
{ id: 'f2', plan_url: '/api/houseplan/content/plans/_/f2.abc.png' },
|
||||||
|
{ id: 'f3', plan_url: null },
|
||||||
|
{ id: 'f4', plan_url: '/local/not-ours.png' }, // not our endpoint
|
||||||
|
],
|
||||||
|
markers: [
|
||||||
|
{ id: 'm1', pdfs: [{ url: '/houseplan_files/files/m1/manual.pdf' }, { url: '' }] },
|
||||||
|
{ id: 'm2' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual([...referencedContentUrls(cfg)].sort(), [
|
||||||
|
'/api/houseplan/content/files/m1/manual.pdf',
|
||||||
|
'/api/houseplan/content/plans/_/f1.svg',
|
||||||
|
'/api/houseplan/content/plans/_/f2.abc.png',
|
||||||
|
]);
|
||||||
|
assert.equal(referencedContentUrls(null).size, 0);
|
||||||
|
assert.equal(referencedContentUrls({}).size, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,7 +99,103 @@ 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("/api/houseplan/content/plans/_/s1.png?v=")
|
url = resp["result"]["url"]
|
||||||
|
assert resp["success"]
|
||||||
|
# versioned name: "<space>.<token>.<ext>" (review R2-1)
|
||||||
|
name = url.rsplit("/", 1)[-1]
|
||||||
|
assert name.startswith("s1.") and name.endswith(".png") and len(name.split(".")) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plan_upload_does_not_touch_the_previous_file(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""review R2-1: a rejected config write must leave the stored plan intact.
|
||||||
|
|
||||||
|
The upload used to overwrite "<space>.<ext>" (and unlink the other
|
||||||
|
extension) BEFORE the revision-checked config write, so a conflict left the
|
||||||
|
live plan replaced — or, with a new extension, pointing at a deleted file.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import PLANS_DIR
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
plans = Path(hass.config.path(PLANS_DIR))
|
||||||
|
plans.mkdir(parents=True, exist_ok=True)
|
||||||
|
legacy = plans / "s1.svg" # what an older version stored, still referenced
|
||||||
|
legacy.write_bytes(b"<svg>old</svg>")
|
||||||
|
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
||||||
|
)
|
||||||
|
first = (await client.receive_json())["result"]["url"].rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
# pretend the config write was rejected: no cleanup call follows
|
||||||
|
assert legacy.read_bytes() == b"<svg>old</svg>"
|
||||||
|
assert (plans / first).is_file()
|
||||||
|
|
||||||
|
# a second attempt neither overwrites the first nor the legacy file
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "d29ybGQ="}
|
||||||
|
)
|
||||||
|
second = (await client.receive_json())["result"]["url"].rsplit("/", 1)[-1]
|
||||||
|
assert second != first
|
||||||
|
assert (plans / first).read_bytes() == b"hello"
|
||||||
|
assert (plans / second).read_bytes() == b"world"
|
||||||
|
assert legacy.is_file()
|
||||||
|
|
||||||
|
# config accepted → cleanup keeps exactly the referenced file
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/plan/cleanup", "space_id": "s1", "keep": second}
|
||||||
|
)
|
||||||
|
resp = await client.receive_json()
|
||||||
|
assert resp["success"] and resp["result"]["removed"] == 2
|
||||||
|
assert (plans / second).is_file()
|
||||||
|
assert not legacy.exists() and not (plans / first).exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plan_cleanup_never_reaches_another_space(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
"""A space id cannot contain '.', so "<space>.<token>.<ext>" is unambiguous.
|
||||||
|
|
||||||
|
With '-' as the separator, cleaning "f1" would have eaten the files of a
|
||||||
|
space called "f1-attic".
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from custom_components.houseplan.const import PLANS_DIR
|
||||||
|
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
plans = Path(hass.config.path(PLANS_DIR))
|
||||||
|
plans.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name in ("f1.aaaa1111.png", "f1.bbbb2222.png", "f1-attic.cccc3333.png", "f1-attic.png", "notes.txt"):
|
||||||
|
(plans / name).write_bytes(b"x")
|
||||||
|
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/plan/cleanup", "space_id": "f1", "keep": "f1.bbbb2222.png"}
|
||||||
|
)
|
||||||
|
resp = await client.receive_json()
|
||||||
|
assert resp["success"] and resp["result"]["removed"] == 1
|
||||||
|
assert not (plans / "f1.aaaa1111.png").exists()
|
||||||
|
assert (plans / "f1.bbbb2222.png").is_file()
|
||||||
|
assert (plans / "f1-attic.cccc3333.png").is_file()
|
||||||
|
assert (plans / "f1-attic.png").is_file()
|
||||||
|
assert (plans / "notes.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plan_cleanup_rejects_a_bad_space_id(
|
||||||
|
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||||
|
) -> None:
|
||||||
|
await _setup(hass)
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "houseplan/plan/cleanup", "space_id": "../evil", "keep": "x.png"}
|
||||||
|
)
|
||||||
|
resp = await client.receive_json()
|
||||||
|
assert not resp["success"] and resp["error"]["code"] == "invalid_space_id"
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_check_fails_closed(hass, hass_ws_client):
|
async def test_admin_check_fails_closed(hass, hass_ws_client):
|
||||||
|
|||||||
Reference in New Issue
Block a user