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.
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
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.
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.
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.
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.
- 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
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
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
- 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
CR-1: the lock invariant is restated precisely (never by an accidental
tap; the door card's labeled button is the ONE sanctioned surface),
unlocking now confirms, and smoke_lock_invariant exercises all five
actuation paths (icon tap, controls[], card entities, _cardToggle,
opening card).
CR-2: attachment migration is transactional — the server COPIES files,
the config is committed with its revision check, and only then the old
folder is removed via the new houseplan/files/cleanup. A rejected save
no longer leaves the stored urls pointing at an emptied folder.
CR-3: migrate returns an exact {source: written} mapping; only confirmed
copies are rewritten, destination name collisions get a unique name
instead of silently linking a pre-existing file, and a failed migration
raises a toast instead of being swallowed.
+1 unit test (119), +1 backend test, +1 smoke (51 total); docs
same-commit
- badges + header line in README.md / README.ru.md
- 'Getting help & sharing your plan' section in both READMEs, asking for
the version number when reporting (console banner / integration page)
- .github/ISSUE_TEMPLATE/config.yml contact links (chat + discussions)
- CONTRIBUTING 'Where to ask'; STATUS (community row) and SCOPE (field
feedback source)
- device card opens with controllable entities: toggles inline (finger
targets), cover/lock/climate hand off to HA more-info; metadata and
manuals moved below; config/diagnostic entities filtered; locks still
never toggle from a card
- marker.is_light: a smart switch driving dumb fixtures glows in the
light-sources fill (its own entity or the bound controls) — no
light-group helper needed
- backend schema; smoke_card_controls.mjs, smoke_glow extended; docs
same-commit
- the room gear became a fixed-size pill button (was 0.9em/60% opacity
inside the label — invisible in practice, field report); shown on
unnamed rooms too, which is where you name them
- metrics line 0.62em -> 0.75em (unreadable on tablets)
- tooltips: latch on the first touch/pen pointer event instead of
trusting (hover: none) alone; any touch drops an open tip
- smoke_feedback_v2.mjs; docs same-commit
- upload/plan_set tests asserted the old public /houseplan_files/... paths
and only run in CI, so the B1 change surfaced there
- +test for the fail-closed admin check (audit B2/T4: the authorization
boundary had zero coverage)
T1: demo/serve.mjs exports check/checkAll/finish — all 48 smokes now
assert named facts and exit non-zero on a mismatch or an uncaught
in-card exception (verified by breaking the kiosk guard on purpose).
Informational values were frozen from a v1.43.1 run and cross-read
against the source; timings assert budgets, not exact numbers.
T2: new CI job 'smoke' gated on 'frontend', builds a FRESH bundle
before running (the committed demo/srv/assets copy is a snapshot) and
uploads per-file logs on failure.
T3: [auto] now means 'a named failing check exists' and each line names
it (43 lines); 72 aspirational markers honestly downgraded to [manual].
Fixed the 'ZERO edit buttons' contradiction (wrong since v1.30.1) and
the opening-click line (true again since v1.43.1).
Three smokes carried pre-v1.39.0/v1.25 expectations and were testing
old behaviour: tap defaults for lights, card-wide tap action, label drag
requiring plan mode.
DEVELOPMENT.md documents the harness contract.
L1: memoized space model + open pairs (structural fingerprint key, epoch
bumped synchronously at mutation time, not inside the debounce); hoisted
per-room geometry out of the render loop; smoke asserts zero recomputation
across state pushes.
L4: openings get the 3 px drag threshold used by every other pipeline and
only write when the geometry actually changed — taps open the dialog again.
G2: interiorPoint() replaces the vertex mean, so island rooms inside
concave (U/L) parents are accepted and their evenodd holes render; traced
duplicates still are not containment.
G3: segKey rounds before ordering — one shared wall, one key.
B2: _check_write fails closed when the entry is unavailable.
B3: layout/set honours expected_rev and returns the new rev.
B4: config/set without expected_rev over a non-empty store logs a warning.
B5: coordinates reject NaN/Infinity; spaces/rooms/markers/decor/layout capped.
+2 unit tests (118), +2 backend tests (14), smoke_render_perf; docs
same-commit
L2 (silent data loss): debounce gains flush()/pending(); _reloadConfigOnly
flushes a pending write and defers while one is in flight; conflict path
forces; failed reload now toasts instead of an empty catch; teardown flushes.
G1 (split corruption): same-edge cuts carve the niche properly instead of
walking the outline twice; partition invariant (parts sum to the original)
rejects anything else; +1 unit test covering 5 niche shapes and both legacy
cut shapes.
B1 (unauthenticated content): plans and marker files move to
HouseplanContentView (/api/houseplan/content/..., requires_auth); only the
card bundle stays static; contentUrl() rewrites legacy URLs on read (no
storage migration); repairs.py accepts both prefixes; +1 unit test.
L3 (dialog zombies): all four save catch-blocks guard against a closed
dialog; the card no longer blanks when a save fails after Esc.
smokes: smoke_save_race, smoke_dialog_zombie; docs (TESTING/CHANGELOG/
ARCHITECTURE incl. the optimistic-UI note) same-commit
- taps on tablets synthesized mousemove and popped the tooltip over the
finger (user feedback item 5); _showTip gated by (hover: none)
- smoke_touch_tips.mjs (matchMedia shim); TESTING/CHANGELOG same-commit
- space.settings.card_font_scale (tier 2 base) + room.settings
name_scale/label_scale (tier 3), 50-300%, multiplied together and
stacking with resize-k and kiosk per-screen multipliers
- live sample card in both the space and room dialogs
- backend schema; smoke_font_scales.mjs (9 checks); docs same-commit
- four-tier principle fixed in ARCHITECTURE: global > space > room >
device, specific overrides general, unset inherits
- room.settings { fill_mode, temp_source, hum_source } with backend
schema; pure roomFillModeOf + sourceValue (+2 test suites, 114)
- gear on room cards in the Plan editor opens Room settings: rename,
re-area (current area included in the list), fill override (may opt
out of glow darkness), explicit temp/hum source with a searchable
device+entity dropdown; the same section in the creation dialog
- source feeds the room card, tooltip and temp fill; works for rooms
without an HA area (user-feedback case #1)
- smoke_room_settings.mjs (10 checks); TESTING/CHANGELOG/ARCHITECTURE
same-commit
- new WS houseplan/files/migrate moves /files/<oldId>/ to the new id
(admin-only, sanitized ids, merge-safe, removes the empty old dir)
- _saveMarker calls it and rewrites pdf urls (migratePdfUrls pure
helper, +1 unit test, 112) when rebinding changes the id
- field incident: the sauna heater manuals pointed at an orphaned
old-id folder that got cleaned up; data restored by hand on the
home instance (official Harvia PDFs re-downloaded)
- TESTING/CHANGELOG same-commit
- rooms inert in View (default cursor); mdi:open-in-new after the room
name (View, rooms with an area) navigates to the HA area
- smoke_room_link.mjs (7 checks); TESTING/CHANGELOG/UX-MODES same-commit
- alignGuides/segmentAngle/is45 pure helpers (+2 unit tests, 108);
per-context candidates (room vertices + path pts / other icons /
decor endpoints+corners / other room cards), nearest per axis, max
two guides, dashed accent lines with a source dot
- cursor badge shows length + angle, green on 45deg multiples
- indication only, no magnetism (owner's decision); guides live in
plan/devices/decor, never in View
- smoke_align_guides.mjs (9 checks); TESTING/CHANGELOG same-commit
- the markup layer's .seg lines ran solid through open stretches;
cutSegments extracted (outlineWithout now reuses it) and applied to
_segments in _renderMarkupLayer
- smoke_openwall: planSegCut check (15 total); docs same-commit
- LS_NAV stores {space, mode}; restored in setConfig from the cached
config, with a retry after the live config when the cache was stale;
deep-link hash wins; edit modes restored for admins only
- UX-MODES updated (owner reversed the 'always start in View' rule)
- smoke_nav_persist.mjs; TESTING/CHANGELOG same-commit
- Virtual / Pick-from-HA radios; Show-entities checkbox with tooltip
gates device entities (groups/helpers always listed); searchable
dropdown only in HA mode, auto-opens when empty, closes on pick;
Save guarded until a binding is chosen; logic untouched
- smoke_binding_ui.mjs (16 checks); marker_stay/controls smokes green;
TESTING/CHANGELOG same-commit
- outlineWithout trims the rooms' solid strokes under open stretches
(.room.noedge kills the polygon stroke incl. hover; trimmed
.room-outline path draws the remaining walls)
- dash color follows the space stroke color; openwalls layer moved
after the glow layer
- +1 unit test (105); smoke_openwall extended; docs same-commit
- _openWallHit shared by click and hover; amber dashed preview of the
stretch that would open, red solid when the click would close it;
stage.wallhot drives the pointer cursor
- smoke_openwall_hover.mjs (9 checks); TESTING/CHANGELOG same-commit
- room.open_to symmetric links; 'Open boundary' plan tool toggles the
shared wall pair under the click (sharedBoundary collinear-overlap
math + distToSegment pull, same as Split); dashed rendering with an
amber hot state while the tool is active; Esc -> Draw
- glow: the clip becomes the transitive open zone (openZoneOf BFS,
either-direction links) + door sectors from the zone's outer walls
- model rooms carry open_to; backend ROOM_SCHEMA open_to: [str]
- +3 unit tests (104), smoke_openwall.mjs (8 checks); docs same-commit
- room outline + sectors as separate clipPath children (always union)
instead of subpaths of one nonzero path where opposite windings
cancelled the overlap; smoke asserts one contour per path
- TESTING/CHANGELOG same-commit