mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15e5dd7392 | ||
|
|
f1b501a956 | ||
|
|
5d2dbb1009 | ||
|
|
40cb0302e3 | ||
|
|
14cc4df4bd | ||
|
|
ead56dd9b6 | ||
|
|
018b37940f | ||
|
|
ebeaa5c0c6 | ||
|
|
02ba18dc7b | ||
|
|
715a93ec61 | ||
|
|
09b0ba41a5 | ||
|
|
0467cee98a | ||
|
|
c0653dfc73 |
@@ -3,6 +3,12 @@
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## Changelog
|
||||
|
||||
User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
|
||||
@@ -268,6 +268,8 @@ turned the way it is mounted.
|
||||
reports and feature requests (please attach your House Plan version).
|
||||
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
|
||||
longer-form ideas.
|
||||
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
|
||||
([на русском](docs/CHANGELOG.ru.md)).
|
||||
|
||||
When reporting a problem, the version number helps a lot: it is shown in the
|
||||
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
|
||||
|
||||
@@ -271,6 +271,7 @@ title: План дома
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Single source of truth for the write-authorization policy.
|
||||
|
||||
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||
one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
from .store import get_entry
|
||||
|
||||
|
||||
def may_write(hass: HomeAssistant, user) -> bool:
|
||||
"""True when `user` may modify House Plan data.
|
||||
|
||||
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||
same as "permissive": only admins are allowed through.
|
||||
"""
|
||||
is_admin = bool(getattr(user, "is_admin", False))
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return is_admin if admin_only else True
|
||||
@@ -11,9 +11,14 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||
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"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.44.2"
|
||||
VERSION = "1.45.0"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ except ImportError: # older HA versions
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .store import get_entry
|
||||
from .auth import may_write
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -95,12 +95,8 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if admin_only:
|
||||
user = request.get("hass_user")
|
||||
if user is None or not user.is_admin:
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.44.2"
|
||||
"version": "1.45.0"
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ POS_SCHEMA = vol.Schema(
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
POINT = vol.All([_finite], vol.Length(min=2, max=2))
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
@@ -102,10 +102,10 @@ ROOM_SCHEMA = vol.All(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
vol.Optional("x"): _finite,
|
||||
vol.Optional("y"): _finite,
|
||||
vol.Optional("w"): _finite,
|
||||
vol.Optional("h"): _finite,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -161,17 +161,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("openings"): [
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("x"): _finite,
|
||||
vol.Required("y"): _finite,
|
||||
vol.Required("angle"): _finite,
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
@@ -181,7 +181,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
],
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,12 +16,13 @@ from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
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 .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,8 +39,10 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
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_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_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -55,18 +59,8 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
||||
|
||||
|
||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
"""May this connection write?
|
||||
|
||||
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
|
||||
while the integration is disabled — the policy is unknown, and "unknown" is
|
||||
not the same as "permissive". Previously this returned True and ws_plan_set,
|
||||
which never touches the runtime helper, accepted uploads in that window.
|
||||
"""
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return bool(getattr(connection.user, "is_admin", False))
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
return connection.user.is_admin if admin_only else True
|
||||
"""May this connection write? Thin wrapper over the shared policy."""
|
||||
return may_write(hass, getattr(connection, "user", None))
|
||||
|
||||
|
||||
# ---------------- layout ----------------
|
||||
@@ -210,6 +204,46 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/content/sign",
|
||||
vol.Required("paths"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Sign content paths so the BROWSER can fetch them.
|
||||
|
||||
Home Assistant authenticates HTTP requests by a Bearer header or an
|
||||
`authSig` signed path — there is no cookie auth. An <image href> inside SVG
|
||||
and a plain <a href> can send neither, so after the content endpoint became
|
||||
`requires_auth` the plan backgrounds and PDF links returned 401 (audit
|
||||
follow-up B1 regression, 2026-07-27 — reproduced live).
|
||||
|
||||
The card asks for signatures and uses the signed urls for display.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
|
||||
out: dict[str, str] = {}
|
||||
token_id = getattr(connection, "refresh_token_id", None)
|
||||
for path in msg["paths"][:MAX_SIGN_PATHS]:
|
||||
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
|
||||
continue # only ever sign our own content endpoint
|
||||
clean = path.split("?", 1)[0]
|
||||
try:
|
||||
try:
|
||||
signed = async_sign_path(hass, clean, timedelta(hours=24), refresh_token_id=token_id)
|
||||
except TypeError: # older HA signature: (hass, refresh_token_id, path, expiration)
|
||||
signed = async_sign_path(hass, token_id, clean, timedelta(hours=24))
|
||||
except Exception as err: # noqa: BLE001 — signing must never break the card
|
||||
_LOGGER.warning("House Plan: could not sign %s: %s", clean, err)
|
||||
continue
|
||||
out[path] = signed
|
||||
connection.send_result(msg["id"], {"urls": out})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/cleanup",
|
||||
@@ -368,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")
|
||||
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))
|
||||
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)
|
||||
# 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)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
connection.send_result(
|
||||
msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{space_id}.{msg['ext']}?v={mtime}"}
|
||||
)
|
||||
await hass.async_add_executor_job(_write)
|
||||
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
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,76 @@
|
||||
// Подложка (фон плана) лежит за requires_auth-эндпоинтом: браузер не умеет
|
||||
// авторизовать <image href>, поэтому карточка просит бэкенд подписать путь.
|
||||
// Регрессия 2026-07-27: _display() вызывался внутри _buildModel(), а модель
|
||||
// мемоизируется по отпечатку конфига — неподписанный url «замерзал» в кэше,
|
||||
// подпись до <image> не доезжала. План не отображался никогда, а браузер
|
||||
// продолжал дёргать неподписанный путь → 401 → HA писал «неудачный вход»
|
||||
// с собственного IP пользователя.
|
||||
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;
|
||||
const bgHref = () => {
|
||||
const im = sr().querySelector('.stage svg image');
|
||||
return im ? im.getAttribute('href') : null;
|
||||
};
|
||||
|
||||
let signCalls = 0;
|
||||
let release;
|
||||
const gate = new Promise((r) => { release = r; });
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
signCalls++;
|
||||
const n = signCalls;
|
||||
if (n === 1) await gate;
|
||||
const urls = {};
|
||||
for (const p of m.paths) urls[p] = p.split('?')[0] + '?authSig=SIG' + n;
|
||||
return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, plan_url: '/api/houseplan/content/plans/_/f1.svg?v=17831509',
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// до подписи ничего не рисуем: неподписанный запрос вернул бы 401
|
||||
out.hrefBeforeSign = bgHref();
|
||||
|
||||
release();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
await c.updateComplete;
|
||||
|
||||
// подпись доехала до атрибута, а не осела в кэше модели
|
||||
out.signRequested = signCalls;
|
||||
out.hrefSigned = bgHref();
|
||||
|
||||
// перерисовка по состоянию HA не теряет подпись и не просит её заново
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.hrefAfterRerender = bgHref();
|
||||
out.signRequestedAfterRerender = signCalls;
|
||||
|
||||
// ре-подпись на долгоживущем экране: старый url держится до нового ответа
|
||||
const before = bgHref();
|
||||
c._resign();
|
||||
out.resignKeepsPlan = bgHref() === before;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
out.hrefAfterResign = bgHref();
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.7 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefBeforeSign: null,
|
||||
signRequested: 1,
|
||||
hrefSigned: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
|
||||
hrefAfterRerender: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
|
||||
signRequestedAfterRerender: 1,
|
||||
resignKeepsPlan: true,
|
||||
hrefAfterResign: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG2',
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,70 @@
|
||||
// Загрузка подложки: ссылка обязана долететь до конфига.
|
||||
// Баг 2026-07-27 (найден на боевой установке): _saveSpaceDialog держал ссылку
|
||||
// на объект пространства через await загрузки файла. Любое событие
|
||||
// houseplan_config_updated в этот момент вызывает _reloadConfigOnly(), которое
|
||||
// ЗАМЕНЯЕТ _serverCfg — и plan_url/aspect/settings уезжали в осиротевший
|
||||
// объект, а на сервер уходил нетронутый конфиг. Симптом: файл на диске есть,
|
||||
// подложки нет, пересохранение не помогает.
|
||||
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 reloadDuringUpload = 0;
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plan/set') {
|
||||
// пока файл «загружается», прилетает чужая ревизия конфига
|
||||
reloadDuringUpload++;
|
||||
await c._reloadConfigOnly(true);
|
||||
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.png?v=42' };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { c.__sent = m.config; return { ok: true, rev: 99 }; }
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
// сервер отдаёт СВЕЖИЙ объект, а не тот же самый — как в реальном HA
|
||||
const r = await base(m);
|
||||
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
// редактирование существующего пространства: подложка + новый заголовок
|
||||
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;
|
||||
|
||||
out.reloadHappened = reloadDuringUpload === 1;
|
||||
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
||||
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
|
||||
out.sentPlanUrl = sentF1?.plan_url;
|
||||
out.sentAspect = sentF1?.aspect;
|
||||
out.sentTitle = sentF1?.title;
|
||||
out.livePlanUrl = liveF1?.plan_url;
|
||||
out.dialogClosed = c._spaceDialog === null;
|
||||
|
||||
// создание пространства при том же сбое: оно должно доехать целиком
|
||||
c._openSpaceDialog('create'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'file',
|
||||
planFile: { ext: 'png', b64: 'BBBB', aspect: 0.8 } };
|
||||
await c._saveSpaceDialog(); await c.updateComplete;
|
||||
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
|
||||
out.atticSaved = !!attic;
|
||||
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
|
||||
out.atticAspect = attic?.aspect;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.44.8 и сверено с кодом
|
||||
checkAll(res, {
|
||||
reloadHappened: true,
|
||||
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
sentAspect: 1.6,
|
||||
sentTitle: 'Ground',
|
||||
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
|
||||
dialogClosed: true,
|
||||
atticSaved: true,
|
||||
atticHasPlan: true,
|
||||
atticAspect: 0.8,
|
||||
});
|
||||
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';
|
||||
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
|
||||
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;
|
||||
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)
|
||||
checkAll(res, {
|
||||
"filledClass": 1,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+25
-25
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/config/get` | — | `{config, rev}` |
|
||||
| `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) |
|
||||
|
||||
**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`
|
||||
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
|
||||
|
||||
|
||||
@@ -1,5 +1,135 @@
|
||||
# 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
|
||||
- **An uploaded plan is actually attached to the space.** `_saveSpaceDialog`
|
||||
held a reference to the space object across the `await` that uploads the
|
||||
image. Every `houseplan_config_updated` event runs `_reloadConfigOnly()`,
|
||||
which *replaces* `_serverCfg` — so the reference became an orphan and
|
||||
`plan_url`, `aspect`, the title and all display settings were written into a
|
||||
detached object while the save shipped the untouched config. The file landed
|
||||
on disk, the plan never appeared, and re-saving could not help. Creating a
|
||||
space in that window lost the space entirely.
|
||||
The upload now happens *before* the config is touched, and nothing is held
|
||||
across an await.
|
||||
- **`_saveConfigNow` marks the write in flight** (`_cfgWriting`), like the
|
||||
debounced writer already did, so a remote revision arriving mid-save defers
|
||||
its reload instead of replacing the config underneath it (audit L2 extended
|
||||
to this path).
|
||||
- Regression test `demo/smoke_plan_upload_race.mjs` fails on v1.44.7 and passes
|
||||
here.
|
||||
|
||||
## v1.44.7 — 2026-07-27
|
||||
- **Plan backgrounds are visible again (regression from v1.44.5).** Since the
|
||||
content endpoint requires authentication, the card asks the backend to sign
|
||||
the plan's url — but the signing happened inside the *memoized* space model,
|
||||
which is cached on the config fingerprint. The unsigned url froze in that
|
||||
cache, so the signature never reached the `<image>` element and the plan never
|
||||
loaded. The url is now resolved at render time, outside the cache. (PDF links
|
||||
were unaffected — they already resolved at render time.)
|
||||
- **No more "failed login attempt" from your own IP.** While the plan was
|
||||
broken the browser kept requesting the unsigned path, which returns 401 and
|
||||
makes Home Assistant raise a login-attempt warning for the viewer's own
|
||||
address. The card now renders nothing until the signature is in hand, so an
|
||||
unsigned request is never made.
|
||||
- **Long-lived screens no longer blink.** The 12-hour re-signing used to drop
|
||||
every signature and wait for new ones; it now keeps the current urls until the
|
||||
replacements arrive, so a wall tablet never shows an empty plan.
|
||||
- Regression test `demo/smoke_plan_signed.mjs` fails on v1.44.6 and passes here.
|
||||
|
||||
## v1.44.6 — 2026-07-27
|
||||
- **Only room *air* counts as room climate.** After v1.44.5 started reading the
|
||||
area registry instead of the visible icons, every hidden temperature entity in
|
||||
the area became a candidate — including ones that measure something other than
|
||||
the air. Three guards now run before averaging: entities marked
|
||||
diagnostic/config are skipped, entities from curated-out integrations are
|
||||
skipped, and entity ids naming a non-air medium are skipped
|
||||
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
|
||||
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
|
||||
`oven`, `kettle`, `boiler`).
|
||||
On a live 60-area install this removed four real false positives: a NAS
|
||||
processor temperature, the water in a smart kettle, a 90 °C sauna heater and a
|
||||
virtual `better_thermostat` duplicating the real sensor.
|
||||
- **New icon rules:** kettles/thermopots get `mdi:kettle`, saunas
|
||||
(`sauna`, `harvia`, `парная`) get `mdi:hot-tub` — previously both fell through
|
||||
to the generic thermometer rule, which is also what made them count as room
|
||||
climate.
|
||||
|
||||
## v1.44.5 — 2026-07-27
|
||||
- **Room climate now counts every sensor in the area**, including devices that
|
||||
are not placed on the plan (hidden by curation or by you). Previously the
|
||||
average was taken over the visible icons only, so hiding a thermometer
|
||||
silently removed it from the room card, the tooltip and the temperature fill.
|
||||
Curation still applies (fridges, TRVs and chip-temperature plugs stay out),
|
||||
and an explicit per-room source still wins.
|
||||
- The room tooltip no longer says "open the area" — clicking a room stopped
|
||||
navigating in v1.40.1; the link icon on the room card does that.
|
||||
|
||||
|
||||
## v1.44.4 — 2026-07-27 (audit follow-up: B2, B5, L4)
|
||||
- **One authorization policy (B2).** The HTTP upload view still failed **open**
|
||||
when the config entry was unavailable while the WebSocket path failed closed —
|
||||
the two had drifted apart. Both now call the same `may_write` helper, which
|
||||
denies non-admins whenever the policy cannot be read.
|
||||
- **NaN/Infinity refused on every coordinate (B5).** The finite-number check
|
||||
guarded only layout positions; room rects, polygon vertices, `view_box` and
|
||||
opening coordinates accepted `"NaN"`, which serializes to `null` and corrupts
|
||||
the stored geometry permanently. The `MAX_OPENINGS` cap was defined but never
|
||||
wired in — the openings list was unbounded.
|
||||
- **Drag hardening (L4 sub-item).** The tolerant `setPointerCapture` wrapper is
|
||||
now used by every drag pipeline (device, label, resize), not just openings —
|
||||
an inactive pointer id could kill a drag outright. Decor shapes gained a
|
||||
bounds clamp: they can no longer be dragged far outside the plan and saved
|
||||
there.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (fix: plans and manuals load again)
|
||||
- **The authenticated content endpoint had no working browser path.** v1.43.0
|
||||
closed the security hole correctly, but Home Assistant authenticates HTTP
|
||||
requests by a Bearer header or an `authSig` signed path — and an SVG
|
||||
`<image href>` or a plain `<a href>` sends neither. Plan backgrounds and PDF
|
||||
links returned **401** on a real dashboard (reproduced live before the fix).
|
||||
The card now asks the backend to sign what it displays
|
||||
(`houseplan/content/sign`, 24 h, bound to the session's refresh token, only
|
||||
for our own endpoint), re-renders when signatures arrive, and refreshes them
|
||||
every 12 hours so wall tablets keep working. A backend test fetches a signed
|
||||
url **without** an Authorization header and asserts 200, and 401 without the
|
||||
signature.
|
||||
|
||||
|
||||
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
|
||||
|
||||
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
|
||||
|
||||
A second, adversarial review (of v1.44.0) produced three findings; all are
|
||||
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
# История изменений
|
||||
|
||||
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
|
||||
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
|
||||
>
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. 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
|
||||
- **Загруженная подложка действительно привязывается к пространству.**
|
||||
`_saveSpaceDialog` держал ссылку на объект пространства через `await`
|
||||
загрузки картинки. Любое событие `houseplan_config_updated` запускает
|
||||
`_reloadConfigOnly()`, а оно *заменяет* `_serverCfg` — ссылка становилась
|
||||
осиротевшей, и `plan_url`, `aspect`, заголовок и все настройки отображения
|
||||
писались в отсоединённый объект, тогда как на сервер уходил нетронутый
|
||||
конфиг. Файл попадал на диск, подложка не появлялась, пересохранение не
|
||||
помогало. Создание пространства в этот момент теряло пространство целиком.
|
||||
Теперь загрузка идёт *до* обращения к конфигу, и ни одна ссылка не живёт
|
||||
через await.
|
||||
- **`_saveConfigNow` помечает запись как выполняющуюся** (`_cfgWriting`) — так
|
||||
же, как отложенный писатель, — поэтому чужая ревизия, пришедшая посреди
|
||||
сохранения, откладывает перечитывание вместо подмены конфига (аудит L2,
|
||||
расширен на этот путь).
|
||||
- Регрессионный тест `demo/smoke_plan_upload_race.mjs` падает на v1.44.7 и
|
||||
проходит здесь.
|
||||
|
||||
## v1.44.7 — 2026-07-27
|
||||
- **Подложки снова отображаются (регрессия с v1.44.5).** Эндпоинт с файлами
|
||||
требует авторизации, поэтому карточка просит бэкенд подписать ссылку на план —
|
||||
но подпись подставлялась внутри *мемоизированной* модели пространства, а она
|
||||
кэшируется по отпечатку конфига. Неподписанная ссылка «замерзала» в кэше,
|
||||
подпись до элемента `<image>` не доезжала, и план не грузился никогда. Теперь
|
||||
ссылка вычисляется в момент отрисовки, вне кэша. (Ссылки на PDF не страдали —
|
||||
там она и так вычислялась при отрисовке.)
|
||||
- **Больше нет «неудачной попытки входа» с собственного IP.** Пока подложка была
|
||||
сломана, браузер продолжал дёргать неподписанный путь, тот отвечал 401, и
|
||||
Home Assistant поднимал предупреждение о неудачном входе с адреса самого
|
||||
зрителя. Теперь до получения подписи не рисуется ничего, и неподписанный
|
||||
запрос не уходит вовсе.
|
||||
- **Долгоживущие экраны не моргают.** Переподписывание раз в 12 часов раньше
|
||||
сбрасывало все подписи и ждало новые; теперь текущие ссылки держатся до
|
||||
прихода замены, так что настенный планшет не показывает пустой план.
|
||||
- Регрессионный тест `demo/smoke_plan_signed.mjs` падает на v1.44.6 и проходит
|
||||
здесь.
|
||||
|
||||
## v1.44.6 — 2026-07-27
|
||||
- **Климатом комнаты считается только температура *воздуха*.** После v1.44.5,
|
||||
когда данные стали браться из реестра зон, а не с видимых значков,
|
||||
кандидатами стали все скрытые датчики температуры в зоне — в том числе те,
|
||||
что меряют вовсе не воздух. Перед усреднением теперь работают три фильтра:
|
||||
пропускаются сущности с категорией диагностика/настройка, сущности
|
||||
исключённых интеграций и сущности, в id которых назван не-воздушный носитель
|
||||
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
|
||||
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
|
||||
`oven`, `kettle`, `boiler`).
|
||||
На живой установке с 60 зонами это убрало четыре реальных ложных
|
||||
срабатывания: температуру процессора NAS, воду в умном чайнике, сауну с 90 °C
|
||||
и виртуальный `better_thermostat`, дублирующий настоящий датчик.
|
||||
- **Новые правила иконок:** чайники и термопоты получают `mdi:kettle`, сауны
|
||||
(`sauna`, `harvia`, `парная`) — `mdi:hot-tub`. Раньше и те и другие попадали
|
||||
под общее правило термометра, из-за чего и учитывались в климате комнаты.
|
||||
|
||||
## v1.44.5 — 2026-07-27
|
||||
- **Климат комнаты считается по всем датчикам зоны**, включая устройства,
|
||||
которых нет на плане (скрыты курированием или вами). Раньше среднее бралось
|
||||
только по видимым значкам, поэтому скрытый термометр молча выпадал из
|
||||
карточки комнаты, подсказки и температурной заливки. Курирование сохранено
|
||||
(холодильники, термоголовки и розетки с температурой чипа не считаются), а
|
||||
явно выбранный источник в настройках комнаты по-прежнему главнее.
|
||||
- Из подсказки к комнате убрана фраза «открыть зону» — клик по комнате перестал
|
||||
никуда вести ещё в v1.40.1, для перехода есть значок-ссылка у названия.
|
||||
|
||||
|
||||
## v1.44.4 — 2026-07-27 (доработка по аудиту: B2, B5, L4)
|
||||
- **Единая политика авторизации (B2).** HTTP-загрузка по-прежнему **разрешала**
|
||||
запись, когда запись о конфигурации недоступна, тогда как WebSocket-путь уже
|
||||
отказывал — они разошлись. Теперь оба вызывают общий помощник `may_write`,
|
||||
который в неопределённой ситуации пропускает только администраторов.
|
||||
- **NaN/Infinity отвергаются во всех координатах (B5).** Проверка на конечность
|
||||
числа стояла только у позиций раскладки; прямоугольники комнат, вершины
|
||||
полигонов, `view_box` и координаты проёмов принимали `"NaN"`, который при
|
||||
записи превращается в `null` и необратимо портит геометрию. Ограничение
|
||||
`MAX_OPENINGS` было объявлено, но нигде не использовалось — список проёмов
|
||||
оставался безразмерным.
|
||||
- **Укрепление перетаскивания (часть L4).** Безопасная обёртка над
|
||||
`setPointerCapture` теперь используется во всех сценариях перетаскивания
|
||||
(устройства, подписи, изменение размера), а не только у проёмов — «мёртвый»
|
||||
идентификатор указателя мог оборвать перетаскивание. Фигуры декора получили
|
||||
ограничение по границам: их больше нельзя утащить далеко за пределы плана и
|
||||
сохранить там.
|
||||
|
||||
## v1.44.3 — 2026-07-27 (исправление: планы и инструкции снова загружаются)
|
||||
- **У аутентифицированной выдачи контента не было рабочего пути для браузера.**
|
||||
Версия v1.43.0 закрыла дыру правильно, но Home Assistant аутентифицирует
|
||||
HTTP-запросы либо заголовком Bearer, либо подписанным путём `authSig` — а
|
||||
`<image href>` внутри SVG и обычная ссылка `<a href>` не отправляют ни того,
|
||||
ни другого. На настоящем дашборде фоны планов и ссылки на PDF отдавали
|
||||
**401** (воспроизведено вживую до исправления). Теперь карточка просит бэкенд
|
||||
подписать то, что собирается показать (`houseplan/content/sign`, 24 часа,
|
||||
привязано к токену сессии, только для нашего эндпоинта), перерисовывается,
|
||||
когда подписи приходят, и обновляет их каждые 12 часов, чтобы настенные
|
||||
планшеты продолжали работать. Тест бэкенда скачивает подписанный адрес **без**
|
||||
заголовка авторизации и проверяет 200, а без подписи — 401.
|
||||
|
||||
## v1.44.2 — 2026-07-27 (внешнее код-ревью: CR-1…CR-3)
|
||||
|
||||
Второе, состязательное ревью (версии v1.44.0) дало три находки — все закрыты.
|
||||
|
||||
- **Правило про замки теперь сформулировано точно и проверяется (CR-1).**
|
||||
Рецензент справедливо отметил, что утверждение «замок нельзя открыть с плана»
|
||||
было слишком абсолютным: кнопка в карточке двери действительно вызывает
|
||||
сервис. Эта кнопка — осознанное продуктовое решение, поэтому правило
|
||||
переписано там, где ему место («никогда случайным нажатием; ровно одна
|
||||
подписанная поверхность»), отпирание теперь **спрашивает подтверждение**, а
|
||||
новый смок-тест проверяет все пять путей управления и доказывает, что значки,
|
||||
`controls[]` и карточка устройства по-прежнему отказывают замкам.
|
||||
- **Перенос вложений стал транзакционным (CR-2).** При смене привязки маркера
|
||||
файлы раньше ПЕРЕМЕЩАЛИСЬ до сохранения конфига с проверкой ревизии: если
|
||||
сохранение отклонялось, на сервере оставались старые ссылки, а файлы уже
|
||||
уехали. Теперь сервер копирует, конфиг фиксируется, и только после этого
|
||||
старая папка удаляется (`houseplan/files/cleanup`).
|
||||
- **Неудачный или частичный перенос больше не переписывает ссылки (CR-3).**
|
||||
Копирование возвращает точное соответствие «исходное имя → записанное»;
|
||||
переписываются только подтверждённые копии, при совпадении имён файл получает
|
||||
уникальное имя вместо молчаливой ссылки на чужой файл, а ошибка переноса
|
||||
показывается тостом.
|
||||
|
||||
## v1.44.1 — 2026-07-27
|
||||
|
||||
- Ссылка на чат сообщества добавлена везде, где её ищут:
|
||||
**https://t.me/ha_houseplan** (бейдж и строка в шапке обоих README, раздел
|
||||
«Помощь и обмен опытом», контакт-ссылки в шаблонах issue, CONTRIBUTING,
|
||||
STATUS и SCOPE).
|
||||
|
||||
## v1.44.0 — 2026-07-27 (отзыв пользователя: сначала управление)
|
||||
|
||||
- **Карточка устройства стала поверхностью управления.** Она открывается со
|
||||
списка управляемых сущностей: лампы, розетки и вентиляторы переключаются
|
||||
прямо здесь кнопками под палец, шторы, замки и климат передают управление в
|
||||
штатный more-info Home Assistant. Модель, ссылки и PDF-инструкции ушли ниже —
|
||||
на настенном планшете эта карточка нужна для управления домом, а не для
|
||||
чтения документации (из полевого отзыва). Служебные (config/diagnostic)
|
||||
сущности в списке не показываются, замки по-прежнему не переключаются
|
||||
нажатием в карточке.
|
||||
- **«Это устройство — источник света»** — новый флаг у устройства. Умный
|
||||
выключатель с обычными (не умными) светильниками теперь даёт ореол в заливке
|
||||
«Свет по источникам» без создания хелпера-группы: свечение следует за самим
|
||||
выключателем либо за лампами, привязанными в «Управляет источниками света».
|
||||
|
||||
## v1.43.3 — 2026-07-27 (отзыв пользователя: обнаруживаемость и тач)
|
||||
|
||||
- **Настройки комнаты невозможно было найти.** Шестерёнка из v1.42.0 жила
|
||||
внутри подписи комнаты размером 0.9em от её шрифта и с прозрачностью 60% —
|
||||
несколько бледных пикселей на обычном плане. Теперь это кнопка-пилюля
|
||||
«⚙ Комната» фиксированного читаемого размера, не зависящая от масштаба
|
||||
карточки, и она появляется **даже у комнат без имени** (их там и называют).
|
||||
Заодно разблокировались слайдеры размеров шрифта, до которых никто не мог
|
||||
добраться.
|
||||
- **Строка показателей увеличена** с 0.62 до 0.75 от размера названия — автор
|
||||
отзыва мог увеличить название, но строка датчиков оставалась нечитаемой на
|
||||
планшете. Множители комнаты и пространства работают поверх.
|
||||
- **Тултипы на тач-устройствах, вторая попытка.** Проверки `(hover: none)`
|
||||
оказалось мало: некоторые устройства, оболочки, стилусы и подключённые мыши
|
||||
сообщают `hover: hover`, и подсказки продолжали висеть под пальцем. Теперь
|
||||
карточка запоминает первое же касание (touch/pen) и гасит открытую подсказку.
|
||||
|
||||
## v1.43.2 — 2026-07-27 (внешний аудит: слой тестов)
|
||||
|
||||
- **Смок-тесты наконец умеют падать (T1).** Все 48 браузерных смоков печатали
|
||||
булевы значения и всегда завершались с кодом 0 — регрессия была видна в их
|
||||
собственном выводе, а прогон считался успешным. `demo/serve.mjs` теперь
|
||||
экспортирует `check`/`checkAll`/`finish`: каждый факт проверяется по имени,
|
||||
несовпадения и необработанные исключения внутри карточки дают ненулевой код
|
||||
возврата. Проверено намеренной поломкой блокировки редакторов в киоске —
|
||||
соответствующий смок покраснел.
|
||||
- **Набор гоняется в CI (T2)** отдельной джобой `smoke` после `frontend`,
|
||||
против свежесобранного бандла (закоммиченная копия в `demo/srv/assets` —
|
||||
снимок, на нём легко получить «зелёный» отчёт о несуществующем коде), с
|
||||
выгрузкой логов при падении.
|
||||
- **`docs/TESTING.md` приведён в соответствие (T3).** `[auto]` теперь означает
|
||||
«существует именованная проверка, которая падает», и рядом написано, где она;
|
||||
72 пункта, где автоматизация была намерением, честно помечены `[manual]`.
|
||||
Исправлены два давних противоречия: строка про «ноль кнопок редактирования в
|
||||
Просмотре» (неверна с v1.30.1) и строка про клик по проёму (снова верна
|
||||
с v1.43.1).
|
||||
- Три смока проверяли поведение, которого уже нет (ожидания времён v1.39.0 и
|
||||
v1.25); теперь они тестируют текущий контракт.
|
||||
|
||||
## v1.43.1 — 2026-07-27 (внешний аудит: исправления P1)
|
||||
|
||||
- **Стоимость отрисовки (L1).** Home Assistant подменяет объект `hass` при
|
||||
любом изменении состояния в доме, и каждая такая отрисовка пересчитывала всю
|
||||
геометрию плана — `_openPairs()` вызывался по разу на комнату (кубическая
|
||||
математика коллинеарных наложений), модель пространства строилась дважды.
|
||||
Теперь и то, и другое мемоизируется по структурному отпечатку конфига и
|
||||
вынесено из цикла по комнатам; сброс кэша происходит синхронно в момент
|
||||
мутации, а не внутри дебаунса.
|
||||
- **Тап против перетаскивания у проёмов (L4).** У перетаскивания двери или окна
|
||||
не было порога движения, поэтому любое дрожание пальца считалось
|
||||
перетаскиванием: диалог свойств не открывался, а в конфиг писалось
|
||||
неизменённое состояние (что подпитывало гонку L2). Теперь порог 3 px, как во
|
||||
всех остальных сценариях перетаскивания, и запись только при реальном
|
||||
изменении геометрии.
|
||||
- **Вогнутые комнаты (G2).** Вложенность определялась через среднее арифметическое
|
||||
вершин — а оно лежит СНАРУЖИ U- и L-образных комнат, поэтому комнаты-острова
|
||||
в них отвергались как пересечение, а дырка в заливке не рисовалась. Теперь
|
||||
вычисляется настоящая внутренняя точка (`interiorPoint`).
|
||||
- **Дедупликация стен (G3).** `segKey` сортировал концы по сырым float, а
|
||||
печатал округлённые, поэтому одна общая стена могла дать два ключа и
|
||||
рисовалась дважды. Сначала округление, потом сортировка.
|
||||
- **Укрепление бэкенда (B2–B5).** Проверка прав на запись теперь **отказывает**,
|
||||
когда запись о конфигурации недоступна (раньше во время перезагрузки
|
||||
интеграции запись разрешалась); `layout/set` поддерживает `expected_rev` и
|
||||
сообщает о конфликте так же, как хранилище конфига; `config/set` без
|
||||
`expected_rev` поверх непустого хранилища пишет предупреждение в лог;
|
||||
координаты отвергают NaN/Infinity, а у пространств, комнат, маркеров, декора
|
||||
и раскладки появились щедрые ограничения размера.
|
||||
|
||||
## v1.43.0 — 2026-07-27 (внешний аудит: исправления P0)
|
||||
|
||||
Внешний аудит кода версии v1.41.1 нашёл четыре критические проблемы. Все четыре
|
||||
исправлены и покрыты регрессионными тестами.
|
||||
|
||||
- **Молчаливая потеря правок при сохранении (L2).** Отложенная запись конфига
|
||||
читала его в момент срабатывания, поэтому пришедшее в промежутке событие
|
||||
`houseplan_config_updated` подменяло конфиг, и правка пользователя исчезала
|
||||
без единой ошибки — воспроизводилось даже в одной вкладке. Теперь дебаунс
|
||||
умеет `flush()`/`pending()`, перезагрузка сперва дописывает отложенную
|
||||
запись и откладывается, пока запись в полёте, а неудачная перезагрузка
|
||||
наконец сообщает о себе вместо молчания.
|
||||
- **Разрез разрушал геометрию комнаты (G1).** Разрез, начинающийся и
|
||||
заканчивающийся на ОДНОЙ стене (вырезание ниши — совершенно естественное
|
||||
действие), давал две самопересекающиеся комнаты, суммарная площадь которых
|
||||
вдвое превышала исходную, и проверка пересечений это не ловила. Теперь такие
|
||||
разрезы корректно вырезают нишу, а инвариант разбиения (части в сумме дают
|
||||
исходную площадь) отклоняет всё остальное.
|
||||
- **Планы и загруженные файлы отдавались без авторизации (B1).** Любой, кто мог
|
||||
достучаться до вашего Home Assistant, скачивал планы этажей и вложенные
|
||||
инструкции без входа в систему. Теперь их отдаёт аутентифицированный
|
||||
обработчик; сохранённые старые адреса переписываются на чтении, так что
|
||||
ничего не ломается. **Старые публичные пути исчезают только после
|
||||
перезапуска Home Assistant.**
|
||||
- **Диалоги могли воскреснуть и обнулить карточку (L3).** Закрытие диалога во
|
||||
время неудачного сохранения превращало его состояние в пустую «оболочку»,
|
||||
отрисовщик падал, и карточка оставалась пустой до перезагрузки страницы.
|
||||
Защита добавлена во все четыре процедуры сохранения, тост об ошибке
|
||||
по-прежнему показывается.
|
||||
|
||||
## v1.42.2 — 2026-07-26
|
||||
|
||||
- На тач-устройствах подсказки при наведении больше не выскакивают при каждом
|
||||
касании (из отзыва: «на планшете при тапе вылезают доп. надписи — мешают»).
|
||||
Подсказки теперь только для мыши; на тач та же информация есть в карточках
|
||||
комнат и в карточке устройства по долгому нажатию.
|
||||
|
||||
## v1.42.1 — 2026-07-26 (размеры шрифтов карточек комнат)
|
||||
|
||||
- Закрываем отзыв «нельзя настроить размер шрифта»: **три слайдера**. В
|
||||
настройках пространства появился базовый размер шрифта карточек комнат для
|
||||
всего пространства; в настройках комнаты — независимые размеры **названия** и
|
||||
**строки показателей** (50–300% каждый). Эффекты перемножаются и складываются
|
||||
с растягиванием карточки за уголки и множителем экрана в киоск-режиме.
|
||||
- В обоих диалогах показывается **живой пример карточки**, который меняется
|
||||
прямо во время перетаскивания слайдеров.
|
||||
|
||||
## v1.42.0 — 2026-07-26 (настройки комнаты — третий уровень)
|
||||
|
||||
- **У настроек теперь четыре уровня**: общие → пространство → комната →
|
||||
устройство; более конкретный уровень переопределяет более общий (решение
|
||||
владельца, зафиксировано в ARCHITECTURE). В этом релизе добавлен уровень
|
||||
КОМНАТЫ.
|
||||
- У каждой карточки комнаты в редакторе плана появилась **шестерёнка**:
|
||||
переименовать комнату, сменить её зону HA, переопределить **тип заливки**
|
||||
только для этой комнаты (работает и в glow-пространствах — «без заливки»
|
||||
выводит комнату из темноты) и выбрать явный **источник температуры и
|
||||
влажности** — любое устройство или сущность HA вместо среднего по комнате.
|
||||
Источник питает карточку комнаты, всплывающую подсказку и температурную
|
||||
заливку и работает даже у комнат без зоны HA (случай из отзыва: собственный
|
||||
template-сенсор, привязанный к помещению).
|
||||
- Тот же раздел настроек появляется в диалоге комнаты сразу после замыкания
|
||||
контура.
|
||||
+12
-2
@@ -51,9 +51,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
|
||||
- SSH: port **323**, root, key `ha_jb` (the user uploads it to the chat; in the sandbox /tmp/ha_jb, chmod 600).
|
||||
- JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/`
|
||||
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
|
||||
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
|
||||
A copy dropped next to `__init__.py` (…/houseplan/houseplan-card.js) is served
|
||||
by nobody: md5 on the server matches, the browser still gets the old bundle,
|
||||
and hours go into debugging a bug that was already fixed. Cost this mistake
|
||||
once: 2026-07-27, two releases deployed into the void.
|
||||
- The whole integration: tar c custom_components/houseplan (--exclude __pycache__) → tar x on the server.
|
||||
- **Verification is mandatory**: `md5sum` locally == on the server == `curl http://homeassistant:8123/houseplan_files/houseplan-card.js | md5sum`
|
||||
(inside the SSH add-on `localhost` is NOT HA, use the host `homeassistant`).
|
||||
- **Verification is mandatory, and it must go over HTTP** — comparing md5 against
|
||||
the file you just copied proves nothing about what the browser receives. The
|
||||
one check that counts:
|
||||
`curl -s https://ha.jbstudio.pro/houseplan_files/houseplan-card.js | grep -o '1\.[0-9]*\.[0-9]*' | sort -u`
|
||||
must print the version just built. (Inside the SSH add-on `localhost` is NOT
|
||||
HA — use the host `homeassistant`.)
|
||||
- Python changes require an HA restart (`ha core restart`, holds the connection until it finishes, HTTP
|
||||
comes back up in 1–3 min). JS changes — just a page refresh (the static path is served
|
||||
with no-cache).
|
||||
|
||||
+7
-5
@@ -5,22 +5,24 @@
|
||||
> state, where everything lives, and how to continue safely.
|
||||
>
|
||||
> **Documentation policy (mandatory):** every change is documented *in the same
|
||||
> commit* — CHANGELOG entry for anything user-visible, STATUS.md for state changes
|
||||
> commit* — a CHANGELOG entry for anything user-visible **in BOTH
|
||||
> `docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian, since
|
||||
> v1.42.0 — the user base is largely Russian-speaking, see the Telegram chat)**, STATUS.md for state changes
|
||||
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
|
||||
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
|
||||
|
||||
## Snapshot (2026-07-24)
|
||||
## Snapshot (2026-07-27)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.41.0** 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) |
|
||||
| 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 |
|
||||
| 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.41.0** 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 |
|
||||
| Tests | 111 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 |
|
||||
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
|
||||
|
||||
|
||||
@@ -50,6 +50,29 @@
|
||||
never silently linked); urls are rewritten only for confirmed copies
|
||||
[auto: unit logic.test + tests_backend]
|
||||
|
||||
- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a
|
||||
dashboard with an uploaded plan — the background renders and a manual link
|
||||
opens; DevTools shows /api/houseplan/content/... returning 200 via a
|
||||
signed url, while the same url without authSig returns 401
|
||||
[auto: tests_backend + manual]
|
||||
- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS
|
||||
write use the same `may_write`, which denies non-admins when the config
|
||||
entry is unavailable [auto: tests_backend]
|
||||
- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room
|
||||
rects, polygon vertices, view_box and openings — not only in layout; the
|
||||
openings list honours MAX_OPENINGS [auto: tests_backend]
|
||||
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
|
||||
pointer through the tolerant helper; decor shapes cannot be dragged more
|
||||
than a quarter of the plan outside the viewBox [auto: smoke_decor]
|
||||
|
||||
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
|
||||
placed on the plan (hidden by curation or by the user) still feeds the
|
||||
room card, the tooltip and the temperature fill; fridges/TRVs still do
|
||||
not; an explicit per-room source still wins [auto: unit devices.test]
|
||||
- [ ] Room tooltip wording (v1.44.5): hovering a room shows its name (plus
|
||||
temperature/signal when available) and no longer claims "open the area" —
|
||||
room clicks were removed in v1.40.1 [manual]
|
||||
|
||||
## Environments matrix
|
||||
|
||||
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
|
||||
@@ -211,6 +234,31 @@ 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
|
||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||
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
|
||||
tab open on the same plan, attach a background image in space settings —
|
||||
the plan shows immediately, `plan_url` is in `.storage/houseplan.config`,
|
||||
and the same holds when the space is being CREATED, not edited
|
||||
[auto: smoke_plan_upload_race]
|
||||
- [ ] Signed plan background (v1.44.7): a space whose plan lives on the content
|
||||
endpoint renders its background image with an `authSig` query — the plan is
|
||||
visible after a plain page load, and Home Assistant logs NO failed-login
|
||||
attempt from the viewer's own IP. Nothing is requested before the signature
|
||||
arrives; a 12 h re-sign keeps the previous url until the new one lands
|
||||
[auto: smoke_plan_signed]
|
||||
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is
|
||||
in flight and let the save fail — the dialog stays closed, the card keeps
|
||||
rendering, the error toast still fires [auto: unit: logic.test + manual]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.44.2",
|
||||
"version": "1.45.0",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+102
-2
@@ -2,7 +2,7 @@
|
||||
* Building the device list from HA registries: curation, light groups,
|
||||
* markers (overrides/virtual). No Lit/DOM — only the hass object.
|
||||
*/
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
|
||||
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
|
||||
import { averageLqi } from './logic';
|
||||
import type { DevItem, Marker, ServerConfig } from './types';
|
||||
|
||||
@@ -162,7 +162,7 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
|
||||
}
|
||||
|
||||
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
|
||||
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
|
||||
export function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
|
||||
const byRules = iconFor(name, model, rules);
|
||||
if (byRules !== FALLBACK_ICON) return byRules;
|
||||
const classes: string[] = [];
|
||||
@@ -400,6 +400,106 @@ export function areaHum(
|
||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity ids that measure something other than room air, however honest their
|
||||
* device_class is: water and coolant loops, chip/CPU/board temperatures,
|
||||
* battery temperature, setpoints (target/external) and the like.
|
||||
*/
|
||||
const NON_AIR_RE = new RegExp(
|
||||
[
|
||||
'water', 'voda', 'coolant', 'flow_?temp', 'return_?temp', 'target', 'setpoint',
|
||||
'chip', 'cpu', 'processor', 'board', 'core_temp', 'device_temp',
|
||||
'batter', 'akkum', 'freezer', 'fridge', 'oven', 'kettle', 'boiler',
|
||||
].join('|'),
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* Room climate from EVERY sensor of the area — including devices that are not
|
||||
* placed on the plan (hidden by curation or by the user). The old helpers read
|
||||
* the visible-icon list, so hiding a thermometer silently removed it from the
|
||||
* room card (field report, 2026-07-27).
|
||||
*
|
||||
* Curation is kept: only devices the card itself recognises as thermometers /
|
||||
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
|
||||
* The AUTO icon is used on purpose — a custom marker icon must not change what
|
||||
* a device measures.
|
||||
*/
|
||||
export interface AreaClimate { temp: number | null; hum: number | null }
|
||||
|
||||
/**
|
||||
* Climate for EVERY area in one registry pass (review R2-3).
|
||||
*
|
||||
* 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)) {
|
||||
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
|
||||
const area = reg.area_id || dev?.area_id || null;
|
||||
if (!area) continue;
|
||||
// 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
|
||||
// 90 C and a virtual better_thermostat duplicating the real sensor (field
|
||||
// question, 2026-07-27). Three guards, cheapest first:
|
||||
if (reg.entity_category) continue; // diagnostic/config readings
|
||||
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
|
||||
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;
|
||||
let g = groups.get(key);
|
||||
if (!g) {
|
||||
const st = hass.states?.[eid];
|
||||
g = {
|
||||
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
|
||||
model: dev?.model,
|
||||
ents: [],
|
||||
};
|
||||
groups.set(key, g);
|
||||
}
|
||||
g.ents.push(eid);
|
||||
}
|
||||
for (const [area, groups] of byArea) {
|
||||
const temps: number[] = [];
|
||||
const hums: number[] = [];
|
||||
for (const g of groups.values()) {
|
||||
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
|
||||
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
|
||||
if (air) {
|
||||
const t = tempFor(hass, g.ents);
|
||||
if (t != null) temps.push(t);
|
||||
}
|
||||
if (air || icon === 'mdi:water-percent') {
|
||||
const h = humFor(hass, g.ents);
|
||||
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. */
|
||||
export function areaLightStats(
|
||||
hass: any,
|
||||
|
||||
+210
-28
@@ -21,8 +21,9 @@ import {
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS, chunk, referencedContentUrls,
|
||||
} from './logic';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } from './devices';
|
||||
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
RoomCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
@@ -32,7 +33,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.44.2';
|
||||
const CARD_VERSION = '1.45.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -90,6 +91,22 @@ const debounce = <T extends (...a: any[]) => void>(fn: T, ms: number): Debounced
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture the pointer for a drag, tolerating an inactive pointerId.
|
||||
*
|
||||
* `setPointerCapture` throws for synthetic events and for pointers some
|
||||
* browsers consider gone; that killed a drag outright. The opening pipeline
|
||||
* was hardened for this, the device/label/resize ones were not (audit
|
||||
* follow-up L4 sub-item) — now they all go through here.
|
||||
*/
|
||||
const capturePointer = (ev: PointerEvent): void => {
|
||||
try {
|
||||
(ev.target as Element | null)?.setPointerCapture?.(ev.pointerId);
|
||||
} catch {
|
||||
/* an inactive pointerId must never kill the drag */
|
||||
}
|
||||
};
|
||||
|
||||
class HouseplanCard extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: CardConfig;
|
||||
@@ -348,6 +365,9 @@ class HouseplanCard extends LitElement {
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('keydown', this._keyHandler);
|
||||
// signatures expire (24 h); refresh well before that on long-lived screens
|
||||
clearInterval(this._resignTimer);
|
||||
this._resignTimer = window.setInterval(() => this._resign(), 12 * 3600 * 1000);
|
||||
if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
|
||||
clearInterval(this._cycleTimer);
|
||||
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
|
||||
@@ -361,6 +381,9 @@ class HouseplanCard extends LitElement {
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
clearTimeout(this._reloadRetry);
|
||||
clearTimeout(this._signTimer);
|
||||
clearInterval(this._resignTimer);
|
||||
clearTimeout(this._toastTimer);
|
||||
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||
window.removeEventListener('hashchange', this._onHashChange);
|
||||
clearTimeout(this._holdTimer);
|
||||
@@ -571,7 +594,11 @@ class HouseplanCard extends LitElement {
|
||||
id: s.id,
|
||||
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],
|
||||
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
// raw url on purpose: the model is memoized on the config fingerprint,
|
||||
// so a signed url baked in here would freeze BEFORE the signature
|
||||
// arrives and the plan would never load (bug found 2026-07-27).
|
||||
// _display() is called at render time instead.
|
||||
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
|
||||
rooms: s.rooms.map(scale),
|
||||
};
|
||||
});
|
||||
@@ -756,6 +783,91 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _reloadRetry?: number;
|
||||
/**
|
||||
* Signed urls for the content endpoint (audit follow-up B1 regression).
|
||||
* A browser cannot authenticate an <image href> or an <a href>: HA takes a
|
||||
* 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.
|
||||
*/
|
||||
private _signed: Record<string, { url: string; at: number }> = {};
|
||||
private _signPending = new Set<string>();
|
||||
private _signTimer?: number;
|
||||
|
||||
/** Display url: a signature we hold and still trust, else nothing. */
|
||||
private _display(url: string | null | undefined): string {
|
||||
const u = contentUrl(url);
|
||||
if (!u.startsWith('/api/houseplan/content/')) return 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);
|
||||
// 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
|
||||
// viewer's own IP. Callers skip rendering until the signature lands.
|
||||
return '';
|
||||
}
|
||||
|
||||
private _requestSignature(url: string): void {
|
||||
if (this._signPending.has(url) || !this.hass?.callWS) return;
|
||||
this._signPending.add(url);
|
||||
clearTimeout(this._signTimer);
|
||||
// batch: a plan switch asks for several urls in the same tick
|
||||
this._signTimer = window.setTimeout(() => {
|
||||
const paths = [...this._signPending];
|
||||
this._signPending.clear();
|
||||
this._signBatches(paths);
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* blank the plan for a round trip (and, if the socket is down, until it heals).
|
||||
*/
|
||||
private _resignTimer?: number;
|
||||
|
||||
private _resign(): void {
|
||||
// prune first: an entry for a plan that was replaced months ago must not
|
||||
// consume a slot in the (capped) signing request
|
||||
const live = referencedContentUrls(this._serverCfg);
|
||||
const now = Date.now();
|
||||
const kept: Record<string, { url: string; at: number }> = {};
|
||||
for (const [k, v] of Object.entries(this._signed)) {
|
||||
if (live.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this._signed = kept;
|
||||
this._signBatches(Object.keys(kept));
|
||||
}
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
@@ -1309,7 +1421,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
const p = this._pos(d);
|
||||
this._drag = { id: d.id, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
this._tip = null;
|
||||
}
|
||||
|
||||
@@ -1652,7 +1764,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
const p = this._snap(this._svgPoint(ev));
|
||||
this._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId };
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
return true;
|
||||
}
|
||||
if (t === 'text') {
|
||||
@@ -1712,15 +1824,25 @@ class HouseplanCard extends LitElement {
|
||||
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
|
||||
pid: ev.pointerId, moved: false,
|
||||
};
|
||||
(ev.target as HTMLElement).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
}
|
||||
|
||||
private _decorMoveUpdate(ev: PointerEvent): void {
|
||||
const m = this._decorMove!;
|
||||
const p = this._svgPoint(ev);
|
||||
const g = this._gridPitch;
|
||||
const dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
const dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
let dx = snapToGrid(p[0] - m.start[0], g) / NORM_W;
|
||||
let dy = snapToGrid(p[1] - m.start[1], g) / this._decorH;
|
||||
// audit follow-up L4: decor had neither a threshold nor a bounds clamp, so
|
||||
// a shape could be dragged far outside the viewBox and persisted there.
|
||||
const o = m.orig;
|
||||
const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x;
|
||||
const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y;
|
||||
const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0);
|
||||
const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0);
|
||||
const lim = 0.25; // a quarter of the plan may hang outside, no more
|
||||
dx = Math.max(-curX - lim, Math.min(1 + lim - curX - w, dx));
|
||||
dy = Math.max(-curY - lim, Math.min(1 + lim - curY - h, dy));
|
||||
if (dx || dy) m.moved = true;
|
||||
const sp = this._curSpaceCfg;
|
||||
sp.decor = this._decorList.map((x) => {
|
||||
@@ -2031,7 +2153,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
try {
|
||||
(ev.target as Element).setPointerCapture?.(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
} catch {
|
||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||
}
|
||||
@@ -2873,12 +2995,30 @@ class HouseplanCard extends LitElement {
|
||||
const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0;
|
||||
this._spaceDialog = { ...d, busy: true };
|
||||
try {
|
||||
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
|
||||
const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!;
|
||||
|
||||
/* Upload BEFORE touching the config, and never hold a reference to a
|
||||
config object across an await. `houseplan/config/get` runs on every
|
||||
`houseplan_config_updated` event and REPLACES `_serverCfg`; a space
|
||||
object captured before the upload is then detached, so plan_url,
|
||||
aspect and settings were written into an orphan and the save shipped
|
||||
the untouched config. Symptom: the file lands on disk, the plan never
|
||||
appears, and re-saving does not help (owner's install, 2026-07-27). */
|
||||
let uploaded: { url: string; aspect: number } | null = null;
|
||||
if (d.source === 'file' && d.planFile) {
|
||||
const resp = await this.hass.callWS({
|
||||
type: 'houseplan/plan/set', space_id: spaceId, ext: d.planFile.ext, data: d.planFile.b64,
|
||||
});
|
||||
uploaded = { url: resp.url, aspect: d.planFile.aspect };
|
||||
}
|
||||
|
||||
// from here on: no awaits until the save, so `sp` cannot be orphaned
|
||||
const cfg = this._serverCfg!;
|
||||
let sp: any;
|
||||
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
|
||||
if (d.mode === 'create') {
|
||||
sp = {
|
||||
id: 's' + Date.now().toString(36),
|
||||
id: spaceId,
|
||||
title: d.title.trim(),
|
||||
plan_url: null,
|
||||
aspect: d.source === 'draw' ? drawAspect : 1.414,
|
||||
@@ -2887,15 +3027,13 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
cfg.spaces.push(sp);
|
||||
} else {
|
||||
sp = cfg.spaces.find((x: any) => x.id === d.spaceId);
|
||||
sp = cfg.spaces.find((x: any) => x.id === spaceId);
|
||||
if (!sp) throw new Error('space ' + spaceId + ' is gone from the config');
|
||||
sp.title = d.title.trim();
|
||||
}
|
||||
if (d.source === 'file' && d.planFile) {
|
||||
const resp = await this.hass.callWS({
|
||||
type: 'houseplan/plan/set', space_id: sp.id, ext: d.planFile.ext, data: d.planFile.b64,
|
||||
});
|
||||
sp.plan_url = resp.url;
|
||||
sp.aspect = d.planFile.aspect;
|
||||
if (uploaded) {
|
||||
sp.plan_url = uploaded.url;
|
||||
sp.aspect = uploaded.aspect;
|
||||
}
|
||||
// switching an existing space to "draw" detaches its background image
|
||||
// (the uploaded file stays on disk; only the reference is cleared)
|
||||
@@ -2920,6 +3058,10 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
|
||||
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;
|
||||
if (d.mode === 'create') this._space = sp.id;
|
||||
this._regSignature = '';
|
||||
@@ -2975,18 +3117,40 @@ class HouseplanCard extends LitElement {
|
||||
private async _saveConfigNow(): Promise<void> {
|
||||
this._dropLegacySegments();
|
||||
this._cfgEpoch++;
|
||||
// same flag the debounced writer uses: while it is set, an incoming
|
||||
// `houseplan_config_updated` defers its reload instead of replacing the
|
||||
// config under an unfinished write (audit L2, extended to this path)
|
||||
this._cfgWriting = true;
|
||||
try {
|
||||
const r = await this.hass.callWS({
|
||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||
});
|
||||
this._cfgRev = r?.rev ?? this._cfgRev + 1;
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'conflict') await this._reloadConfigOnly();
|
||||
if (e?.code === 'conflict') {
|
||||
this._cfgWriting = false;
|
||||
await this._reloadConfigOnly();
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
this._cfgWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 =================
|
||||
|
||||
private _startImport(): void {
|
||||
@@ -3528,8 +3692,8 @@ class HouseplanCard extends LitElement {
|
||||
${this._editing && !this._markup
|
||||
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
|
||||
: nothing}
|
||||
${space.bg
|
||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
${space.bg && this._display(space.bg.href)
|
||||
? svg`<image href="${this._display(space.bg.href)}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${this._renderDecorLayer()}
|
||||
${(() => {
|
||||
@@ -3579,7 +3743,7 @@ class HouseplanCard extends LitElement {
|
||||
style = st.join(';');
|
||||
}
|
||||
const tip = (e: MouseEvent) =>
|
||||
this._showTip(e, r.name, this._t('tip.room'),
|
||||
this._showTip(e, r.name, '',
|
||||
showLqi ? this._roomLqi(r.area) : null,
|
||||
this._roomTemp(r));
|
||||
const label = !space.bg && !disp.showNames && !this._markup;
|
||||
@@ -3759,14 +3923,32 @@ class HouseplanCard extends LitElement {
|
||||
private _roomTemp(r: RoomCfg): number | null {
|
||||
const src = r.settings?.temp_source;
|
||||
if (src) return sourceValue(this.hass, src, 'temp');
|
||||
return r.area ? areaTemp(this.hass, this._devices, r.area) : null;
|
||||
// every sensor of the area, placed on the plan or not (field report)
|
||||
return r.area ? this._climate().get(r.area)?.temp ?? null : null;
|
||||
}
|
||||
|
||||
/** Room humidity honouring the tier-3 source override. */
|
||||
private _roomHum(r: RoomCfg): number | null {
|
||||
const src = r.settings?.hum_source;
|
||||
if (src) return sourceValue(this.hass, src, 'hum');
|
||||
return r.area ? areaHum(this.hass, this._devices, r.area) : 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 {
|
||||
@@ -3880,7 +4062,7 @@ class HouseplanCard extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const p = this._labelPos(r, spaceId);
|
||||
this._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
this._tip = null;
|
||||
}
|
||||
|
||||
@@ -3926,7 +4108,7 @@ class HouseplanCard extends LitElement {
|
||||
const cy = b.top + b.height / 2;
|
||||
const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy));
|
||||
this._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 };
|
||||
(ev.target as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
capturePointer(ev);
|
||||
}
|
||||
|
||||
private _rlResizeMove(ev: PointerEvent): void {
|
||||
@@ -4602,7 +4784,7 @@ class HouseplanCard extends LitElement {
|
||||
${d.pdfs && d.pdfs.length
|
||||
? html`<div class="inforow"><span class="k">${this._t('info.manuals')}</span><span class="pdflist">
|
||||
${d.pdfs.map(
|
||||
(p) => html`<a class="pdf" href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||
(p) => html`<a class="pdf" href="${safeUrl(this._display(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">
|
||||
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
||||
)}</span></div>`
|
||||
: nothing}
|
||||
@@ -4839,7 +5021,7 @@ class HouseplanCard extends LitElement {
|
||||
<div class="pdfedit">
|
||||
${d.pdfs.map(
|
||||
(p) => html`<span class="pdftag"><ha-icon icon="mdi:file-pdf-box"></ha-icon>
|
||||
<a href="${safeUrl(contentUrl(p.url)) || '#'}" target="_blank" rel="noreferrer noopener">${p.name}</a>
|
||||
<a href="${safeUrl(this._display(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>`,
|
||||
)}
|
||||
<label class="btn filebtn">
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
|
||||
"markup.hint_start": "click a grid dot to start the outline",
|
||||
"tip.room": "room — open the area",
|
||||
"tip.lqi": "average zigbee signal:",
|
||||
"info.device_header": "Device on the plan",
|
||||
"info.model": "Model",
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
|
||||
"tip.room": "комната — открыть зону",
|
||||
"tip.lqi": "средний сигнал zigbee:",
|
||||
"info.device_header": "Устройство на плане",
|
||||
"info.model": "Модель",
|
||||
|
||||
@@ -1036,6 +1036,48 @@ export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6):
|
||||
* authenticated content endpoint (audit B1). Applied on READ, so stored
|
||||
* 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 {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('/houseplan_files/plans/')) {
|
||||
|
||||
@@ -29,6 +29,8 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
|
||||
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
|
||||
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
|
||||
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
|
||||
{ pattern: 'чайник|kettle|термопот', icon: 'mdi:kettle' },
|
||||
{ pattern: 'сауна|sauna|harvia|парная|парилк', icon: 'mdi:hot-tub' },
|
||||
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
|
||||
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
|
||||
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
|
||||
|
||||
@@ -109,6 +109,8 @@ class HouseplanSpaceCard extends LitElement {
|
||||
|
||||
public getCardSize(): number {
|
||||
const models = spaceModels(this._snap?.config || null);
|
||||
// B1 follow-up: the plan <image> needs a signed url in a browser session
|
||||
for (const m of models) if (m.bg?.href) this._signBg(m.bg);
|
||||
const sp = models.find((s) => s.id === this._config?.space);
|
||||
if (sp) {
|
||||
const ratio = sp.vb[3] / sp.vb[2]; // h/w
|
||||
@@ -121,6 +123,27 @@ class HouseplanSpaceCard extends LitElement {
|
||||
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
|
||||
}
|
||||
|
||||
private _signedBg: Record<string, string> = {};
|
||||
private _signBgPending = new Set<string>();
|
||||
|
||||
/** Ask the backend for a signed content url and swap it in when it arrives. */
|
||||
private _signBg(bg: { href: string }): void {
|
||||
const raw = bg.href.split('?authSig=')[0];
|
||||
if (!raw.startsWith('/api/houseplan/content/')) return;
|
||||
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
|
||||
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
|
||||
this._signBgPending.add(raw);
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
|
||||
.then((r: any) => {
|
||||
const url = r?.urls?.[raw];
|
||||
if (!url) return;
|
||||
this._signedBg = { ...this._signedBg, [raw]: url };
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._config) return nothing;
|
||||
const cfg = this._snap?.config;
|
||||
|
||||
+125
-1
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats, sourceValue } 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';
|
||||
|
||||
/** Minimal fake hass around the pieces buildDevices reads. */
|
||||
@@ -359,3 +359,127 @@ test('sourceValue: explicit entity and device sources (tier 3)', () => {
|
||||
assert.equal(sourceValue(hass, '', 'temp'), null);
|
||||
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
|
||||
});
|
||||
|
||||
test('areaClimate: counts sensors that are NOT on the plan (field report)', () => {
|
||||
const hass = {
|
||||
devices: {
|
||||
d1: { id: 'd1', name: 'Датчик температуры спальня', area_id: 'bed' },
|
||||
d2: { id: 'd2', name: 'Холодильник', model: 'LG', area_id: 'bed' },
|
||||
d3: { id: 'd3', name: 'Qingping air monitor', area_id: 'bed' },
|
||||
d4: { id: 'd4', name: 'Датчик температуры кухня', area_id: 'kitchen' },
|
||||
},
|
||||
entities: {
|
||||
'sensor.bed_t': { device_id: 'd1' },
|
||||
'sensor.bed_h': { device_id: 'd1' },
|
||||
'sensor.fridge_t': { device_id: 'd2' },
|
||||
'sensor.air_t': { device_id: 'd3' },
|
||||
'sensor.air_h': { device_id: 'd3' },
|
||||
'sensor.kitchen_t': { device_id: 'd4' },
|
||||
},
|
||||
states: {
|
||||
'sensor.bed_t': { state: '21.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.bed_h': { state: '40', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.fridge_t': { state: '4', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.air_t': { state: '23.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.air_h': { state: '50', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
|
||||
'sensor.kitchen_t': { state: '30.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
},
|
||||
};
|
||||
// среднее по двум термометрам зоны; ни одно устройство не «размещено» на плане
|
||||
assert.equal(areaClimate(hass, 'bed', 'temp'), 22);
|
||||
assert.equal(areaClimate(hass, 'bed', 'hum'), 45);
|
||||
// холодильник по-прежнему не считается климатом комнаты
|
||||
assert.notEqual(areaClimate(hass, 'bed', 'temp'), (21 + 4 + 23) / 3);
|
||||
// чужая зона не подмешивается
|
||||
assert.equal(areaClimate(hass, 'kitchen', 'temp'), 30);
|
||||
assert.equal(areaClimate(hass, 'nowhere', 'temp'), null);
|
||||
// сущность со своей area_id учитывается, даже если устройство в другой зоне
|
||||
const hass2 = {
|
||||
...hass,
|
||||
entities: { ...hass.entities, 'sensor.kitchen_t': { device_id: 'd4', area_id: 'bed' } },
|
||||
};
|
||||
assert.equal(areaClimate(hass2, 'kitchen', 'temp'), null);
|
||||
});
|
||||
|
||||
test('areaClimate: only ROOM AIR counts (field question, 2026-07-27)', () => {
|
||||
const hass = {
|
||||
devices: {
|
||||
good: { id: 'good', name: 'Датчик температуры спальня', area_id: 'bed' },
|
||||
kettle: { id: 'kettle', name: 'Polaris PWK-1725CGLD', model: 'Kettle', area_id: 'bed' },
|
||||
nas: { id: 'nas', name: 'System Monitor', area_id: 'bed' },
|
||||
sauna: { id: 'sauna', name: 'Сауна Harvia', area_id: 'bed' },
|
||||
trv: { id: 'trv', name: 'Термоголовка в спальне', area_id: 'bed' },
|
||||
bt: { id: 'bt', name: 'Спальня better thermostat', area_id: 'bed' },
|
||||
zb: { id: 'zb', name: 'SLZB-06MU', area_id: 'bed' },
|
||||
},
|
||||
entities: {
|
||||
'sensor.good_t': { device_id: 'good', platform: 'mqtt' },
|
||||
// вода в чайнике
|
||||
'sensor.kettle_current_temperature': { device_id: 'kettle', platform: 'syncleo_kettle' },
|
||||
// температура процессора: и diagnostic, и исключённая интеграция
|
||||
'sensor.nas_processor_temperature': { device_id: 'nas', platform: 'systemmonitor', entity_category: 'diagnostic' },
|
||||
'sensor.sauna_temperature': { device_id: 'sauna', platform: 'harvia_sauna' },
|
||||
'sensor.trv_local_temperature': { device_id: 'trv', platform: 'mqtt' },
|
||||
'sensor.bt_temperature': { device_id: 'bt', platform: 'better_thermostat' },
|
||||
'sensor.zb_core_chip_temp': { device_id: 'zb', platform: 'smlight', entity_category: 'diagnostic' },
|
||||
},
|
||||
states: {
|
||||
'sensor.good_t': { state: '22.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.kettle_current_temperature': { state: '95', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.nas_processor_temperature': { state: '61', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.sauna_temperature': { state: '90', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.trv_local_temperature': { state: '24', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.bt_temperature': { state: '22.0', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
'sensor.zb_core_chip_temp': { state: '48', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
|
||||
},
|
||||
};
|
||||
// остаётся ровно один настоящий датчик воздуха
|
||||
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,
|
||||
migratePdfUrls,
|
||||
roomFillModeOf,
|
||||
contentUrl,
|
||||
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
|
||||
interiorPoint,
|
||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||
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]));
|
||||
});
|
||||
|
||||
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,106 @@ async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocket
|
||||
{"type": "houseplan/plan/set", "space_id": "s1", "ext": "png", "data": "aGVsbG8="}
|
||||
)
|
||||
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)
|
||||
# the HA test config dir is shared across a module: start from a known state
|
||||
for stale in plans.glob("s9.*"):
|
||||
stale.unlink()
|
||||
legacy = plans / "s9.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": "s9", "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": "s9", "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": "s9", "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):
|
||||
@@ -177,3 +276,47 @@ async def test_files_migrate_copies_and_reports_mapping(
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["removed"] is True
|
||||
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
|
||||
|
||||
|
||||
async def test_content_signed_path_opens_without_a_bearer_header(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
|
||||
) -> None:
|
||||
"""B1 follow-up: a browser <image>/<a> sends no Authorization header.
|
||||
|
||||
The unsigned url must be refused and the signed one must work — otherwise
|
||||
plan backgrounds and PDF links 401 on a real dashboard (reproduced live,
|
||||
2026-07-27).
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan.const import CONTENT_URL, PLANS_DIR
|
||||
|
||||
await _setup(hass)
|
||||
plans = hass.config.path(PLANS_DIR)
|
||||
|
||||
def _write() -> None:
|
||||
os.makedirs(plans, exist_ok=True)
|
||||
with open(os.path.join(plans, "s1.png"), "wb") as fh:
|
||||
fh.write(b"PNGDATA")
|
||||
|
||||
await hass.async_add_executor_job(_write)
|
||||
path = f"{CONTENT_URL}/plans/_/s1.png"
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "houseplan/content/sign", "paths": [path]})
|
||||
resp = await client.receive_json()
|
||||
assert resp["success"], resp
|
||||
signed = resp["result"]["urls"][path]
|
||||
assert "authSig=" in signed
|
||||
|
||||
http = await hass_client_no_auth()
|
||||
assert (await http.get(path)).status == 401 # unsigned: refused
|
||||
ok = await http.get(signed)
|
||||
assert ok.status == 200 and await ok.read() == b"PNGDATA"
|
||||
|
||||
# only our own endpoint may be signed
|
||||
await client.send_json_auto_id(
|
||||
{"type": "houseplan/content/sign", "paths": ["/api/other/secret"]}
|
||||
)
|
||||
resp2 = await client.receive_json()
|
||||
assert resp2["success"] and resp2["result"]["urls"] == {}
|
||||
|
||||
@@ -140,3 +140,36 @@ def test_collection_caps():
|
||||
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.LAYOUT_SCHEMA(big)
|
||||
|
||||
|
||||
def test_finite_on_every_coordinate():
|
||||
"""audit follow-up B5: NaN/Infinity must be refused everywhere, not only in layout."""
|
||||
base = {"id": "s1", "title": "S", "aspect": 1.0, "view_box": [0, 0, 100, 100], "rooms": []}
|
||||
# view_box
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "view_box": [0, 0, "NaN", 100]}]})
|
||||
# room rect coordinates
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "x": "Infinity", "y": 0, "w": 1, "h": 1}]}]})
|
||||
# polygon vertices
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, "NaN"], [1, 1]]}]}]})
|
||||
# opening coordinates
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{**base, "openings": [
|
||||
{"id": "o", "type": "door", "x": "NaN", "y": 0.5, "angle": 0, "length": 0.1}]}]})
|
||||
# a sane config still validates
|
||||
assert v.CONFIG_SCHEMA({"spaces": [{**base, "rooms": [
|
||||
{"id": "r", "name": "R", "poly": [[0, 0], [1, 0], [1, 1]]}]}]})
|
||||
|
||||
|
||||
def test_openings_cap_enforced():
|
||||
"""audit follow-up B5: MAX_OPENINGS was defined but never wired in."""
|
||||
many = [{"id": f"o{i}", "type": "door", "x": 0.1, "y": 0.1, "angle": 0, "length": 0.1}
|
||||
for i in range(v.MAX_OPENINGS + 1)]
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
|
||||
"view_box": [0, 0, 100, 100], "rooms": [],
|
||||
"openings": many}]})
|
||||
|
||||
Reference in New Issue
Block a user