Compare commits

..
13 Commits
Author SHA1 Message Date
Matysh 15e5dd7392 Release v1.45.0
External review of v1.44.8: R2-1 plan upload transaction boundary,
R2-2 signed-url batching and expiry, R2-3 room climate in one registry pass.
2026-07-27 21:14:42 +03:00
Matysh f1b501a956 test: isolate the plan-upload transaction test from a shared config dir
The HA harness reuses one config directory inside a module, so the s1 upload
left by test_plan_set_validates counted as a third file and the cleanup
assertion read 3 instead of 2. Own space id plus a defensive sweep.
2026-07-27 21:11:32 +03:00
Matysh 5d2dbb1009 v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.

R2-2: the backend signs at most MAX_SIGN_PATHS (200) per request and ignores
the rest silently, while the card sent its whole cache in one call and trusted
any cached entry forever — past 200 attachments the later ones stopped being
refreshed and expired for good. Requests are chunked to the shared constant,
entries carry their issue time (aging urls keep rendering while a replacement
is fetched, expired ones are dropped), and the cache is pruned to urls the live
config still references.

R2-3: areaClimate() rescanned the whole registry per room and per measurement.
areaClimateMap() classifies once and returns Map<area,{temp,hum}>, memoized on
hass identity so fresh states are always observed. Smoke measurement: 133
registry scans per update with 44 rooms before, 2 after, flat in room count.

Also: smoke_ux_fixes wrote its screenshot to a hard-coded /tmp path and could
not run on Windows.

Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-27 21:08:34 +03:00
Matysh 40cb0302e3 Release v1.44.8
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m23s
Validate / backend (push) Failing after 6m59s
Validate / smoke (push) Successful in 7m26s
v1.44.6 room climate counts only air temperature
v1.44.7 plan backgrounds never displayed (signed-url regression)
v1.44.8 an uploaded plan never reached the config
2026-07-27 15:36:09 +03:00
Matysh 14cc4df4bd chore: sync the committed card bundle with dist (v1.44.8)
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 1m36s
Validate / backend (push) Failing after 6m41s
Validate / smoke (push) Failing after 37s
CI checks `cmp dist == custom_components/houseplan/frontend`; the three
previous commits shipped source and docs without the rebuilt bundle, so
validate.yml failed on all of them. Same folder that HA serves statically —
the one that must never be skipped.
2026-07-27 15:33:21 +03:00
Matysh ead56dd9b6 v1.44.8: an uploaded plan never reached the config
Found on the owner's install: the image lands in /config/houseplan/plans, the
space keeps plan_url=null, the plan never shows and re-saving does not help.

_saveSpaceDialog held a reference to the space object across the await that
uploads the file. _reloadConfigOnly() — which runs on every
houseplan_config_updated event — REPLACES _serverCfg, so that reference became
an orphan: plan_url, aspect, title and every display setting were written into a
detached object while the save shipped the untouched config. In 'create' mode
the whole new space was lost the same way.

- upload first, then touch the config; no reference is held across an await.
- _saveConfigNow() sets _cfgWriting like the debounced writer, so a revision
  arriving mid-save defers its reload instead of replacing the config (audit L2
  extended to this path).
- demo/smoke_plan_upload_race.mjs: on v1.44.7 the sent config still carries the
  OLD plan_url and the created space is missing; passes here. The demo's
  config/get now returns a fresh object, as a real server does — returning the
  same reference is what hid this class of bug from the smoke layer.
- DEVELOPMENT.md: the deploy target is custom_components/houseplan/frontend/,
  and deploy verification must go over HTTP. A copy placed next to __init__.py
  is served by nobody — that cost two deployments today.
- docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-27 15:14:19 +03:00
Matysh 018b37940f v1.44.7: plan backgrounds never displayed (regression from v1.44.5)
The card signs content urls because a browser cannot authenticate an <image
href>. But _display() was called inside _buildModel(), and the space model is
memoized on the config fingerprint — so the UNSIGNED url froze in the cache and
the signature, which did arrive, never reached the element. The plan never
loaded, and the browser kept hitting the unsigned path: 401, which Home
Assistant reports as a failed login attempt from the viewer's own IP (that is
how the owner spotted it). PDF links were unaffected: they already resolved at
render time.

- _buildModel() keeps the raw plan_url; the render pass calls _display().
- _display() returns '' for an unsigned content url instead of the plain path,
  and the <image> is not emitted at all until the signature lands — no 401, no
  spurious login-attempt warning.
- _resign() replaces 'drop everything and re-request': the previous urls are
  kept until the new ones arrive, so a wall tablet never blanks.
- demo/smoke_plan_signed.mjs: reproduces on v1.44.6 (href stays ?v=..., never
  ?authSig=), passes here. TESTING.md row added.
