v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3

R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.

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

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

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

Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-27 21:08:34 +03:00
parent 14cc4df4bd
commit 5d2dbb1009
22 changed files with 814 additions and 110 deletions
+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/`.
+33
View File
@@ -1,5 +1,38 @@
# 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
+33
View File
@@ -6,6 +6,39 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. 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`
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.44.8** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.45.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| 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 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.44.8** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| 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 |
+14
View File
@@ -234,6 +234,20 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
are only reachable through /api/houseplan/content/… with a session; the
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`,