- docs: CHANGELOG.md + CHANGELOG.ru.md + STATUS.md.
2026-07-27 15:05:46 +03:00
Matysh ebeaa5c0c6 v1.44.6: room climate counts only air temperature
After v1.44.5 read the area registry instead of visible icons, every hidden
temperature entity in the area became a candidate, including ones measuring
something other than room air. Verified against a live 60-area install: a NAS
processor temperature, kettle water, a 90 C sauna heater and a virtual
better_thermostat all leaked into room averages.

- areaClimate(): skip entity_category (diagnostic/config), skip EXCLUDED_DOMAINS
  platforms, skip entity ids naming a non-air medium (water/coolant/flow_temp/
  return_temp/target/setpoint/chip/cpu/processor/board/device_temp/batter/
  freezer/fridge/oven/kettle/boiler).
- rules.ts: kettle/thermopot -> mdi:kettle, sauna/harvia -> mdi:hot-tub, so they
  no longer fall through to the generic thermometer rule.
- test: all four real false positives asserted out, one real sensor left.
- docs: CHANGELOG.md + CHANGELOG.ru.md + STATUS.md snapshot.
2026-07-27 14:38:50 +03:00
Matysh 02ba18dc7b Merge dev: v1.44.3..v1.44.5 (B1 regression fix, audit follow-up, room climate) 2026-07-27 14:24:46 +03:00
Matysh 715a93ec61 fix v1.44.5: room climate counts hidden sensors; drop the stale room tooltip
- areaClimate() walks the HA registry for the area instead of the list
  of VISIBLE icons: a thermometer hidden by curation or by the user was
  silently dropped from the room card, tooltip and temperature fill
  (field report). Curation still filters fridges/TRVs; the auto icon is
  used on purpose so a custom marker icon cannot change what a device
  measures; an explicit per-room source still wins
- room tooltip no longer says 'open the area' — room clicks were removed
  in v1.40.1 (the link icon does it)
- +1 unit test (120); both changelogs updated
2026-07-27 14:21:50 +03:00
Matysh 09b0ba41a5 fix v1.44.4: audit follow-up B2, B5, L4 sub-item
B2: the HTTP upload view failed OPEN when the config entry was
unavailable while the WS path failed closed — both now share one
may_write() policy helper (new auth.py) that denies non-admins when the
policy cannot be read.

B5: _finite now guards room rects, polygon vertices, view_box and
opening coordinates, not just layout positions; the declared
MAX_OPENINGS cap is finally enforced.

L4 (sub-item): every drag pipeline captures the pointer through the
tolerant helper (an inactive pointerId used to kill device/label/resize
drags); decor shapes gained a bounds clamp so they cannot be dragged far
outside the plan and persisted there.

+2 backend tests (16); both changelogs updated in this commit
2026-07-27 14:14:25 +03:00
Matysh 0467cee98a fix v1.44.3: signed content paths — plans and PDFs load again (B1 regression)
The v1.43.0 auth fix closed the hole but left the DISPLAY path
unauthenticated: HA authenticates by a Bearer header or an authSig
signed path, and an <image href> / <a href> sends neither, so plan
backgrounds and manual links returned 401. Reproduced live before the
fix (fetch 401, Image onerror).

- new WS houseplan/content/sign mints async_sign_path urls (24 h,
  bound to the connection's refresh token, only for our own endpoint)
- the card resolves display urls through _display(): signed when known,
  requests a batched signature otherwise, re-renders when it lands, and
  drops all signatures every 12 h so long-lived wall tablets stay valid
- houseplan-space-card signs its background too
- backend test asserts the unsigned url is refused and the signed one
  returns the bytes WITHOUT an Authorization header
2026-07-27 14:08:29 +03:00
Matysh c0653dfc73 docs: add docs/CHANGELOG.ru.md (Russian changelog from v1.42.0)
- 10 most recent releases translated; older entries stay English-only
- policy updated in STATUS.md and CONTRIBUTING: user-visible changes go
  into BOTH changelogs in the same commit (the user base is largely
  Russian-speaking — see the Telegram chat)
- cross-links between the two files and from both READMEs
2026-07-27 13:57:48 +03:00
36 changed files with 1874 additions and 166 deletions
+6
View File
@@ -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
+2
View File
@@ -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 &
+1
View File
@@ -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** — с ней разбираться сильно
+28
View File
@@ -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
+6 -1
View File
@@ -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
+3 -7
View File
@@ -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
+1 -1
View File
@@ -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"
}
+11 -11
View File
@@ -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.
+121 -26
View File
@@ -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})
+76
View File
@@ -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);
+76
View File
@@ -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);
+70
View File
@@ -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);
+69
View File
@@ -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);
+76
View File
@@ -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);
+6 -1
View File
@@ -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
+25 -25
View File
File diff suppressed because one or more lines are too long
+26 -1
View File
@@ -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/`.
+130
View File
@@ -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
+314
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
-1
View File
@@ -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",
-1
View File
@@ -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": "Модель",
+42
View File
@@ -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/')) {
+2
View File
@@ -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' },
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+144 -1
View File
@@ -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"] == {}
+33
View File
@@ -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}]})