Compare commits

...
170 Commits
Author SHA1 Message Date
Matysh a8d40e4d99 v1.55.3
Validate / smoke (push) Failing after 12m47s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 2m24s
Validate / backend (push) Failing after 7m57s
2026-08-02 15:01:37 +03:00
Matysh 63a8274623 AUD-1552-01/02: the v1.55.2 boot-veil audit findings, fixed with regressions
The v1.55.2 recheck (verdict NEEDS FIX) found two lifecycle holes in the
first-open boot veil (HP-1552); both are closed here and covered by
demo/smoke_preloader_lifecycle.mjs, which fails on v1.55.2.

AUD-1552-01 (high): disconnect/reconnect while booting hid the plan
FOREVER. disconnectedCallback cleared the boot timer but kept its
truthy id, so 'updated()' never restarted the watcher. Now the id is
nulled on disconnect and connectedCallback restarts the whole veil
lifecycle: a fresh watch (fresh clock, BOOT_MAX_MS hard cap) while
booting, the tail timers when detached mid-fade or mid-grace.

AUD-1552-02 (medium): two equal reads at 200/400 ms revealed the plan
at ~400 ms, so HA chrome landing at 450+ ms jumped on a VISIBLE plan.
The veil now holds a full protective window (BOOT_MIN_MS=700 — the old
600 ms plus a frame-latency margin: a shift applied at ~590 ms only
materializes in the stage height a couple frames later) with 100 ms
sampling and trailing quiescence (BOOT_QUIET_MS=250 restarts on every
height change), capped by BOOT_MAX_MS=1200. After the reveal a short
soft grace (BOOT_SOFT_MS=1500, .stage.hpsettle) turns later passive
shifts into a 0.25 s height glide — the viewport ResizeObserver refits
the plan along the transition; deliberate height changes (_setMode into
an editor) cancel the grace so the plan never drifts under the pointer.
Reduced motion disables the glide.

Regressions (demo/smoke_preloader_lifecycle.mjs, all FAIL on v1.55.2):
- A: detach before the first tick -> reattach -> veil lifts within the
  cap, plan visible, no hpboot class, no zombie veil after a mid-fade
  remount either;
- B: parameterized layout shifts at 150/300/450/590 ms -> not a single
  frame shows the plan at a non-final stage height;
- C: a shift after the reveal glides (>=3 intermediate frames), no snap.

smoke_modes.mjs now strips the transient hpsettle class from its exact
stage-class assertions. Docs: CHANGELOG en+ru, STATUS.
2026-08-02 14:56:58 +03:00
Matysh 1a75ee1ddf v1.55.2 2026-08-02 12:54:36 +03:00
Matysh e81e841ed6 v1.55.2 2026-08-02 12:50:43 +03:00
Matysh 3e23ff58c3 ui(resize): wall handles — half-size wall-with-arrows glyph, grab cursor
Validate / hacs (push) Failing after 10s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m22s
Validate / backend (push) Failing after 4m37s
Validate / smoke (push) Failing after 6m17s
Owner request: in the resize tool make the wall-drag handles twice
smaller, replace the circle with a 'wall + two opposite arrows' icon,
drag cursor.

- visible glyph: wall segment with two arrows perpendicular to the
  edge (the drag directions), rotated per wall orientation; accent ink
  over a --hp-bg halo, readable on any plan
- HIT area unchanged: an invisible circle of the original finger-sized
  radius keeps touch targets and the HP-1550-04 hit priority over
  openings (04_handle_wins_hit_test still passes)
- cursor: grab on hover, grabbing while dragging (:active)
- scale-frame corner handles untouched (classic filled circles,
  nwse-resize)
- docs/RESIZE.md: handle appearance updated
2026-08-02 11:50:49 +03:00
Codex 21786ed5a1 fix: first-open boot veil — no residual jump under HA panels (HP-1552)
In normal (non-kiosk) mode the stage is calc(100dvh - _hdrH), and _hdrH is
measured from HA's chrome, which finishes loading AFTER the card's first
paint — late panels nudged the height and the freshly painted plan visibly
jumped (kiosk is a flat 100dvh, hence 'perfect in kiosk').

Fix, per the owner's sketch:
- until the stage height reads the same twice in a row (200 ms cadence) or
  a hard ~600 ms cap, the plan stays hidden (.hpboot) and is revealed only
  after a refit — not a single frame paints at a stale height;
- that window is covered by a quiet product-style preloader: dark veil,
  small pulsing outline house (the card's own mdi:home-city outline,
  inline SVG), no text, 0.15 s opacity fade-out;
- prefers-reduced-motion gets a static house; kiosk never shows the veil;
  first open of the card instance only — floor/mode switches are untouched.

smoke_preloader simulates an HA panel landing at t=300 ms and asserts the
veil, zero plan-visible frames at a non-final height, the kiosk opt-out and
the reduced-motion variant; serve.mjs starts every smoke after the reveal,
where the user starts.
2026-08-02 10:49:15 +03:00
Codex 6414873fb0 feat: background color around the plan — global + per-space (HP-1554)
New setting 'background around the plan' (#rrggbb):
- global: config.settings.bg_color, edited in the gear dialog with a live
  preview and a 'theme default' reset (empty = keep the stylesheet default);
- per-space override in the space dialog next to the room colors, empty =
  inherit the general setting (the show_lqi/fill_mode pattern);
- applied to the stage in view and kiosk modes (editors keep their own
  canvas) and to the static houseplan-space-card;
- backend validates both keys with the same strict #rrggbb match as
  room_color; garbage strings are rejected (test_bg_color_setting).
smoke_bg_color covers apply/override/inherit/reset, dialog previews, the
wire format of the cleared override, kiosk and the static card.
2026-08-02 10:48:51 +03:00
Codex 941d2709fd fix: round caps/joins on background-editor lines (HP-1553)
Lines drawn in the background (decor) editor showed notched 'teeth' where
two segments met at an angle. Decor <line> elements — both the saved render
and the live drawing preview — now carry stroke-linecap/linejoin=round, so
every line end reads as a circle of the stroke width and joints are smooth.
smoke_decor asserts the round attributes on the draft and the saved lines.
2026-08-02 10:47:49 +03:00
Matysh 9bcfebe8f4 fix: no default-fit flash when opening with a saved zoom (HP-1551)
The cached config (LS_CFG) painted its first frames with _zoom=1 because
the saved per-space zoom was applied only by _restoreZoom()'s rAF after
the server config round-trip - the plan visibly jumped to the saved scale.

- setConfig now arms _zoom from LS_ZOOM for the resolved space before the
  first view computation (view mode, no established view only);
- _restoreZoom applies the view synchronously when the stage is already
  measured (space tabs, kiosk carousel, editor exit included); the rAF
  path remains only for an unmeasured stage, where updated() already fits
  with the correct zoom before the first paint.

New smoke_zoom_flash.mjs samples every rAF frame from the first possible
moment with a primed config cache, saved zoom 1.8 and a 350 ms server
delay: a visible stage with a default-scale viewBox fails the run
(22 such frames before the fix, 0 after).
2026-08-02 10:08:04 +03:00
Matysh 8b439ea0f3 v1.55.1
Validate / smoke (push) Failing after 11m44s
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 10s
Validate / frontend (push) Successful in 2m20s
Validate / backend (push) Failing after 7m27s
2026-08-01 19:01:35 +03:00
Matysh b7d5cb8a8f v1.55.1 2026-08-01 18:58:23 +03:00
Matysh 84f8bcf0e6 HP-1550-01..04: the v1.55.0 resize audit findings, fixed with regressions
01 (high): the live resize preview no longer touches _serverCfg — it lives in
the _rszPreview overlay served to renders via _curSpaceCfg/_renderCfg, so a
debounced write queued from a previous edit can never carry mid-drag geometry
to the server; commit happens once, on pointerup; Esc just drops the overlay.
03: pointercancel/lostpointercapture take the cancel path (no commit, no undo
step, no write) for edge and corner handles alike.
04: in the resize tool the wall handles own the hit test — the transparent
.op-hit is inert and the resize layer renders above the openings; a door at a
wall midpoint no longer shadows the handle, other tools unchanged.
02: the 30 cm floor is orientation-independent — minSpanClearance (band sweep
of the moved stretch) for wall drags, minPolyWidth (calipers) for the scale
frame; already-thin rooms may improve, never worsen.
2026-08-01 18:58:23 +03:00
Matysh 10927ffaed v1.55.0 2026-08-01 15:48:24 +03:00
Matysh 6ff79106b8 v1.55.0 2026-08-01 15:45:14 +03:00
Matysh f49ac613b5 room resize: smoke (numeric vertex asserts) + TESTING.md checklist
demo/smoke_room_resize.mjs drives real pointer events over the handles:
T-stack drag (r1 grows, r2 translates, r3 becomes a 6-vertex L — checked
numerically), live badges appear and change, opening rides the wall,
neighbour 30 cm stop, opening-anchor stop, scale frame proportional with
static neighbours and a neighbour stop, Esc-cancel, one-step undo, no
handles in any other tool/mode. Fails on the pre-feature blob (verified).
2026-08-01 13:48:43 +03:00
Matysh ec58b0602a room resize: the 'Размер' tool in the Plan editor (docs/RESIZE.md)
Wall-midpoint handles for every room (finger-sized, pointer-captured, no
stage-pan conflict); dragging moves the wall along its normal with live
preview through the config snapshot, shared stretches drag the neighbour
(T-junctions insert vertices), openings on the wall travel along. Click a
room for the corner scale frame (uniform, about the opposite corner,
neighbours never dragged). Live badges: dragged+adjacent wall lengths and
m² per reshaped room. Grid snap, Esc cancels the drag, Ctrl+Z pops the
resize undo stack (one release = one step). Legacy rects are saved back
as polygons; three blob copies rebuilt.
2026-08-01 13:48:43 +03:00
Matysh 65070c0520 room resize: spec (docs/RESIZE.md) + pure geometry in src/resize.ts with unit tests
Mechanism A (wall drag along its normal, shared stretches of neighbours move
together, T-junctions insert vertices) and mechanism B (corner scale frame)
with every stop: min room size ~30 cm, self-intersection, foreign rooms
(polyclip area check — roomsOverlap alone misses collinear slide-over),
islands, opening anchors. node:test units pin each stop numerically.
2026-08-01 13:36:35 +03:00
Matysh fa32afa9f3 HP-1543: editor-exit zoom after cross-floor switch; motion flash restart on rapid retrip
HP-1543-01 (medium): exiting an editor AFTER switching floors inside it left
the editor working zoom on screen in view mode — the snapshot guard
(snap.space) dropped the restore and nothing else put the viewport back.
A space-mismatched exit now falls back to _restoreZoom() of the current
floor's saved view zoom (per-space store/LS_ZOOM were never polluted, the
view-only _saveZoom guard is untouched). Regression: crossFloor* asserts in
demo/smoke_zoom_out.mjs — red on v1.54.3, green now.

HP-1543-02 (low): a rapid off->on retrip before the current flash ended did
not restart the CSS animation: the senseflash class and animation-name never
changed, so the browser kept the old timeline and the second detection played
nothing once the first one-shot had finished (base opacity 0). Every
witnessed trip now bumps a generation counter; its parity alternates the
identical keyframes hp-sense / hp-sense-b via the .sf2 marker — a new
animation identity forces a fresh timeline per detection. flashTs and the
window timer re-arm as before; prefers-reduced-motion keeps the static ring
(retrip only extends the window). Regression: rapid* asserts in
demo/smoke_motion_sense.mjs — red on v1.54.3, green now.
2026-08-01 13:09:55 +03:00
Matysh adaed9be7c v1.54.3 2026-08-01 12:40:39 +03:00
Matysh b508c20ad3 v1.54.3 2026-08-01 12:37:24 +03:00
Matysh ca07579b63 motion sensors: one-shot flash on detection; occupancy/presence hold a static ring
Owner's contract (2026-08-01, вариант «б») replaces yesterday's 'sense' class
(29dae45, lived <1 day, stored nowhere — no migration):

- MOTION (device_class motion): a SHORT one-shot flash at the off→on
  transition — 3 beats of the yellow ring (hp-sense 1.1s x3, ~3.3s), then
  silence even while the entity still reports 'on': «движение = разовая
  вспышка в момент обнаружения; cool-down не пульсирует». A new off→on trip
  flashes again. Tracking lives in _senseRt (markerId → last state + flash
  ts), stamped by _senseTick on the hass tick (the _vacTick pattern); one
  setTimeout per entry repaints the card when the window closes so the
  'senseflash' class is dropped without a state change (cleared in
  disconnectedCallback). First sight already-'on' and unavailable→on do not
  flash — not a witnessed detection.
- OCCUPANCY/PRESENCE while 'on': 'sensehold' — a STATIC yellow ring, no
  animation, opacity 0.4 (the reduced-motion brightness): «присутствие =
  статичное кольцо пока обитаемо».
- Unchanged: the yellow FILL stays reserved for «включено», alarm's red ring
  wins on the shared ::after, ghosts draw nothing.
- prefers-reduced-motion: the flash becomes a static ring for the same ~3.3s
  window (consistent with alarm); sensehold is static by design.

Smoke smoke_motion_sense.mjs rewritten to the new contract (13 asserts fail
on the pre-fix bundle): finite iteration count '3', flash gone after 3.6s
while state is still 'on' (the key assert), re-trip flashes again,
occupancy/presence static ring, neutral badge, hidden ghost inert.
shot_motion_sense.mjs now cycles off→on to arm the flash.
2026-08-01 12:17:17 +03:00
Matysh 29dae45e2a motion sensors: tripped motion/occupancy/presence pulses a soft yellow ring
Validate / hacs (push) Failing after 42s
Validate / hassfest (push) Failing after 40s
Validate / frontend (push) Successful in 1m33s
Validate / backend (push) Failing after 6m16s
Validate / smoke (push) Successful in 5m8s
Owner's rule (2026-08-01): the yellow FILL is reserved for 'on' — a tripped
sensor keeps the neutral badge and gets a new 'sense' state class instead:
a gentle .dev.sense::after ring in the .dev.on yellow (--hp-on), 2.4s period,
max opacity 0.5 — the soft sibling of the red .dev.alarm siren. Declared
before .dev.alarm so red wins on the shared ::after if both ever apply;
ghosts don't pulse (hidden gate already strips the state class).
prefers-reduced-motion: static faint ring, same treatment as alarm.

Smoke smoke_motion_sense.mjs (fails on the pre-fix bundle): sense class on/off,
::after animation, neutral background (not --hp-on), occupancy/presence,
hidden ghost. shot_motion_sense.mjs captures the ring for review.
2026-08-01 11:54:23 +03:00
Matysh 88008961e3 zoom: _saveZoom is view-only — editor 500% can no longer leak into the per-space store
The exit-editor restore re-saved the view zoom from a rAF; on a slow
tablet the floor-tab click lands before that rAF (input runs first in
the frame), the snap.space guard skipped the fix-up save and the editor
wheel zoom (500%) stayed in _zoomBySpace/LS_ZOOM — the second floor
switch brought it back into view mode (owner's dacha report). Now
_saveZoom simply refuses to write while _mode is not 'view': the wheel
inside the editors keeps zooming but never touches the per-space view
store, so no fix-up is needed at all. The snapshot restore keeps
bringing the pre-editor viewport back. Smoke: smoke_zoom_out.mjs grows
the owner's exact scenario (both floors zoomed in view, editor 5.0,
same-tick switch after exit, two floor switches) — red before, green
now; the 6d16f69 asserts stay green.
2026-08-01 11:31:18 +03:00
Matysh 6d16f69f38 zoom: editor zoom is a working tool — leaving any editor restores the view-mode viewport
Entering an editor snapshots the view-mode zoom+center (per space);
leaving back to view brings it back and re-saves it to LS_ZOOM, so wheel
zoom done inside the editors no longer leaks into the viewing zoom.
Editor-to-editor switches and the per-space view zoom keep working as
before. Smoke: smoke_zoom_out.mjs grows the 1.6 -> editor 2.5 -> 1.6
scenario (zoom, center, LS), the no-touch no-jump case and the
space-switch persistence guard.
2026-08-01 11:04:19 +03:00
Matysh 92ea8fa03c device satellites: the size multiplier scales the value plate and labels too
Owner report (2026-08-01, screenshot): a device scaled up via marker.size
kept its value badge at the default size — an enlarged icon next to a tiny
'26.6°'. Same bug class as the v1.51.3 glyph fix (3456706): satellite
metrics were pinned to the base --icon-size, so --dev-scale grew the box
but nothing that belongs to it visually.

All .dev satellites now derive from var(--dev-size, var(--icon-size,
2.5cqw)) — which equals icon-size * dev-scale — keeping the exact same
coefficients, so at size=1 nothing changes pixel-wise:

- .valonly plate padding (0.16) and .valtext font (0.45)
- .tval/.hval plates: margin (0.1), radius (0.18), padding (0.14),
  line-height (0.68) — font was already dev-size, the plate was not
- .lqi label: margin (0.05), font (0.38)
- .newdot: offsets (-0.12), diameter (0.34)
- .alarm ring inset (-0.35)

smoke_icon_scale extended: value badge and temp plate must double at
size=2 (getBoundingClientRect + computed font-size, 1.8-2.2 tolerance)
and keep the exact old defaults at size=1; verified failing on the
pre-fix build. space-card renders bare icons only, unaffected.
2026-08-01 10:27:34 +03:00
Matysh 6743b6efc4 hassfest: services.yaml for the stand-only demo_guard
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m35s
Validate / backend (push) Failing after 6m48s
Validate / smoke (push) Failing after 8m42s
Hassfest scans every manifest in the repo, and demo_guard (which
re-registers homeassistant.restart/stop as no-ops on the public stand)
tripped the SERVICES check. It defines no services of its own; the file
documents exactly that.
2026-07-31 18:44:08 +03:00
Matysh c9775141c9 stand: demo_guard neuters homeassistant.restart/stop for visitor-admins; console reset countdown (www/stand-reset-timer.js)
Visitors hold admin sessions (the editor demo needs is_admin), and some of
them restarted HA from the UI — exit code 100 between hourly resets looked
like stand instability. demo_guard re-registers restart/stop as no-op
services after startup; the hourly docker-level reset is untouched.
stand-reset-timer.js logs a per-minute countdown to the :00 reset in the
browser console (warn for the last 5 minutes); stand-dev-info.js is the
one-shot dev-stand note. Also on the stand host: mem_limit 1536m per
container in compose.yml as an OOM safety net.
2026-07-31 15:16:36 +03:00
Matysh 9c224e7606 ci: re-run Validate on dev
smoke_grid_fade flaked on the duplicate dev run of 93d97e1a (roomFaded
timing on a busy runner); the identical tree is green on main and on the
v1.54.2 tag. Empty commit to re-run — the PAT cannot rerun failed jobs.
2026-07-31 14:37:47 +03:00
Matysh 93d97e1a83 v1.54.2
Validate / hassfest (push) Failing after 11s
Validate / hacs (push) Failing after 13s
Validate / frontend (push) Successful in 1m39s
Validate / backend (push) Failing after 6m22s
Validate / smoke (push) Failing after 8m37s
2026-07-31 13:47:22 +03:00
Matysh fad2e87ab5 v1.54.2: HP-1541-01 — vacuum selected_map fallback obeys the not-nullish map-id contract
The v1.54.1 contract (first not-None value wins, zero is a value) covered
the source entity but not the card's fallback on the vacuum's own
selected_map: _vacMapId still used truthiness, so selected_map: 0 became
'default' on the frontend while trails.py resolve_map_id stored the run
under '0'. Calibration and server trails split across two keys and the
recorded run never rendered after reload.

The fallback is now the shared pure helper vacMapIdWithFallback (nullish
check), mirroring resolve_map_id. Cross-runtime regressions added for
selected_map = 0, '0' and '' on both sides; the frontend cases fail on the
old truthiness code.
2026-07-31 13:44:10 +03:00
Matysh 909bb6fbc7 v1.54.1 2026-07-31 13:15:12 +03:00
Matysh 4e3d8f1d53 Test harness: run async recorder regressions on a private event loop
asyncio.run() clears the thread's current-loop slot when it finishes; the
CI HA harness keeps a session event loop, so every test that followed the
new HP-1540-05 regression failed at SETUP with 'There is no current event
loop'. The pure-only local run never sees the harness and stayed green —
which is exactly how it slipped through. The regressions now spin up an
isolated loop and leave the ambient one untouched.
2026-07-31 13:12:14 +03:00
Matysh a1f7fb4161 v1.54.1: the v1.54.0 audit findings, sealed
Version 1.54.0 -> 1.54.1 in package.json, package-lock.json, manifest.json,
const.py and CARD_VERSION; rebuilt bundle in all three tracked copies
(dist/, demo/srv/assets/, custom_components/houseplan/frontend/).
Changelog entries (EN+RU) — one line per finding, HP-1540-01..06 — and
docs/STATUS.md bumped.

Suites on this exact tree: 161 frontend unit, 74 pure-backend, 74 browser
smokes — all green.
2026-07-31 13:07:37 +03:00
Matysh 257a71123a Trail recorder: zero map ids, one source per N markers, serialized refresh
Audit HP-1540-02: the recorder chose the map id with an or-chain, so a
valid numeric map_index=0 fell through to selected_map (or 'default') and
the server stored the run under a key the renderer never looks up. The
choice is now resolve_map_id() — the explicit backend half of the contract
shared with vacMapIdFromAttrs in src/vacuum.ts: the first value that is
not None wins, zero and empty string included.

HP-1540-03: pairs was a plain source -> (marker, vacuum) dict, so the
second placement of the same robot (the documented two-floor case) evicted
the first and its server history silently stopped. A source now maps to a
list of pairs, every marker gets its own copy of the run, and the state
subscription set is deduplicated.

HP-1540-05: every config/set spawns async_refresh as a detached task; two
of them interleaving across the awaited config load could both subscribe,
overwriting one unsub handle — a callback leak until HA restart. Refresh
is serialized with an asyncio.Lock and teardown flags the recorder closed
first, so a refresh parked on its await can never resubscribe afterwards.

Regressions cover map_index 0/'0'/''/selected_map cross-checks, one
source feeding two floor markers across a map switch, pair-list refresh
with a deduplicated entity set, and two overlapping refreshes leaving
exactly one live subscription (zero after teardown). On the v1.54.0
recorder 11 of these tests fail.
2026-07-31 13:07:27 +03:00
Matysh 69c5a4c41d Vacuum first-use path: materialize the marker, count rectangle rooms, fix the toasts
Audit HP-1540-01 (High): an auto-discovered vacuum has no config marker
until the device dialog is saved once, yet the live-position section was
already interactive. setVac, _vacSaveMatrix and auto-calibration all did
cfg.markers.find(...) and silently bailed out — while the auto-calibration
toast still claimed success. Every vacuum edit now materializes a minimal
marker (same id/binding the dialog Save would produce), _vacSaveMatrix
reports whether the write landed, and success toasts are gated on it.

HP-1540-04: the auto-calibration room matcher accepted only polygon rooms
and told users their room names did not match. It goes through the shared
roomPoly() now, so legacy x/y/w/h rectangles count like everywhere else.

HP-1540-06: the no-rooms/no-match/rough-fit toasts pointed at the removed
point calibration; they now point at the fit panel that shipped instead,
and docs/VACUUM.md Setup UX describes the actual UI. Also extracted
vacMapIdFromAttrs as the explicit frontend half of the map-id contract
(backend half lands with HP-1540-02).

Regressions: demo/smoke_vacuum_firstuse.mjs starts from cfg.markers=[]
(the fixture gap the audit called out) with rectangle plan rooms and a
zero map_index, and fails 11 checks on the v1.54.0 bundle; i18n unit test
asserts no point/точк wording in either language.
2026-07-31 13:07:16 +03:00
Matysh 77327c07d4 TESTING-DEMO.md: every checklist line answered with a stand recipe or an honest 'not here'
Manual testers kept asking which parts of docs/TESTING.md the public stand
can actually exercise. The answer used to live in nobody's head: the stand
was missing a vacuum, any LQI at all, toggleable leak/smoke alarms, an
hvac_action marker, and its automations/scripts/scenes YAML was never
!included - so the tap-run marker pointed at a script that did not exist.
With those gaps closed on the stand, this doc maps each checklist item to a
concrete click path on demo.houseplan.tech, and openly lists what only
local setups or real hardware can verify (broken stores, HACS flows,
non-admin users, anything that must outlive the hourly reset).
2026-07-31 12:49:02 +03:00
Matysh 18b8589438 Scripted demo-stand vacuum: the checklist needs a robot no hardware can provide
The public stand cannot run any Tier-A map integration (no radios, no
robots), which left the whole vacuum section of docs/TESTING.md untestable
outside the owner's home. demo_robot fakes exactly the surface the card
consumes: a Tasshack-shaped map sensor (dict vacuum_position, rooms named
after the plan rooms so auto-calibration has something to match, flipped Y
so the mirror default is meaningful) and a vacuum that drives a serpentine
route through every room in ~3.5 minutes and docks itself.
2026-07-31 12:48:53 +03:00
Matysh 9870b2fb98 A booting HA must not split the cleanup run
At startup the vacuum entity reads unavailable; the recorder took that
for 'stopped' and ended the open run, so every HA restart mid-cleanup
rotated the trail into previous and began a fresh one (observed live:
a 21-point run became previous and restarted at 5). Unavailable and
unknown now mean 'no verdict'.
2026-07-31 12:09:31 +03:00
Matysh 7de7cfb8c4 v1.54.0 2026-07-31 12:07:50 +03:00
Matysh 18ac9b459f Trail recorder reads object-style positions — the real-world fix
Caught live on the owner's X50 mid-cleanup with temporary logging: the
recorder saw every camera state change and rejected every one, because
server-side Tasshack keeps vacuum_position as a Point OBJECT — it only
becomes a dict when serialised to the frontend, which is why the card
adapter (and MCP inspection) always saw a dict and the stub test
faithfully reproduced the same wrong assumption. getattr fallback added,
regression test pinned, diagnostic logging removed (setup line kept at
INFO).
2026-07-31 12:04:33 +03:00
Matysh 4e355dcbd2 The stub loader no longer poisons the HA harness
test_trail_recorder injected fake homeassistant modules into sys.modules
at import time; every pytest_homeassistant_custom_component fixture in
the same session then failed with 'module homeassistant has no attribute
util'. The stubs now live inside a snapshot that is restored in a finally
block.
2026-07-31 11:54:01 +03:00
Matysh f9b612a389 v1.54.0: live robot vacuums
The plan shows the robot at work: the marker stays at its dock while a
puck drives the plan, calibration is one click or a drag-and-stretch
overlay, and the path is recorded server-side (current + previous run)
with never/cleaning/always display modes. Also: glow is the default
fill for new spaces, and the run-target search renders again.
2026-07-31 11:50:31 +03:00
Matysh d27864e90e docs: bring the whole set up to the shipped vacuum feature
TESTING gets a manual checklist matching the current contracts (modes,
teleport-on-view-change, tip glued to the icon, fit panel, multi-floor);
ARCHITECTURE gains trails.py and the trail/get command; VACUUM records
the display modes and what P1 actually shipped; README/PRODUCT/ROADMAP/
STATUS mention the feature.
2026-07-31 11:48:29 +03:00
Matysh f3bc3278e5 Trail recorder: seed a run already in progress, and test the wiring
Only TrailBook was covered; the HA-facing half — subscription callback,
attribute dialects, map-id resolution, run end on docking — had no test
at all. It does now, against a stubbed hass, which is also where the
missing behaviour showed up: recording started at the NEXT state change,
so an HA restart (or finishing calibration) mid-cleanup dropped the
opening seconds of the path. Sampling is factored out and runs once per
source on setup and on every refresh.
2026-07-31 11:47:31 +03:00
Matysh 75524d9d85 Trail modes + the tip grows glued to the icon
«Показывать путь робота» is a three-way choice now: never / while
cleaning (the default — the line hides the moment the run ends) /
always (the only mode that also shows the faded previous run).
trail_mode rides next to the legacy bool, which still maps in.

The last segment no longer pops in when the next telemetry point
arrives: a rAF sampler drags a tip line's endpoint to the puck's
animated centre every frame, so the path visually pours out strictly
from under the icon. The sampler runs only while pucks exist and stops
itself.
2026-07-31 11:30:17 +03:00
Matysh 089c5ef462 websocket_api: import DOMAIN — the trail-recorder refresh crashed config/set
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m39s
Validate / smoke (push) Failing after 29s
Validate / backend (push) Failing after 4m42s
Caught only by the CI HA harness; the pure suite never exercises the
import. NameError inside ws_config_set turned every save into
unknown_error.
2026-07-31 11:11:56 +03:00
Matysh 8dbc7db0b2 trail recorder: hass.data[DOMAIN] via setdefault
The CI harness sets the entry up without async_setup having created the
domain dict; the KeyError failed every WS test downstream.
2026-07-31 11:07:38 +03:00
Matysh 23daa28cf4 Server-side trails: the current run and one previous
The integration now records the path itself (trails.py): it watches the
source entity's state changes, so recording needs no open card, has no
multi-tab write races, and every screen sees the same line — reloads
included, which retires the localStorage snapshot after one day of
life. Stored per marker: the current run plus exactly one previous
(owner call — users want cleaned-vs-uncleaned at a glance). The
previous run renders at 40% opacity; the current one still trims its
live tail so it never outruns the puck. Runs rotate on start or map
switch, points cap at 2000 with decimation, store writes debounce 10 s,
and houseplan_trail_updated pushes live cards. TrailBook is pure under
5 backend tests; the WS command degrades silently on older backends.
2026-07-31 11:03:47 +03:00
Matysh d063453670 The trail survives a page reload
The self-recorded points now snapshot into localStorage per marker
(raw robot coords, so recalibration does not invalidate them). Restore
is gated: fresher than the linger window, same map, and never into a
run that started after the snapshot ended — the old trail must not
leak into a new cleanup. The smoke simulates the reload by wiping the
runtime map and asserts both the restore and the new-run discard.
2026-07-31 10:56:10 +03:00
Matysh f83577afa7 The trail is half as thick (owner call): casing 2.25, core 0.9 2026-07-31 10:53:19 +03:00
Matysh eddc8b41db The fit overlay is actually touchable
The devlayer is pointer-events: none and every child opts back in; the
overlay never did, so every real click fell through to the plan while
the smoke's synthetic dispatch — which skips hit-testing — kept
passing. The overlay now opts in, the corner handles grew to finger
size, and the smoke performs REAL elementFromPoint hit-tests through
the shadow root so an untouchable overlay can never pass again.
2026-07-31 10:45:36 +03:00
Matysh 1eabfeeee8 The fit panel replaces the three-point wizard
Calibration is now a direct-manipulation overlay: the robot's rooms as
a dashed translucent ghost over the plan, dragged into place and
stretched by four corner handles (uniform scale about the opposite
corner). Quarter-turn and mirror buttons re-anchor about the ghost
centre; mirror defaults on because every robot map seen so far flips Y
versus the screen — measured on the owner's X50. Everything folds into
the same stored 6-number matrix, and legacy matrices reopen in the
panel with rotation snapped to a quarter. The park-the-robot-three-
times wizard is deleted outright: it was the most fragile part of the
feature (owner: «плохо работает»). fitMatrix/fitFromMatrix/initialFit/
reanchorFit are pure and unit-tested; the smoke drives the panel end to
end — drag, corner-stretch, rotate, save, puck on the new matrix.
2026-07-31 10:38:49 +03:00
Matysh 11186371a9 The puck teleports on view changes; the trail never outruns it
Two owner reports. One: zoom, space switch or a tab return animated the
puck's left/top through the viewport change — it looked like the robot
driving across the whole plan. The view signature now forces the jump
class for that render, and a visibilitychange listener covers returning
to the browser tab. Two: a trail segment appeared the moment new
telemetry arrived, ahead of the still-gliding icon — the self-recorded
trail now lags exactly one point behind (the previous target is what
the puck has just reached), and an integration path is trimmed of its
live tail while moving.
2026-07-31 10:31:53 +03:00
Matysh 692bb91e57 Trail casing: dark halo + light core
The accent line vanished on same-hue fills. Blend modes (difference/
exclusion) all keep a blind luminance where the stroke disappears and
composite expensively on old kiosk WebViews, so the trail now uses the
cartographers' trick instead: a neutral dark halo under a light core —
one of the two always contrasts with whatever is underneath. Verified
over glow fill (dark rooms + light pools) in one screenshot.
2026-07-31 09:59:27 +03:00
Matysh 893ccff94f Centre the puck glyph
ha-icon inside the puck lacked the .dev centering recipe (flex +
line-height: 0), so the glyph sat on its text baseline and floated
around the circle. The smoke now measures the glyph centre against the
puck centre to sub-pixel tolerance.
2026-07-31 09:50:52 +03:00
Matysh d2faa070b5 No heading arrow on the puck (owner call)
The wedge looked like clutter at badge size; the trail already shows
where the robot is going. Smoke asserts its absence now.
2026-07-31 09:46:46 +03:00
Matysh f5c56d2f8e The puck is the base badge, round and 20% smaller
Owner's wording: «иконка похожа на иконку базы, только круглая и чуть
меньше» — same plate colors and shadow as a regular device badge
(var(--hp-bg)/--hp-line/--hp-txt), circle, 0.8 of the device size. The
smoke now compares the puck against a NEUTRAL badge computed-style for
plate parity — the robot's own base is yellow while cleaning and would
never match.
2026-07-31 09:43:56 +03:00
Matysh 0142d37bec Vacuum adapter vs a live Dreame X50 Master
Checked against the real robot at the dacha: room centres arrive as
plain x/y next to the bbox, and the active-map name (selected_map,
'Первый этаж'/'Второй этаж') lives on the VACUUM entity, not on the
camera — without reading it both floors would silently share one
calibration matrix. Parser and card resolver adjusted; the captured
attribute shape is now a unit fixture.
2026-07-31 09:28:20 +03:00
Matysh 40abbccbf0 Live robot vacuums, P1 (docs/VACUUM.md)
The base marker never moves — it is the dock. While the robot cleans, a
round pulsing puck (no badge plate) drives the plan over an affine
transform solved from vacuum-map coordinates: auto-calibration matches
the robot's room list against plan rooms by name, and a three-point
wizard covers integrations without room data. The trail rides the
integration's own path when offered (it predates the card being opened)
and a self-recorded thinned buffer otherwise, lingering ten minutes
after docking. Adapters read the Map Extractor / Tasshack / Valetudo
attribute dialects through one tolerant parser. Display only — no
commands, per the owner's decision.

vacuum.ts is pure logic under 8 new unit tests; the marker schema grew
an optional vacuum block (56 backend tests); smoke_vacuum drives 19
browser asserts including the wizard end to end.
2026-07-31 09:19:56 +03:00
Matysh 831e694f83 docs: the live-vacuum spec, approved scope
The base marker never moves — it is the dock. A separate round puck
(no badge plate, soft pulse) drives the plan while cleaning and
dissolves into the base on docking. All three Tier-A adapters and the
trail ship in P1; commands are out entirely.
2026-07-31 09:05:38 +03:00
Matysh b350893448 merge main (README cherry-picks) 2026-07-30 21:24:07 +03:00
Matysh fc8b6f85a4 README: a live-demo badge in the shield row
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m33s
Validate / backend (push) Failing after 5m52s
Validate / smoke (push) Successful in 4m33s
2026-07-30 21:24:04 +03:00
Matysh 6fc60e3a50 README: the live demo stand, right under the fold
demo.houseplan.tech with demo/demo — a real HA anyone can break, it heals
itself hourly. Placed above the feature list: 'try it now' converts better
than any bullet.
2026-07-30 21:24:04 +03:00
Matysh dcc0b156af README: a live-demo badge in the shield row 2026-07-30 21:13:26 +03:00
Matysh 9d56708f2d README: the live demo stand, right under the fold
demo.houseplan.tech with demo/demo — a real HA anyone can break, it heals
itself hourly. Placed above the feature list: 'try it now' converts better
than any bullet.
2026-07-30 21:13:16 +03:00
Matysh 6d8ec7b92a Glow is the default fill for new spaces, and leads the options list
Owner call: 'Свет по источникам' is the mode that sells the card, so a new
space starts with it and the settings dialog offers it first. Deliberately
NOT changed: the fallback for an absent fill_mode stays 'none'
(spaceDisplayOf), so updating the card never repaints an existing plan
whose owner made no choice. smoke_space_settings re-pinned to the new
contract.
2026-07-30 21:09:19 +03:00
Matysh a9b999b3e0 STATUS: the demo stand is live
demo.houseplan.tech (public, hourly reset to a pristine synthetic home) and
dev.houseplan.tech (closed, auto-deploys the dev branch). Deployed 2026-07-30
per the plan in houseplan-demo-stand.pdf.
2026-07-30 20:06:52 +03:00
Matysh f36d2cddad v1.53.1
Validate / smoke (push) Failing after 8m31s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m50s
Validate / backend (push) Failing after 6m15s
2026-07-30 01:50:20 +03:00
Matysh 1770ca2960 v1.53.1: the run-target search rendered into a 1px sliver
Reported by the owner minutes after v1.53.0: typing a name showed no
results. The results existed — .candlist is a scrollable box, and a
scrollable flex item inside the dialog body collapses happily: 26 matching
rows rendered inside a 1px strip. The binding dropdown never showed this
because it sits inside .droppanel, a block context.

flex: 0 0 auto + a min-height keeps it open. The smoke now MEASURES the
list and the first row instead of counting DOM nodes — counting is exactly
why it passed a build where nothing was visible.
2026-07-30 01:47:19 +03:00
Matysh 764e023996 v1.53.0 2026-07-30 01:37:00 +03:00
Matysh ce6a11f53f v1.53.0: a tap can run your automation
The owner's spec shipped in dev yesterday, released as one:
- tap action 'Run automation/script/scene' with a searchable picker,
  per-domain services, save/runtime guards for the target
- 'Ask for confirmation' checkbox guarding toggle and run alike
- covers/valves in the card-wide toggle, garage/door/gate excluded

Inventory: 148 / 52 / 43 / 72.
2026-07-30 01:34:05 +03:00
Matysh 3e976562ff tap action: run an automation, a script or a scene — with a confirm guard
Owner's spec (2026-07-29), agreed points: one 'Run' action covering the
three runnable domains of HA (a script is the idiomatic 'action' — with
automations alone people would build trigger-less dummies); the confirm
checkbox guards BOTH toggle and run; covers and valves join the card-wide
toggle so curtains work natively.

- marker.tap_action gains 'run'; marker.tap_target (schema-bounded to
  automation./script./scene. ids); marker.tap_confirm.
- the dialog: a searchable picker over the three domains (friendly name +
  kind), save refuses a run action without a target, a vanished target gets
  a warning hint; the checkbox shows for any actionable tap (explicit or
  effective-default toggle).
- the tap: automation.trigger / script.turn_on / scene.turn_on, started/
  error toasts; with confirm on — our own dialog (not window.confirm, it
  must work on a wall tablet), Esc/backdrop/Cancel = no call. The guard
  covers the controls-toggle path too.
- 'run' is explicit-only by construction: it needs a per-marker target, so
  it can never arrive as a card-wide default.
- covers: the old test pinned 'garage stays shut' — that intent survives as
  COVER_GUARDED_CLASSES (garage/door/gate stay out of the CARD-WIDE toggle;
  an explicit per-device toggle remains the owner's conscious choice).
  Locks/alarms stay forbidden everywhere, run included is not affected —
  we do not inspect automation contents, same trust as HA's own Run button.

Tests: unit resolveTapAction/runServiceFor + cover guard, backend schema
parity picks 'run' automatically + tap_target bounds, smoke_tap_run with 11
assertions (picker, search, save guard, confirm cancel/ok, per-domain
services, missing target). smoke_tap_ctx: 4 options now.
Inventory: 148 / 52 / 43 / 72.
2026-07-30 01:26:30 +03:00
Matysh f56bceef27 STATUS: fresh HACS queue numbers (2026-07-29) — 835 ahead, merge rate stalled 2026-07-30 00:49:51 +03:00
Matysh 96a01e1380 v1.52.2 2026-07-29 22:19:22 +03:00
Matysh 08a9cc7d27 v1.52.2: the v1.52.1 review (HP-1521-01, HP-1521-02)
- HP-1521-01: the plan-mode assertion looked for ANY .dev.on and the lit
  kettle satisfied it — a false positive hiding the very regression it
  guards. It targets d_lamp now (kettle asserted separately), and the
  mutation check proves it: reverting the v1.52.1 gate fails the smoke.
- HP-1521-02: the checklist entry and the _stateClass comment still said
  'yellow in every fill mode'. Both now state the two-part contract: the
  state predicate is the glow-pool condition; the renderer keeps the badge
  only where the spot is not drawn.
2026-07-29 22:16:26 +03:00
Matysh 1be79a1427 v1.52.1 2026-07-29 21:58:05 +03:00
Matysh d7c20ff6a5 v1.52.1: the v1.52.0 review (HP-1520-01/-02, HP-1513-01)
- HP-1520-01: the glow layer is hidden in the plan editor, but the yellow
  suppression still fired there — a lit lamp had NEITHER indicator. The
  gate now equals the layer's visibility (disp.fill === 'glow' &&
  !this._markup), so the badge returns exactly where the spot is absent.
- HP-1513-01: the static card ignored marker.size and marker.angle — the
  same stored marker looked different on the two cards. It mirrors
  --dev-scale and the icon rotation now; geometry only, no live dressing.
- HP-1520-02: TESTING/UX-MODES still demanded the removed RGB icon tint,
  and the lightC comment described the old use. All three brought to the
  v1.52.0 contract.

smoke_light_badges grew the editor-mode vectors; new
smoke_size_angle_parity asserts the x3 ratio inside each card (absolute px
are incomparable across containers) and rotation on both. Inventory:
147 / 51 / 43 / 71.
2026-07-29 21:55:13 +03:00
Matysh fa59767c69 v1.52.0 2026-07-29 21:34:23 +03:00
Matysh 60d6167ecd v1.52.0: one look for light sources, whatever flipped them
Owner's rule, agreed 2026-07-29 after a field report (a lamp turned off by
tap looked different from one turned off by the wall switch):

- a lamp's colour lives ONLY in its glow. The v1.27 RGB tint of the icon,
  border and shadow is deleted — that tint was the fork: with colour data
  the lamp rendered dark-with-coloured-icon, without it plain yellow, and
  the same lamp crossed the fork depending on how it was switched.
- in glow fill the indicator IS the spot: a source's badge stays standard,
  lit or not (litLightEntity — the exact condition that casts the spot —
  gates the suppression, so a lit socket keeps its yellow even in glow).
- in every other fill a lit source is plain yellow, like a heating TRV.
- icon morphing stays everywhere; the ripple colour still falls back to the
  light colour (both explicitly confirmed by the owner).

smoke_light_badges covers the whole table (8 assertions); smoke_rgb_alarm
re-asserted: no rgb class, lit lamp yellow, ripple fallback keeps the
colour. README colour language updated. Inventory: 147 / 51 / 43 / 70.
2026-07-29 21:31:30 +03:00
Matysh 110fabd038 v1.51.3 2026-07-29 21:08:58 +03:00
Matysh 3456706ef6 v1.51.3: the size multiplier reaches the glyph (user report) 2026-07-29 21:06:10 +03:00
Matysh 1310c84a32 device icon: the size multiplier scales the glyph, not just the badge
User report via the owner: change a marker's size and the icon stays at its
default size — a big empty box around a small glyph. The badge, ripple and
value badges all derive from --dev-size (base size x per-device multiplier),
but --mdc-icon-size was pinned to the BASE --icon-size, so the multiplier
never reached the glyph. One calc argument: --dev-size.

New smoke_icon_scale: at size 3 the glyph grows with the badge and keeps
the 0.62 proportion. Inventory: 147 / 51 / 43 / 69.
2026-07-29 21:03:12 +03:00
Matysh 94563d2a0c v1.51.2
Validate / hacs (push) Failing after 25s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 1m38s
Validate / smoke (push) Failing after 8m4s
Validate / backend (push) Failing after 6m43s
2026-07-29 18:03:15 +03:00
Matysh 5615afa33b v1.51.2: the v1.51.1 review (HP-1511-01, HP-1511-02)
- HP-1511-01: defaultPositions ran over different rosters — the full card
  reserves grid cells for hidden devices, the static card compacted them
  away, so an undragged marker sat in different spots on the two cards. The
  static card feeds spaceDevs (hidden included) to the shared grid and
  renders devs (visible) — exactly the split HP-1510-01 introduced for LQI.
- HP-1511-02: a hidden ripple-display marker rendered as an icon-less
  inactive pulse. A ghost drops the display dressing entirely: ripple
  presentation off, noicon off, base icon on, whatever marker.display says.

smoke_hidden_flag: the weak 'has icon OR noicon' assertion is gone — every
ghost must carry a base icon; new autoGridParity vector (vb-coordinate
comparison, the cards render in different view systems) and a ripple-ghost
vector. The demo stub got a connection.subscribeEvents so the static card's
module-level config cache can be invalidated between in-test cards.
2026-07-29 18:00:22 +03:00
Matysh 4083e14247 docs: everything caught up with v1.51.1
A sweep of every document against the shipped behaviour:

- README en+ru: the space dialog no longer claims a background is mandatory
  (draw-by-hand and the saved-plans picker exist; the canvas is square);
  'Show all devices' sections rewritten for the hide-flag world — the
  checkbox, 'Show hidden' ghosts, LQI-yes/light-no; troubleshooting updated.
- ARCHITECTURE: the config schema block still described v1.3 —
  aspect/device_overrides/virtual_devices/1000x1000-per-aspect/legacy-bundle
  fallback, all long gone. Rewritten to the current shape (square canvas,
  markers with hidden, filter_seeded, quotas, signed urls). The WS table
  dropped houseplan/file/set (removed in v1.10.0) and gained
  geometry/repair, layout/delete, files/migrate, files/cleanup,
  content/sign, the plans/list cap.
- UX-MODES: the Devices-tab tool list names the checkbox and the local
  'Show hidden' instead of the retired shared show-all.
- ROADMAP: repair-issues, system_health, floors import, data icon rules,
  click actions, theming and JSON i18n were done releases ago — checked off
  with their versions; the HACS pointer is #9004 (bot closed #8995).
- STATUS: SSH port is 22222 and the HA config root is
  /mnt/data/supervisor/homeassistant (/config does not exist there); the
  key lives in houseplan/.secrets; the PAT note reflects the fine-grained
  token; the feature surface gained the v1.42-v1.51 era.
- DEVELOPMENT: deploy instructions with the real port, path and cache
  busting.
- The owner's product description (user folder) refreshed the same way:
  square canvas, hide flags, the yellow principle.
2026-07-29 15:51:07 +03:00
Matysh 518d72fb74 v1.51.1 2026-07-29 15:16:59 +03:00
Matysh fc95a1f09b v1.51.1: the v1.51.0 review (HP-1510-01, HP-1510-02)
- HP-1510-01: the static card's visibility filter had quietly become its
  aggregation filter — the same room showed different Zigbee health on the
  two cards. Two lists now: aggregation (room LQI, temp) sees every device
  of the space including hidden ones, rendering sees visible only. Light
  fill keeps excluding hidden through areaLights itself, so the contract
  stays exactly as agreed: hidden counts toward signal, casts no light.
- HP-1510-02: the ghost suppressed state colors but still painted value
  text, temperature, humidity, the LQI badge and the state-morphed icon.
  All live numbers are gated on d.hidden now — a ghost is the base icon and
  the name, nothing else.

smoke_hidden_flag grew both audit vectors: the 42 kW value-display ghost
renders no numbers, and a room whose only Zigbee devices are hidden paints
the identical lqi fill on the full and the static card.
2026-07-29 15:14:06 +03:00
Matysh 4e736d49a3 v1.51.0 2026-07-29 14:07:45 +03:00
Matysh aa3c379540 v1.51.0: explicit hide flags, the yellow principle, phone editor gestures, room button
The dev batch since v1.50.4, released as one:
- hiding is a per-device checkbox seeded once from the old filter
  (docs/FILTERING.md); blue ghosts under a local 'Show hidden' toggle
- yellow = doing its main job now; TRVs glow by hvac_action, service
  switches can no longer become a device's primary
- pinch/pan gestures in every editor on touch
- the room settings button: visual centre (inscribed circle + centroid
  pull), icon-derived size, zooms with the plan; metrics visible in the
  plan editor

Inventory: 147 frontend / 51 pure / 43 harness / 68 smokes.
2026-07-29 14:04:49 +03:00
Matysh fa15598e67 room settings button: centroid pull breaks the plateau tie
The inscribed-circle criterion is FLAT along the long axis of any elongated
room — every midline point fits the same circle — and a plain argmax took
the first plateau sample: left of centre on the owner's kitchen-living
room, above centre in the sauna. The score now subtracts a soft pull
toward the area centroid (shoelace-weighted): on the plateau the nearest-
to-centroid point wins, while real clearance differences still dominate,
so the point never wanders into a thinner limb of an L.

Verified on a replica of the owner's floor: kitchen slab centre within a
few units, sauna dead-centre both axes. Units: wide and tall rectangles
centre on both axes; the L keeps to its slab near the centroid x.
2026-07-29 14:00:52 +03:00
Matysh 9eec856d50 room settings button: the VISUAL centre for L-shaped rooms
The owner's kitchen-living room is L-shaped, and interiorPoint() only
promises 'somewhere inside' — the button sat near the seam, visibly
off-centre. poleOfInaccessibility() (largest inscribed circle, grid search
plus one refinement pass) puts it in the middle of the widest open space:
the slab of an L, the exact centre of a rectangle or square. Cached per
poly array in a WeakMap — the memoized model keeps the arrays stable, so
the search runs once per geometry, not per render.

Unit: square -> centre; thick-slab L -> mid-slab, always inside.
2026-07-29 13:55:03 +03:00
Matysh 0bb9282edd room settings button: half size, dead-centred on the room
Owner's follow-up: the button was too large — height is now 0.77 of the
icon-size unit (half the previous), width follows through the derived font
and padding. The below-centre offset is gone: the anchor is the room's
geometric centre on BOTH axes, verified against the polygon centroids in vb
coordinates (exact match on all four demo rooms). Still zooms with the plan.

smoke_feedback_v2's gear assertions pinned the 2026-07-27 feedback sizes
(font >= 10px, box >= 18x40) — superseded by the owner's half-size order;
the contract is now 'sized from the device icon, clickable, opens the
dialog'.
2026-07-29 13:47:20 +03:00
Matysh 09f4dc4115 room settings button: room-centred, icon-sized, zooms with the plan
Owner's spec: the button is no longer glued to the room NAME (which the user
can drag anywhere) — it anchors to the geometric centre of the ROOM
(interiorPoint for polygons, so an L-shaped room gets a point actually
inside it), one button-height below centre so it never covers the name,
whose default position is that same centre. Height is 70% of a device icon
box, and since --icon-size already rescales with the view, the button zooms
with the plan instead of keeping a constant screen size (verified: x2.2 zoom
-> x2.20 button). The small metric rows under the room name (temperature,
humidity, signal, lights) now render in the plan editor too — they used to
be view-mode only.

smoke_room_cards updated: plainInPlan now asserts metrics ARE present in the
editor (the old assertion pinned the old behaviour), plus gearDetached.
2026-07-29 13:39:46 +03:00
Matysh 2551b4ea0e hidden devices: blue ghosts, no live-state paint
A hidden device and an unavailable one both rendered as translucent dark —
indistinguishable at a glance, and a lit hidden lamp still glowed yellow
through the ghost (owner's report). A ghost is CONFIGURATION, not status:

- blue dashed ghost (accent-tinted, color-mix with an rgba fallback for old
  WebViews), clearly apart from the grey 'unavailable' icon;
- no state classes, no RGB tint, no alarm pulse, no active ripple on hidden
  devices — the only thing a ghost says is 'I am hidden, click to unhide'.

smoke_hidden_flag grew two assertions: the ghost carries no state classes
and is blue/dashed.
2026-07-29 11:49:37 +03:00
Matysh 694e1e9a3b filtering: hiding is an explicit per-device flag (docs/FILTERING.md)
Agreed with the owner: whether a device is on the plan is a CHECKBOX
('Hide device from plan', every kind incl. virtual), not a runtime
algorithm. The old filter survives only as the SEEDER of those flags.

- marker.hidden is the flag; hidden devices are BUILT (room LQI counts
  them — owner's decision) but rendered only in the device editor with
  'Show hidden' on, ghosted. They cast no glow and no light fill: an
  invisible device casts no visible light (owner's decision).
- seedHiddenBindings(): non-physical devices (excluded domains, Group,
  scene, bridge, myheat children, grouped lamps) in bound areas WITHOUT a
  marker. The editing client materialises them into hidden:true stub
  markers, sets settings.filter_seeded, retires settings.show_all, and
  strips fresh-hidden ids from the red-dot list. Unticking the checkbox
  keeps a hidden:false marker — the seeder never revisits a marked device,
  so the user's decision is final. New non-physical devices hide silently;
  physical ones keep the red-dot flow.
- legacy configs (no filter_seeded) keep the OLD behaviour verbatim —
  runtime filter, shared show_all, hidden-means-gone — until an editing
  client materialises them, so a read-only tablet never sees a half-state.
- 'Show all' is renamed 'Show hidden' and is LOCAL to the tab; the shared
  settings.show_all retires with the runtime filter.
- 'Remove from plan' disappears for auto/entity devices (the checkbox is
  the way); a virtual device's Delete remains a real deletion.
- docs/FILTERING.md is the source of truth for the mechanism.

Tests: seeder/seeded/legacy/lights units (146), smoke_hidden_flag with 12
assertions (68 smokes). Inventory: 146 / 51 / 43 / 68.
2026-07-29 11:35:35 +03:00
Matysh 996a7442ec yellow means working: one principle for the glowing icon
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Failing after 1m33s
Validate / smoke (push) Skipped
Validate / backend (push) Failing after 6m17s
Research on the owner's install (verified live): the radiator heads that
glowed yellow were the ones with SCALE PROTECTION on, and the ones actually
heating stayed dark. Cause: the primary-entity search ran domains outside
tiers, and switch outranks climate — so a vendor's config switch (anti
scaling, child lock) became the device's primary, driving the color, the
icon morphing and tap-toggle alike.

The principle now: yellow = the device is doing its main job RIGHT NOW.

- primaryEntity: tiers outside, domains inside — a service entity never
  beats the visible main function; a hidden lamp still beats a visible
  config switch (grouped lights), and a plug's switch stays primary.
- climate joins the state table: yellow by hvac_action (heating/cooling/
  drying/fan) — 'which radiators are heating', not 'enabled for winter';
  the coarser state is only a fallback when the integration reports no
  action.
- one truth for light: litLightEntity() is asked by BOTH the glow pool and
  the icon color, in every fill mode — the pool and the icon can no longer
  disagree. The 'is a light source' flag keeps counting controls first.
- README (en+ru): the color table, in words.

Tests: TRV + plug primary units, litLightEntity unit, smoke_yellow_principle
(heating yellow / idle dark / off dark / fallback / lit-light wins / forced
source). Inventory: 142 / 51 / 43 / 67.
2026-07-29 11:07:34 +03:00
Matysh 098a147f87 editor: gestures work on touch — pinch zooms, a moving finger pans
The stage pointerdown bailed out whenever _markup was set, so in the plan
editor no pointer was ever tracked: no pinch, no pan — on a phone the plan
could not be zoomed or moved at all (owner's report). But drawing is
CLICK-based, so the two coexist: a finger that moves pans (and suppresses
the synthesized click so the release feeds no tool), two fingers pinch, a
clean tap still draws. Pointers that start on labels, handles, markers or
buttons stay out — those run their own drags. The tool preview keeps
following the tracked finger.

New smoke: smoke_editor_gestures (pinch in plan mode, pan without drawing,
tap still draws). Inventory: 140 / 51 / 43 / 66.
2026-07-29 10:00:44 +03:00
Matysh 14f7c06bf4 v1.50.4
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 10s
Validate / frontend (push) Successful in 1m42s
Validate / backend (push) Failing after 6m26s
Validate / smoke (push) Failing after 11m53s
2026-07-29 08:35:54 +03:00
Matysh 9f36b39379 v1.50.4: one model builder for both cards (HP-1503-01)
The full card's _buildModel() was a hand-copied twin of spaceModels(), and
the twin missed the legacy-store fallbacks v1.50.3 gave the shared builder —
the same broken store rendered recovered in the static card and as
viewBox='0 0 0 0' with negative-width rects in the main one. The divergence
of the duplicates IS the bug, so the duplicate is gone: the full card calls
spaceModels() and only swaps the raw plan url back in (its signing flow must
not bake a signed url into a memoized model — 2026-07-27).

New smoke_legacy_geometry runs the audit's exact vector (zero viewport +
negative rect) through both models and both DOM trees and asserts parity:
full-canvas fallback, normalised rectangle, no negative SVG attributes.
Inventory: 140 / 51 / 43 / 65.
2026-07-29 08:33:10 +03:00
Matysh 9da96abb05 v1.50.3 2026-07-29 08:18:47 +03:00
Matysh df65d25348 v1.50.3: sizes are not coordinates (HP-1502-01)
The ±4 bound from v1.50.2 measured view_box[2:4] and room w/h with the same
ruler as coordinates, so zero and negative sizes still passed the schema —
and viewBox='0 0 0 0' draws nothing on every client, with the static card
computing aspect-ratio: 0 / 0 on top. _EXTENT now requires strictly positive
sizes with a floor of one thousandth of the canvas (1 render unit — far
below any real room, keeps the maths finite); coordinates stay allowed to be
negative, a crop origin legitimately sits past the edge.

Defensive layer for stores that already hold a broken viewport: spaceModels
falls back to the whole canvas — both cards render from that model, so both
get the fallback — and a legacy rectangle with a negative size reads as the
same rectangle drawn from the other corner.

Also: the room settings button is the bottom row of the room card, and the
room name renders in the same spot in view and plan modes (owner's request,
committed earlier on dev).
2026-07-29 08:16:12 +03:00
Matysh 8db6b2673f room card: the name never moves, the settings button sits at the bottom
The label box is centred on the room point, so anything taking part in its
layout SHIFTS THE NAME: entering the plan editor pushed it down by the gear
button's height (owner's report). The name is the anchor now — the metrics
and the gear button hang below it as absolutes, outside the centring math —
so the name renders in exactly the same spot in view mode and in the editor,
and the settings button is the bottom row of the card. Verified by measuring
the name's vb-coordinates in both modes: identical to the pixel.
2026-07-29 08:12:51 +03:00
Matysh c9030af900 v1.50.2 2026-07-29 07:33:01 +03:00
Matysh 5392dadeaa v1.50.2: the v1.50.1 review (HP-1501-01, HP-1501-02)
- HP-1501-01: v1.50.1 bounded layout positions and left room rectangles,
  polygon vertices, view_box and opening coordinates on bare _finite — the
  same absurd-magnitude failure, one schema over. _GEOM (±4) covers them all
  now, opening angles get ±360. And because a store may already hold such a
  vertex from before the door existed, contentBounds applies its canvas
  envelope to room geometry exactly as it does to device positions: the
  point renders where it is, the frame ignores it, a space of nothing but
  absurd points falls back to the whole canvas.
- HP-1501-02: a repair matching zero positions answered ok/moved:0 and
  replaced the one-deep backup with an empty one — a typo right after
  repairing the wrong space destroyed the promised way back. Empty match is
  nothing_to_repair now: no write, no revision bump, backup intact.

Old test fixtures carried view_box [0,0,100,100] from the render-unit days;
they now use the normalised box the product actually stores.
2026-07-29 07:30:22 +03:00
Matysh aa53b33dd6 v1.50.1
Validate / smoke (push) Failing after 13m53s
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m34s
Validate / backend (push) Failing after 6m46s
2026-07-29 01:41:45 +03:00
Matysh a8ce6020f4 v1.50.1: the v1.50.0 review (HP-1500-01..03)
- HP-1500-02: the stage budget was the absolute document coordinate, so any
  tall dashboard content before the card was billed as header and the stage
  collapsed to 0px. Measure our own chrome relative to the card plus a
  bounded (<=120px) allowance for what the viewport keeps above us; re-measure
  on window resize, remove the listener in disconnectedCallback.
- HP-1500-03, both layers: contentBounds opens a near-zero axis (< ~an icon)
  up to a 200-unit floor and ignores extra points outside a canvas envelope
  (-25%..125%) for FRAMING purposes only; the server bounds layout coordinates
  to +-4 — any finite float used to pass, and one 1e100 hid the plan from
  every viewer. A thin real room keeps its tight frame; the gate sensor past
  the edge still stretches it.
- HP-1500-01: no automatic double-transform — a correct layout and a stranded
  one are indistinguishable, and guessing wrong corrupts good data. Explicit
  admin command houseplan/geometry/repair: dry_run previews, the backup rides
  the same store write, undo restores, and routine layout writes now preserve
  unrelated store keys instead of eating the backup.

Tests: contentBounds guards (unit), layout coordinate bounds + repair
lifecycle (harness), card-below-content smoke. Inventory: 139 / 49 / 42 / 64.
2026-07-29 01:39:10 +03:00
Matysh 9282c28830 v1.50.0 2026-07-28 23:54:16 +03:00
Matysh 8c5d5ba5c5 v1.50.0: the v1.49.0 review (HP-1490-01..04) and the owner's zoom batch
Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
  instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.

From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
  the first write deleted the aspects the second needed — a crash between
  them stranded the layout in the old coordinates with nothing able to
  finish it. The intent {space: old aspect} is durable now: saved to the
  layout store before anything moves, cleared by the same write that stores
  the migrated layout, each half idempotent behind its own trigger. The
  update event fires only after both halves are on disk. Proven at the exact
  crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
  nothing between them, so N parallel uploads all measured the store before
  any of them wrote. One job under a dedicated upload_lock now — narrower
  than write_lock on purpose, a directory scan must not stall config saves.
  A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
  the editors were boxed into yesterday's drawing. Edit modes measure from
  the full square; mode switches refit rather than carry a view clamped
  against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
  file's ratio. Picking a plan clears it immediately; Save awaits the
  bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.

New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
2026-07-28 23:50:59 +03:00
Matysh 6c90e03427 zoom-out, device-aware content frame, and the editor no longer shifts the plan
Three owner reports:

- The content frame behind the default zoom only looked at rooms, and devices
  are allowed to stand outside every one of them — a gate sensor by the fence
  was left outside the opening view. contentBounds() takes the marker positions
  now, and they count as content even on a space with no rooms at all.

- Entering an editor 'strangely shifted' the plan. The stage height was
  100dvh minus a hard-coded 118px of header, and the editor header is ~90px
  taller than that: the whole scene slid down by the difference and its bottom
  went below the fold. The card now measures where the stage actually starts
  (HA toolbar, margins and our header included) and gives it the rest of the
  viewport; the measurement is deferred a frame because setting state straight
  from a ResizeObserver callback trips the 'undelivered notifications' error,
  which smoke_dialog_zombie rightly counts as a page error.

- Zoom stopped at the base fit. The floor is 0.4× now, and zoomed out the
  clamp centres the content instead of pinning it to the top-left corner —
  with the view larger than the plan there is nothing to clamp against.

New smoke: smoke_zoom_out.mjs (editor keeps the stage inside the viewport,
0.4 floor, centring, the device-stretched frame); a unit test for the extra
points of contentBounds. Not released — the next release goes out after the
v1.49.0 audit.
2026-07-28 23:40:39 +03:00
Matysh 9b180c5917 changelog: describe the plan check as it ended up (new references only) 2026-07-28 22:54:00 +03:00
Matysh 3084472c75 v1.49.0 2026-07-28 22:53:35 +03:00
Matysh c00048611e HP-1470-02: only refuse a plan reference that is NEW and already broken
CI caught what the local pure suite cannot run. Four HA-harness tests store a
plan url whose file is not there — and so, sooner or later, will a user: files
disappear from outside Home Assistant, and one of them is what the 'broken plan'
repair exists to report. Refusing every write that names a missing file would
have locked the owner out of every edit, including detaching it.

So the check compares against the stored configuration and only refuses names it
has not seen before, which is exactly the pick-then-delete window it was written
for. The repairs test now attaches a real plan and removes the file behind it;
the quota test budgets from what the shared test config directory already holds
instead of assuming an empty folder.
2026-07-28 22:51:07 +03:00
Matysh f5e6c0318d v1.49.0: content-fit zoom, swipe animation, wording, and the v1.47.0 review
Owner's batch:
- zoom now opens on what is DRAWN (rooms + 5% margin) for spaces with no
  background image; with one the image is the plan and still fits whole. A small
  plan on the square canvas no longer opens as a speck.
- swiping between spaces, and the kiosk carousel, slide sideways; honours
  prefers-reduced-motion.
- the room settings button reads 'Room settings' and lightens on hover.
- 'curation' is filtering everywhere: UI strings, docs, code.

Checked the yard while I was there: its drawing sits off-centre because it was
drawn that way — before the migration x spanned 0.12..0.54 with 0.12 and 0.46 of
margin. The migration added 0.1465 on each side, symmetrically. Content-fit zoom
makes it moot anyway.

From the v1.47.0 review:
- HP-1470-02: the picker let you delete the plan you had just selected — it is
  not in the stored config yet, so the server rightly called it free, and the
  save then stored a url with no file. The button is disabled, and since two
  clients can do this in either order, config/set now verifies every internal
  plan url against the disk under the write lock and answers .
  External and legacy urls are not ours to police.
- HP-1470-01: growth is bounded at the door rather than by deleting old files —
  that mistake cost real plans twice. check_quota refuses an upload that would
  push the store past 256 MB / 200 plans (1 GB / 1000 attachments) or leave less
  than 512 MB free. The plan list is capped at 60 newest with a total, and
  thumbnails load lazily.
- HP-1470-03: picking a saved plan waited for nothing and stored a fallback
  ratio when the signature had not arrived — a square plan came out stretched.
  It waits for the signature, binds the result to the dialog that asked, and the
  dialog preview is signed too.
- report §5: the last lifecycle comments still described age-based collection.

Not released yet — the owner asked for a release once the batch is done.
2026-07-28 22:44:09 +03:00
Matysh e1e730560d fix: the migrated viewport must be the whole square, not the old rectangle
Seen on the live instance: in the editors the dot grid covered only part of the
canvas. The grid is drawn over the space's view_box, and I transformed that box
along with everything else — so it still described the old plan area, and the
margins the square canvas had just added were outside it. Nothing to draw on,
which is precisely the room the change exists to give.

The viewport is now reset to the full square. It is also what 'fit to screen'
fits, so the whole canvas is reachable.
2026-07-28 22:28:40 +03:00
Matysh 94b298962a v1.48.0: the canvas is always square, the plan is centred inside it
A space carried an aspect ratio, and coordinates were normalised against it: x
by the width, y by the height. Every geometric question therefore depended on a
per-space number, and picking a canvas orientation was a decision the user had
no reason to make. The render space is now NORM_W x NORM_W and a plan image is
fitted into it by its OWN ratio, centred — wide plans get margins above and
below, tall ones at the sides.

Migration (geometry_migration.py, pure and unit-tested) runs once at setup under
the write lock. Nothing about a drawing changes: the old box is padded out to a
square and every coordinate re-expressed against it — rooms as rects and
polygons, openings and their lengths, decor, view_box, and the marker positions
in the separate layout store. In render units it is a uniform scale plus an
offset, so angles and proportions are exact. cell_cm is scaled for tall plans,
because the grid pitch is a fraction of the width: without it a wall would
measure less than it does.

 is now dropped by the schema rather than accepted — a stale tab sending
it would be sending coordinates from the old normalisation too, and honouring
the field would not make them right.

The demo fixture was migrated with the same transform, so the smokes exercise
the new geometry rather than a square-native fake; six of them needed their
render-space helpers updated and one its click coordinates.

Not released — dev only, per the owner's instruction.
2026-07-28 22:20:59 +03:00
Matysh f7fe63776a Release v1.47.0
Pick a plan you already uploaded: the space dialog lists the plans stored on the
server, attaches one on click, and is the only place a plan file is deleted.
2026-07-28 21:55:24 +03:00
Matysh 01bc4f9711 test: the plans folder is shared across the module
Assert on our own two files rather than the whole listing.
2026-07-28 21:52:12 +03:00
Matysh 85491d0fea v1.47.0: pick a plan you already uploaded
Closes both findings from the v1.46.6 review with one feature, because they are
the same gap seen from two sides. HP-1466-02: a detached plan stayed on disk and
could not be re-attached from the card — the old url is nowhere in the config,
and the backend test 'proved' reattach by remembering it in a Python variable.
HP-1466-01: files kept forever with no way to see or remove them is not a
policy, it is accumulation.

New: houseplan/plans/list (name, url, size, modified, and which spaces use it)
and houseplan/plans/delete, which refuses while a space still references the
file — the stored configuration answers that, not the client. In the space
dialog, 'Already uploaded' shows the list with thumbnails; one click attaches,
reading the aspect from the image as an upload does; the trash button is the
only way a plan file is ever deleted.

That also bounds the disk without any timer, which is the part every automatic
attempt got wrong: v1.46.4 deleted detached plans, v1.46.5 raced the retry that
was about to reference an upload. The user decides, and can now see what they
are deciding about.

Docs: comments in plans.py and websocket_api.py still described the age-based
collection v1.46.6 removed (report §6); ARCHITECTURE gained the two new routes
and an explanation of why the listing is what makes 'never delete' livable.
2026-07-28 21:49:37 +03:00
Matysh d37a67c29f Release v1.46.6
Detaching a plan finally keeps the file where it matters — at the save, not just
on the scheduled pass. Transitions are classified by the space that owned the
file, and nothing is deleted for being old except a staging folder.
2026-07-28 21:25:50 +03:00
Matysh a66272c6f4 test: two HA-harness tests still asserted the old age rule
One demanded an aged upload be collected; the shared sweep fixture expected an
aged plan file to disappear. Both now assert the opposite, which is the rule.
2026-07-28 21:22:33 +03:00
Matysh f4af2fe508 fix: stop ageing files out entirely, except staging folders
The strengthened race test earned its keep on the first run: the sweep deleted
an aged 'rejected upload' while a save was committing a reference to it, and the
accepted config came out pointing at nothing. The write lock serializes the two
but cannot help when the sweep goes first.

So the age rule is gone for plans and for marker folders. What remains is one
sentence: a file goes when an action says so — a plan replaced, an attachment
dropped from a device that still exists — plus a per-dialog staging folder after
an hour, which by construction can only hold an upload nobody saved.

Cost: an upload whose save failed sits there until someone removes it by hand.
That is the side of the trade the owner picked, and it is the side that cannot
lose data.
2026-07-28 21:18:53 +03:00
Matysh 8e07e3c958 test: race the sweep against a save, not a reload against a save
A reload has an unload window where any WS call answers not_ready, so the save
failed at random — and the vaguer assertion this test used to carry was exactly
what hid that. Driving data.sweep() directly is the concurrency the write lock
actually guards.
2026-07-28 21:13:45 +03:00
Matysh 9868f1035f v1.46.6: the detach promise, actually kept this time
v1.46.4 and v1.46.5 documented that detaching a plan leaves the image on disk,
added guards for it, and shipped tests. The guards were never reached: they sit
behind 'not superseded', and a file that left the configuration was called
superseded. From old_refs - new_refs alone, replacing a plan, detaching one and
deleting its space are indistinguishable — so all three deleted the file, at the
moment of the save, before any scheduled pass ever ran.

Every test I wrote for this called collect_plans(d, cfg, cfg): old config equal
to new, i.e. only the scheduled pass. The transition that mattered was never
exercised. Codex reproduced it in four lines.

Classification is by owner now:
  space in both, plan A -> plan B  : the user picked another image -> removed
  space in both, plan -> none      : detached -> kept
  space gone                       : kept (the image was imported; a thirty-day
                                     grace measured from file age is meaningless
                                     anyway, it was uploaded months ago)
  space has a plan, other file     : rejected upload -> 1 h
Attachments follow the same shape: dropped from a device that still exists ->
removed (a trash button promises nothing); device gone -> kept; staging folder
-> 1 h.

Tests: a matrix per rule in the pure module, and — the part that was missing —
test_detaching_a_plan_keeps_the_file, which goes through real config/set calls:
attach, detach, assert the file is there, restart, assert again, re-attach,
replace, assert the replaced one is gone, delete the space, assert the plan
survives. Also strengthened the sweep/save race test to assert the save actually
succeeded and the config points at the specific expected file, per the report.
2026-07-28 21:11:11 +03:00
Matysh f2c9b07cc1 Release v1.46.5
Audit of every automatic deletion: a detached plan is never removed (standing
rule now in SCOPE.md), files/cleanup verifies against the stored config instead
of trusting the client, and a deleted space's plan waits thirty days.
2026-07-28 20:41:06 +03:00
Matysh 2c7a2f849d test: files/cleanup reports counts now, not a boolean
It answers {removed, kept} since v1.46.5 — the 'kept' side is the point: files
the stored configuration still references survive a cleanup of their folder.
2026-07-28 20:38:49 +03:00
Matysh 33e71ca96c v1.46.5: audit of every automatic deletion
Owner's decision after the incident: a detached plan is never deleted, at any
age. v1.46.4 gave it a month; this makes it permanent and, more importantly,
writes the reasoning where the next change will trip over it — docs/SCOPE.md now
carries the standing rule. The component may delete a file only when a user
action says so. 'Nothing points at this any more' is not such an action, because
the two errors are not symmetrical: wasted disk is visible, cheap and
reversible; a deleted file is none of those.

Went through every other automatic deletion with the same question. One more
was wrong: houseplan/files/cleanup rmtree'd whatever folder the card named. A
partial migration leaves urls pointing into it — files/migrate deliberately does
not rewrite the ones it could not confirm — so those were live links to files
being deleted; and a wrong or stale id from any client destroyed a live device's
manuals. The server now reads the stored config under its lock and removes only
what nothing references, keeping the rest and saying so.

Also: a plan of a DELETED space now waits thirty days rather than an hour.
Deleting a space is deliberate; an hour is a short window to notice a misclick.

The rest came out clean: layout/delete and marker/room/space removal are all
confirm-guarded user actions, upload temporaries are never user-visible, and
dropping legacy 'segments' is a documented migration.
2026-07-28 20:36:17 +03:00
Matysh 7128ab504d Release v1.46.4
Data loss fix: collection treated a detached plan as abandoned and removed it
after an hour. Supersession stays immediate; absence is now judged per case.
2026-07-28 20:04:07 +03:00
Matysh f953a3c286 fix: the guard has to be per-case, not blanket
Protecting every file of a live space also protected the ones a commit had just
superseded, and gave rejected uploads immortality. The distinction that matters
is narrower: a space with NO plan_url has had its image detached and may want it
back; a space that has one can only be holding its own rejects. Attachments:
staging folders keep the hour, marker folders get the month.

Also: the layout event test asserted the order of separately fired bus events,
which nothing promises — it came back [2,1,3] in CI.
2026-07-28 19:59:32 +03:00
Matysh ef270d11b7 v1.46.4: detached plans were being collected as garbage — data loss
Deployed v1.46.3 to my own instance, restarted, and the startup sweep deleted
both floor plans: config/houseplan/plans/ went from f1.svg + f2.png to empty.
The backup is a SecureTar, so they are gone.

The rule was wrong, not the code. v1.46.0 introduced collection that treats
'nothing references this right now' as abandoned and gives it an hour. But
detaching a plan — switching a space to 'draw' — is a normal, reversible action,
and the editor's own comment says the file stays on disk. Those two plans had
been detached for weeks; every pass since v1.46.0 was entitled to remove them,
and the one that finally ran did.

New rule, one for every path:
  * superseded by a commit (was in the old revision, is not in the new) — goes
    immediately; that is the one thing a commit knows for certain;
  * belongs to a space or marker that still exists — never collected, at any
    age, because unreferenced is not abandoned;
  * a per-dialog staging folder (up_*) — one hour, unchanged: by construction it
    only ever holds an upload from a dialog that was never saved;
  * anything else — thirty days.

The  flag I added an hour ago is gone with it: two rules for the same
question is how this happened. Tests updated to the new grace, plus two that pin
the distinction directly.

I am sorry about the files.
2026-07-28 19:55:38 +03:00
Matysh dc24390222 Release v1.46.3
Re-check of v1.46.2: the startup sweep uses the runtime data it already has
instead of a lookup that cannot succeed during setup, and the test that was
supposed to prove it no longer passes for the wrong reason.
2026-07-28 19:45:39 +03:00
Matysh 254354bf56 test: invoke the scheduled sweep instead of faking a 24 h jump
The time-changed variant failed in CI: the timer fired but the assertion still
saw the orphan, and 'the timer fires' and 'the work happens' are different
claims anyway. HouseplanData now publishes the sweep, so the test awaits it
directly and asserts the outcome.
2026-07-28 19:35:09 +03:00
Matysh c9a60a110d chore: untrack __pycache__
.gitignore has covered it for a long time, but four .pyc files were committed
before the rule existed and kept turning up in every diff.
2026-07-28 19:31:53 +03:00
Matysh 75279308c1 v1.46.3: re-check of v1.46.2 — HP-1462-01
The startup sweep resolved its runtime data with get_data(hass), which lists
only LOADED entries — during async_setup_entry the entry is still
SETUP_IN_PROGRESS, so it always got None and degraded to removing streaming
temporaries. The real collection was then 24 hours away, and an instance that
restarts more often than that never ran it at all. It closes over the
object created a few lines above instead; the callback is unregistered with the
entry, so that matches the lifecycle.

The test that was meant to prove the previous fix passed for the wrong reason:
it seeded the strays BEFORE config/set, which collects too, so nothing was left
for the restart to find. Now seeded after the save, plus two more — one firing
the interval callback on its own, and one running a reload and a save
concurrently to assert the accepted config never references a file the sweep
removed (they share the write lock; this pins that they must).
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 19:31:29 +03:00
Matysh b7ae3e7adf Release v1.46.2
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 1m51s
Validate / backend (push) Failing after 7m11s
Validate / smoke (push) Failing after 6m35s
Re-check of v1.46.1: the scheduled sweep now collects unreferenced attachments
and plans against the stored configuration, and a drag in flight survives a
concurrent remote position change.
2026-07-28 17:47:19 +03:00
Matysh 379fb68db2 v1.46.2: re-check of v1.46.1 — HP-1461-01, -02
Validate / hacs (push) Failing after 23s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 1m40s
Validate / backend (push) Failing after 7m16s
Validate / smoke (push) Failing after 7m13s
HP-1461-01: collection was tied to config/set, which is the right scope for
what a commit supersedes but leaves a file nobody references with no future
write to notice it — cancel a dialog after the upload finished, drop the
connection just after, or call the upload API directly. The daily sweep added
in v1.46.1 only removed streaming temporaries, so the documented 'a cancelled
attachment is collected an hour later' did not hold on an instance nobody
edits. The scheduled pass now loads the stored config under the same write_lock
a commit uses and runs collect_attachments/collect_plans with it as BOTH sides:
nothing counts as superseded, referenced files are preserved, aged unreferenced
ones go. Doing it under the lock keeps it from deciding on a snapshot a commit
is about to replace.

HP-1461-02: _reloadLayoutOnly captured the dirty set AFTER flushing the pending
write, and the flush empties it first — so during a real drag (where a write is
already scheduled) the snapshot was empty and the server's older position was
merged over the user's move. The snapshot is taken before the flush, by value,
and a _sentPos map now holds positions that are sent but unacknowledged, which
closes the same window for a write that was already in flight.

Tests: the upload test now cancels the request task for real (the previous one
claimed to and only walked error paths); smoke_layout_sync schedules a genuine
debounced write and delays it — verified failing on a v1.46.1 build with
exactly the reported symptom; a new backend test reloads the entry and asserts
the scheduled sweep takes an aged cancelled attachment and an orphan plan while
keeping everything the config still references.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 17:44:03 +03:00
Matysh 96a70495d3 Release v1.46.1
Re-check of v1.46.0: atomic filename reservation with a bounded collision name,
unconditional cleanup of streaming temporaries plus a scheduled sweep, and the
full card following layout events with a dirty-position merge.
2026-07-28 16:51:19 +03:00
Matysh d3db9e30e6 v1.46.1: re-check of v1.46.0 — HP-1460-01, -02, -03
HP-1460-01: v1.46.0 stopped overwriting attachments, but picking a free name
and taking it were two steps. Two uploads racing between them agreed on the
same name, both answered 200, and one set of bytes replaced the other;
files/migrate had the same check-then-copy gap. reserve_filename now claims the
name with O_CREAT|O_EXCL as it picks it, and both paths use it. It also splits
the extension off the RAW name and budgets the stem against MAX_FILENAME
including the collision tag — a maximal name lost its '.pdf' and then grew past
the limit, so the view sanitised the request back to a different name and the
attachment 404'd for good.

HP-1460-02: cleanup lived in an 'except Exception', which CancelledError walks
past, only one tmp_path was tracked, promotion had no finally, and the
collector only walks marker folders — an aborted transfer stranded a .upload-*
that nothing would ever remove. An outer finally owns every temporary, a second
'file' part is refused, promotion failure cleans up, and sweep_upload_temps
runs at setup, daily, and inside the commit-scoped collector. Chunks are
batched to 1 MB per disk task instead of one per 64 KB.

HP-1460-03: the layout event reached the static card and not the full one, so
two full cards diverged until a reload. The full card subscribes now and
re-reads ONLY the layout, keyed on its revision. Two hazards handled: it
records revisions it produced itself, and the reaction is deferred ~200 ms
because the event can beat the reply to our own write over the same socket;
positions dragged but not yet sent are flushed and merged on top, so a fix for
a stale UI cannot become a lost drag.

Tests: smoke_layout_sync (fails on a v1.46.0 build), four pure tests for atomic
reservation incl. 20-thread concurrency and the length boundary, a backend test
walking every failing exit path of an upload, and — as the report asked — an
HA-harness test that a repair issue disappears with its space.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 16:48:32 +03:00
Matysh 2e731debd9 Release v1.46.0
Full external audit of v1.45.4: sandboxed SVG content (release blocker),
transactional attachments, serialized config writes, geometry-aware openPairs
cache, inner validation limits, streaming file I/O, static-card parity, layout
revisions and events, repair issue cleanup, dev dependency bump.
2026-07-28 16:18:58 +03:00
Matysh a49b5e6d2e fix: collision names must survive the sanitiser the content view applies
unique_filename produced 'manual (2).pdf'; HouseplanContentView sanitises the
name in the REQUEST too, turning ' (2)' into '_2_', so the file was written and
then 404'd. The same pattern was already in files/migrate, so a rebind that hit
a name collision has been producing dead links. Both use the shared helper now,
with '-2', which round-trips sanitize_filename — asserted.
2026-07-28 16:16:07 +03:00
Matysh 4418312b0b test: config cap under aiohttp's 4 MB frame; own marker id for the upload test
A 4 MB cap could never be reported: the frame limit rejects the message first
and the socket closes with 1009, so the user gets a dropped connection instead
of 'too_large'. 2 MB is ~30x a real three-floor configuration (70 KB measured).

The upload test listed a folder test_ha_upload.py also writes into.
2026-07-28 16:11:48 +03:00
Matysh 3f719cc32a test: match the new upload url shape; keep the config cap under the WS frame limit
test_upload_ok still asserted the old '<name>?v=<mtime>' url — uploads take a
free name now, so the name itself is the cache key and the query is gone.

MAX_CONFIG_BYTES was 12 MB, above the WebSocket frame limit: a payload that big
never reaches the handler, the socket just closes with 1009 and the user sees a
dropped connection instead of an actionable error. 4 MB is far above any real
configuration and comfortably inside the frame.

test_upload_never_overwrites listed the whole shared test config folder.
2026-07-28 16:09:00 +03:00
Matysh 260615a63f v1.46.0: full external audit of v1.45.4 — HP-1454-01 … -10
HP-1454-01 (high, release blocker): an uploaded SVG plan opened directly is a
top-level document of Home Assistant's own origin, so a <script> inside it
reaches the session's localStorage and API. Uploading needs write access, which
by default every authenticated user has. SVG responses now carry a sandbox CSP;
only SVG, because a CSP on a PDF can break the browser's viewer and a raster
image has nothing to disable. Verified in Chromium both ways: the script runs
without the header and does not with it.

HP-1454-02: attachment uploads wrote straight to <marker>/<filename>, outside
the config transaction — a cancelled dialog or a rejected save left the stored
url serving new bytes, and every new icon shared one 'new' folder, so two of
them attaching manual.pdf pointed at one file. Uploads take a free name, a new
icon gets a per-dialog staging folder promoted on an accepted save, and
config/set collects superseded and aged-orphan attachments like it does plans.

HP-1454-03: the debounce spaced out the starts of a write, not the writes. A
save slower than 500 ms let the next edit go out with the same expected_rev;
the server accepted the first, rejected the second, and the conflict handler
reloaded over the local copy. Writes are chained now — one in flight, each with
the revision the previous returned.

HP-1454-04: _openPairsCache keyed on room ids and links only, so an aspect
change or a dragged vertex left open boundaries and their glow cuts at old
coordinates. It keys on the rendered model object now — the same invalidation
the model cache already has, not a second strategy. The fingerprint also gained
an O(1) geometry roll-up per room.

HP-1454-05: outer collections were capped, inner ones were not. Limits for
poly points, open_to, controls, pdfs, text and url lengths, plus a total
serialized size cap; legacy  is dropped server-side.

HP-1454-06: upload streams to a temp file and downloads use FileResponse, so a
50 MB manual no longer costs ~100 MB of RSS per transfer.

HP-1454-07: spaceModels() dropped room.settings, so the static card ignored the
per-room fill override. HP-1454-08: layout had no revision on point-wise writes
and no event, leaving static cards stale forever; it now keeps a revision,
returns it and fires houseplan_layout_updated. HP-1454-09: repair cleanup only
walked existing spaces, so a deleted space kept its warning. HP-1454-10:
serialize-javascript pinned past two advisories.

Tests: smoke_svg_sandbox (proves both directions), smoke_config_writer and
smoke_render_parity (both verified failing against a v1.45.4 build), six pure
tests for attachment collection and inner limits, four HA-harness tests for the
CSP, non-overwriting uploads, the size cap and layout revisions.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 16:06:21 +03:00
Matysh e4e300adaa Release v1.45.4
Validate / hacs (push) Failing after 1m19s
Validate / hassfest (push) Failing after 1m18s
Validate / frontend (push) Successful in 2m13s
Validate / backend (push) Failing after 10m58s
Validate / smoke (push) Failing after 6m7s
Review of v1.45.3: R5-1 a partial signing answer no longer skips the backoff,
R5-2 the status snapshot matches the repository and no longer carries counts
that go stale.
2026-07-28 08:51:57 +03:00
Matysh 96d387ff1d v1.45.4: review of v1.45.3 — R5-1, R5-2
Validate / hassfest (push) Failing after 49s
Validate / hacs (push) Failing after 52s
Validate / frontend (push) Successful in 1m43s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Successful in 4m52s
R5-1: the backend signs each path independently and answers successfully with
whatever it managed, skipping (and logging) the rest. The card read any
successful call as 'the batch is done', cleared the backoff for every path in
it, then wrote only the urls that came back — so a path the backend kept
skipping was asked for again on every render, the exact amplification the
backoff was added to stop. A path now counts as signed only when the answer
carries a url for it; the others back off individually, keys that were not
requested are ignored, and onUpdate fires only when a new signature landed.

R5-2: docs/STATUS.md still described main as holding releases up to v1.40.1 and
quoted test counts several releases old, while the version line beside them was
kept current — a handoff reader got a wrong branch model and less coverage than
exists. Branch roles are now accurate, and the counts are gone rather than
corrected: scripts/inventory.mjs (npm run inventory) prints them from the tree,
so there is nothing left to drift.

Tests: three unit cases for empty/partial/foreign-key answers, verified to fail
against a v1.45.3 checkout; a backend test pinning the partial-success contract
by making async_sign_path raise for one path of two.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 08:49:11 +03:00
Matysh 8b531db3f5 docs: the value-display bug lived six days, not a year and a half
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 1m44s
Validate / backend (push) Failing after 6m15s
Validate / smoke (push) Failing after 12m28s
Version distance is not calendar distance. v1.26.0 shipped 2026-07-21 and the
report came in on 2026-07-27; the project itself is three weeks old. The point
stands and is unchanged — nothing in the suite could have caught it, because the
option list and the schema were written in two languages and never compared —
but the 'year and a half' was wrong.
2026-07-28 00:29:40 +03:00
Matysh 68aa1f04ba Release v1.45.3
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 1m34s
Validate / backend (push) Failing after 6m18s
Validate / smoke (push) Failing after 10m27s
issue #3: display='value' was offered by the editor since v1.26.0 but rejected
by the schema, which blocked saving the configuration entirely. Option lists
are now shared and checked across languages.
2026-07-28 00:26:27 +03:00
Matysh 3d41fe16b8 v1.45.3: 'value instead of an icon' could never be saved (issue #3)
The device editor has offered display='value' since v1.26.0; MARKER_SCHEMA
accepted only badge/ripple/icon_ripple. Picking it produced

  not a valid value for dictionary value @ data['config']['markers'][n]['display']

and since one rejected marker fails the whole config write, the user could not
save the plan at all until the setting was undone. Reported by @RemyRoux with
the exact error text, 2026-07-27 — a year and a half after the feature shipped.

The schema now accepts it, and the class of bug is closed rather than the
instance: DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES and ROOM_FILL_MODES are
exported from src/logic.ts, the editors render their options from them, and a
backend test parses those lists out of the TypeScript source and asserts the
schema accepts every one (and rejects a bogus value). Reverting the one-word
schema fix fails that test, which is the check that was missing.

Plus an HA-harness test saving a config that contains a value-display marker —
the exact call the user's card was making.
Docs: CHANGELOG.md + CHANGELOG.ru.md + TESTING.md + STATUS.md.
2026-07-28 00:23:37 +03:00
Matysh ac734688d4 Release v1.45.2
Hardening from the v1.45.1 review: R4-1 a failed cleanup no longer reports an
accepted save as an error, R4-2 one signing request per url instead of one per
render.
2026-07-28 00:16:34 +03:00
Matysh 2e2d353b04 v1.45.2: hardening from the v1.45.1 review — R4-1, R4-2
R4-1: collecting superseded plan files runs after the configuration is already
durable, but an error listing the directory propagated out of config/set. The
client saw a failure for a revision the server had committed, and its retry
came back as a conflict. collect_plans now reports 0 instead of raising, and
config/set logs and proceeds — the event fires, the revision is returned.

R4-2: the pending set was cleared when a batch went out, not when it came back,
so every render during an in-flight content/sign queued another request: six
calls where one was needed, and unbounded on a socket that is slow rather than
busy. Queued and in-flight are separate states now; a failure backs off (2 s
doubling to 60 s) instead of retrying on the next frame; an in-flight entry
expires after 15 s so a promise that never settles cannot wedge retries; a late
answer after dispose() no longer renders.

Tests: test/signing.test.mjs — eight cases with hand-settled promises, verified
against a v1.45.1 checkout where four of them fail (2 sign calls instead of 1,
no backoff, a late answer rendering after teardown). Backend: a broken
collector still yields a successful save whose revision the next CAS accepts.
Pure collector: a disappearing directory returns 0.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-28 00:13:45 +03:00
Matysh f8c8cb4eeb Release v1.45.1
Follow-up review of v1.45.0: R3-1 plan collection moved into the config
transaction, R3-2 the static space card now uses the signed background url.
2026-07-27 22:04:22 +03:00
Matysh c749b52a0d v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2
R3-1 (high): v1.45.0 made the upload safe but left deletion to the client —
after a successful save the card asked the backend to remove everything but the
file it had just committed. Two open editors cannot be ordered: a delayed
request from one deleted the plan the other had just saved, leaving the
accepted configuration pointing at nothing, the exact damage copy-on-write was
introduced to prevent.

houseplan/plan/cleanup is removed. config/set collects inside its own write
lock from the two configurations that bracket the commit (plans.collect_plans):
a file the old revision referenced and the new one does not is superseded and
goes; any other unreferenced upload waits out PLAN_ORPHAN_TTL_S, because a
fresh one may belong to a transaction that has not committed yet. The collector
lives in a pure module so it can be reasoned about and unit-tested without the
HA harness.

R3-2: houseplan-space-card signed its plan url and threw the result away —
getCardSize() mutated a throwaway model while render() rebuilt its own from the
config, so the <image> requested the protected path and got 401 on every
render. Both cards now share ContentSigner (src/signing.ts), which also gives
the static card batching, expiry handling and periodic re-signing.  is
released in finally: one failed request no longer wedges a url for the life of
the page.

Tests: five backend interleaving cases from the report, six unit tests for the
pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build:
the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22
backend-pure.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-27 22:01:41 +03:00
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
Matysh 946e7543ad Merge dev: v1.43.3..v1.44.2 (feedback fixes, control-first card, review CR-1..CR-3) 2026-07-27 13:05:03 +03:00
Matysh 641c61dc19 test: the files-migrate test now sets the integration up like its neighbours
the new CR-2/CR-3 test sent WS commands without a config entry, so the
handlers were not registered and CI reported success=False
2026-07-27 13:02:10 +03:00
Matysh ae9168f6ec fix v1.44.2: external review CR-1..CR-3
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
2026-07-27 12:58:27 +03:00
Matysh 45c863138a docs v1.44.1: add the Telegram community chat (@ha_houseplan)
- 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)
2026-07-27 12:51:48 +03:00
Matysh e04ef2f2e6 feat v1.44.0: control-first device card + light-source flag (user feedback)
- 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
2026-07-27 12:41:26 +03:00
Matysh a841d17543 ux v1.43.3: room gear discoverability, bigger metrics, touch tooltips take two
- 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
2026-07-27 12:37:57 +03:00
134 changed files with 20474 additions and 1194 deletions
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Telegram chat (@ha_houseplan)
url: https://t.me/ha_houseplan
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
- name: 💡 GitHub discussions
url: https://github.com/Matysh/houseplan-card/discussions
about: Longer-form ideas and show-and-tell.
+13
View File
@@ -3,6 +3,19 @@
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
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
is the quickest route to the author and other users. Bugs and concrete feature
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
## Five-minute setup
```bash
+43 -5
View File
@@ -5,6 +5,8 @@
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
a floor plan, outline the rooms with your mouse — and every smart device appears
@@ -15,12 +17,19 @@ right on your Lovelace dashboard.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
🇷🇺 [Документация на русском](README.ru.md)
> ### 🚀 Try it live — no install needed
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
> toggle lights, open the editors, break things. The stand resets itself to a
> pristine state every hour.
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
**Feature highlights**
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
virtual walls and a visual decor layer, all drawn with clicks; smart
virtual walls and a visual decor layer, all drawn with clicks; room resize
by dragging walls, with live lengths and areas as you drag; smart
alignment guides and a live ruler in real meters/feet.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
@@ -32,6 +41,11 @@ right on your Lovelace dashboard.
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
@@ -74,6 +88,12 @@ Key advantages in short:
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
@@ -155,7 +175,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and **upload a background** — a floor-plan image in SVG, PNG or JPG format. Both fields are required: without a plan the "Save" button stays disabled.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is always square; an image keeps its own proportions and is centred inside it.
![Space creation dialog](docs/images/03-space-dialog.png)
@@ -198,7 +218,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
@@ -258,11 +278,29 @@ turned the way it is mounted.
---
## Getting help & sharing your plan
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
setup help, feature ideas, and screenshots of your plans. The fastest way to
reach the author and other users.
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
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 &
Services → House Plan**.
---
## Frequently asked questions
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
+42 -5
View File
@@ -3,6 +3,8 @@
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![GitHub stars](https://img.shields.io/github/stars/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/stargazers)
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
@@ -13,12 +15,19 @@
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
🇬🇧 [Documentation in English](README.md)
> ### 🚀 Попробовать вживую — без установки
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
> возвращается в исходное состояние.
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
**Главное**
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
@@ -30,6 +39,11 @@
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
@@ -73,6 +87,12 @@ House Plan показывает ваш умный дом так, как он в
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -156,7 +176,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст всегда квадратный; картинка сохраняет свои пропорции и центрируется внутри него.
![Диалог создания пространства](docs/images/03-space-dialog.png)
@@ -199,7 +219,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -261,11 +281,28 @@ title: План дома
---
## Помощь и обмен опытом
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
связаться с автором и другими пользователями.
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
и запросы фич (пожалуйста, указывайте версию House Plan).
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
развёрнутых обсуждений.
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
быстрее.
---
## Часто задаваемые вопросы
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+105
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import logging
from datetime import timedelta
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from . import websocket_api as hp_ws
from .const import (
@@ -18,6 +20,8 @@ from .const import (
PLANS_URL,
VERSION,
)
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data
@@ -46,6 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
from .trails import TrailRecorder
recorder = TrailRecorder(hass, data)
await recorder.async_setup()
# setdefault: the CI harness sets entries up without async_setup, so
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
# downstream WS test with unknown_error
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
plans_path = Path(hass.config.path(PLANS_DIR))
files_path = Path(hass.config.path(FILES_DIR))
@@ -96,11 +109,103 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
module_url, module_url,
)
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
# square and a plan is centred inside it. Nothing about the drawing changes
# — the box is padded and the numbers re-expressed against it.
# The two stores are written independently, and the lock is no transaction:
# a crash between the writes used to leave the config in square coordinates
# with the layout still in the old ones — permanently, because the config
# write had already deleted the `aspect` fields the layout half needed
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
# and each half carries its own trigger with its own write: the config half
# removes `aspect`, the layout half removes the saved intent. Whatever
# half is missing after a crash, the next start finishes exactly it.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
merged = {**pending, **pending_from_config(cfg)}
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
# only once both halves are durable — a client refetching on this
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
#
# A commit collects what that commit superseded, which is the right rule for
# a commit — but it only ever runs when somebody saves. Cancel a dialog
# after the file has already uploaded, lose the connection after the upload
# succeeded, or call the API directly, and the file is unreferenced with no
# future write to notice it (HP-1461-01). The earlier version of this sweep
# only removed streaming temporaries, which are a different, narrower case.
#
# Passing the CURRENT configuration as both sides means "nothing was
# superseded": every referenced file is preserved and only unreferenced ones
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
# so it cannot decide from a snapshot that a commit is about to replace.
async def _sweep(_now=None) -> None:
files_dir = Path(hass.config.path(FILES_DIR))
plans_dir = Path(hass.config.path(PLANS_DIR))
try:
# `data` from the closure, NOT get_data(hass): during
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
# async_loaded_entries() does not list it and the lookup returned
# None. The startup pass then silently degraded to removing
# streaming temporaries only, and the real collection waited a full
# day — restarting more often than that meant it never ran at all
# (HP-1462-01). The callback is unregistered with the entry, so
# closing over its runtime data matches the lifecycle exactly.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config") or {}
def _collect() -> int:
n = sweep_upload_temps(files_dir)
# same config on both sides: nothing is superseded, so this
# only ever collects what the shared rules call abandoned
n += collect_attachments(files_dir, cfg, cfg)
n += collect_plans(plans_dir, cfg, cfg)
return n
n = await hass.async_add_executor_job(_collect)
if n:
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
data.sweep = _sweep
await _sweep()
entry.async_on_unload(
async_track_time_interval(hass, _sweep, timedelta(hours=24))
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
if rec:
rec.teardown()
"""Unload the entry.
WS commands and the HTTP view are global (async_setup) and stay registered —
+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
+33 -1
View File
@@ -11,9 +11,41 @@ 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
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
# stopped at the door instead. These bound the whole store, not one request: by
# default any authenticated user may upload, and a per-request cap of 8/50 MB
# says nothing about how many requests there are (HP-1470-01).
MAX_PLANS_BYTES = 256 * 1024 * 1024
MAX_PLANS_FILES = 200
# How many the picker asks for at once — newest first.
MAX_PLANS_LISTED = 60
MAX_FILES_BYTES = 1024 * 1024 * 1024
MAX_FILES_COUNT = 1000
# Refuse to write when the disk is nearly full: filling the config partition
# breaks .storage, the recorder and backups, not just this card.
MIN_FREE_BYTES = 512 * 1024 * 1024
# An uploaded plan that no accepted configuration references is collected only
# once it is this old. Age is a race guard, not a policy: a plan uploaded
# seconds ago may belong to another client's transaction that has not written
# its configuration yet (review R3-1).
PLAN_ORPHAN_TTL_S = 3600
# Kept for compatibility with anything reading it; the collectors no longer use
# a long grace at all. Every attempt to age files out ended badly — first by
# deleting detached plans, then by racing the save that was about to reference a
# retried upload. What is left is deliberately simple: files go when the user's
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.43.2"
VERSION = "1.55.3"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
"""One-time migration to a square canvas — pure, so it can be tested alone.
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
it: x by the width, y by the HEIGHT. Making every canvas square without touching
the numbers would stretch every plan vertically.
Nothing about the drawing changes here. The canvas is padded to a square —
top and bottom for a wide plan, left and right for a tall one — and the
coordinates are re-expressed against that larger box. In render units it is a
uniform scale plus an offset, so angles, room proportions and relative positions
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
tall plan the width grew, so the same wall would otherwise measure less.
"""
from __future__ import annotations
import logging
from typing import Any
_LOGGER = logging.getLogger(__name__)
def transform_for(aspect: float) -> tuple[float, float, float, float]:
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
axis's factor; both are the same uniform scale in RENDER units, which is
why angles are preserved.
"""
a = float(aspect)
if not a or a <= 0:
a = 1.0
k = min(1.0, a) # how much the old box shrinks inside the square
kx = k # x was normalised by the width
ky = k / a # y was normalised by the height (= width / aspect)
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
if isinstance(p, (list, tuple)) and len(p) >= 2:
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
return p
def migrate_space(space: dict[str, Any]) -> bool:
"""Rewrite one space in place. Returns True when anything was changed."""
if "aspect" not in space:
return False
try:
aspect = float(space.get("aspect") or 1)
except (TypeError, ValueError):
aspect = 1.0
dx, dy, kx, ky = transform_for(aspect)
space.pop("aspect", None)
for room in space.get("rooms") or []:
if room.get("x") is not None:
room["x"] = dx + float(room["x"]) * kx
if room.get("y") is not None:
room["y"] = dy + float(room["y"]) * ky
if room.get("w") is not None:
room["w"] = float(room["w"]) * kx
if room.get("h") is not None:
room["h"] = float(room["h"]) * ky
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
# a length is measured along the wall, and the render scale is uniform
if op.get("length") is not None:
op["length"] = float(op["length"]) * kx
for shape in space.get("decor") or []:
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
if shape.get(a) is not None:
shape[a] = dx + float(shape[a]) * fx
if shape.get(b) is not None:
shape[b] = dy + float(shape[b]) * fy
if shape.get("w") is not None:
shape["w"] = float(shape["w"]) * kx
if shape.get("h") is not None:
shape["h"] = float(shape["h"]) * ky
# The viewport becomes the whole square rather than the transformed old
# rectangle. It is what the grid is drawn over and what "fit to screen"
# fits, so keeping the old box would leave the new margins outside the
# canvas — no dots, nothing to draw on — which is exactly the room this
# change was meant to give.
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
# canvas, so a wall now covers fewer cells; without this every measurement
# in the plan would silently shrink.
if kx != 1:
try:
cell = float(space.get("cell_cm") or 5)
except (TypeError, ValueError):
cell = 5.0
space["cell_cm"] = round(cell / kx, 4)
# The image keeps its own proportions and is centred; the space no longer
# has any of its own.
if space.get("plan_url") and not space.get("plan_aspect"):
space["plan_aspect"] = round(aspect, 6)
return True
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
"""{space_id: old aspect} for every space still carrying one.
This is the migration INTENT. The two stores are written independently and
either write can fail, so the intent has to survive on its own: it is saved
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
the same write that stores the migrated layout. A crash between the writes
leaves the intent behind, and the next start finishes the missing half —
each half is idempotent because its trigger (`aspect` in the config, the
saved intent for the layout) travels with that half's own write.
"""
out: dict[str, float] = {}
for space in (config or {}).get("spaces") or []:
if "aspect" not in space:
continue
try:
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
except (TypeError, ValueError):
out[str(space.get("id"))] = 1.0
return out
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
"""The config half: migrate every space still carrying an `aspect`.
`layout` is accepted for backward compatibility and migrated with the
factors found in the config — callers that can crash between store writes
should use `pending_from_config()` + `migrate_layout()` instead, so the
layout half does not depend on state the config half just deleted.
"""
factors = pending_from_config(config)
if not factors:
return False
for space in config.get("spaces") or []:
migrate_space(space)
if layout:
migrate_layout(layout, factors)
return True
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
"""The layout half: marker and label positions of the spaces in `pending`."""
changed = False
for pos in (layout or {}).values():
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
continue
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
if pos.get("x") is not None:
pos["x"] = dx + float(pos["x"]) * kx
if pos.get("y") is not None:
pos["y"] = dy + float(pos["y"]) * ky
changed = True
return changed
+162 -63
View File
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
from __future__ import annotations
import logging
import os
import tempfile
from pathlib import Path
from aiohttp import web
@@ -18,8 +20,12 @@ except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
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 .const import (
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, PLANS_DIR,
)
from .auth import may_write
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -31,6 +37,8 @@ from .validation import (
_LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024
# batch disk writes: one executor job per megabyte instead of per chunk
_FLUSH_AT = 1024 * 1024
_MIME = {
".pdf": "application/pdf",
@@ -73,17 +81,35 @@ class HouseplanContentView(HomeAssistantView):
if not str(path).startswith(str(base)):
return web.Response(status=404)
def _read() -> bytes | None:
return path.read_bytes() if path.is_file() else None
blob = await hass.async_add_executor_job(_read)
if blob is None:
if not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
return web.Response(
body=blob,
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
headers={"Cache-Control": "private, max-age=3600"},
)
suffix = path.suffix.lower()
headers = {
"Cache-Control": "private, max-age=3600",
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
}
if suffix == ".svg":
# An uploaded SVG is user content served from Home Assistant's own
# origin. Inside the card it is referenced by <image>, where scripts
# never run — but the same url opened as a top-level document is a
# live document of this origin, and a <script> in it reaches the
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
# needs write access, which by default every authenticated user has,
# and the signed url is easy to hand to an admin).
#
# `sandbox` with no allow-* tokens drops the document into an opaque
# origin: no scripts, no same-origin access, no forms. The explicit
# directives below are belt and braces for older engines. Only SVG
# gets this — a CSP on a PDF response can break the browser's built-in
# viewer, and a raster image cannot execute anything in the first place.
headers["Content-Security-Policy"] = (
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
)
# FileResponse streams from disk: a 50 MB manual used to be read whole
# into memory and copied into the response body, so a couple of parallel
# downloads could push a small Home Assistant host into swap (HP-1454-06).
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
class HouseplanUploadView(HomeAssistantView):
@@ -95,62 +121,135 @@ 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)
files_root = Path(hass.config.path(FILES_DIR))
marker_id = "misc"
filename: str | None = None
blob: bytes | None = None
too_large = False
# Every temporary file this request creates, promoted or not. The outer
# `finally` removes whatever is left: a dropped connection, a second
# `file` part or a failure while promoting used to leave a `.upload-*`
# behind for good, and the collector only ever walks marker folders, so
# nothing would have picked it up (HP-1460-02).
temps: list[Path] = []
error: tuple[dict, int] | None = None
def _new_tmp() -> Path:
files_root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
os.close(fd)
return Path(name)
def _flush(target: Path, blocks: list[bytes]) -> None:
with open(target, "ab") as fh:
for block in blocks:
fh.write(block)
def _cleanup(paths: list[Path]) -> None:
for path in paths:
try:
path.unlink()
except OSError:
pass
try:
reader = await request.multipart()
async for part in reader:
if part.name == "marker_id":
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
filename = part.filename or "file"
# read in chunks, aborting at the limit, instead of loading the whole file into memory
chunks: list[bytes] = []
size = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
too_large = True
try:
reader = await request.multipart()
async for part in reader:
if part.name == "marker_id":
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
if filename is not None:
# one upload per request: a second part would strand
# the first temporary file and make the response
# ambiguous about which url was returned
error = ({"error": "one_file_only"}, 400)
break
chunks.append(chunk)
if too_large:
break
blob = b"".join(chunks)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
return web.json_response({"error": "bad_request"}, status=400)
filename = part.filename or "file"
if file_ext(filename) not in FILE_EXTENSIONS:
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
break
# Stream to a temporary file instead of collecting the
# whole upload in memory and copying it again into one
# buffer: a 50 MB manual used to cost ~100 MB of RSS
# mid-request (HP-1454-06). Blocks are batched so this
# is one executor job per megabyte, not per 64 KB.
tmp = await hass.async_add_executor_job(_new_tmp)
temps.append(tmp)
size = 0
pending: list[bytes] = []
buffered = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
error = (
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
413,
)
break
pending.append(chunk)
buffered += len(chunk)
if buffered >= _FLUSH_AT:
await hass.async_add_executor_job(_flush, tmp, pending)
pending, buffered = [], 0
if error:
break
if pending:
await hass.async_add_executor_job(_flush, tmp, pending)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
error = ({"error": "bad_request"}, 400)
if too_large:
if error:
return web.json_response(error[0], status=error[1])
if not temps or not filename:
return web.json_response({"error": "no_file"}, status=400)
tmp_path = temps[0]
try:
await hass.async_add_executor_job(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT,
)
except QuotaError as err:
_LOGGER.warning("House Plan upload refused: %s", err.detail)
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
except OSError:
pass
target_dir = files_root / marker_id
safe_name = filename
def _promote() -> str:
"""Claim a free name, then move the finished upload onto it.
Never overwrite an existing attachment: its bytes may be
referenced by the stored configuration, and this upload is not
part of that transaction — a cancelled dialog or a rejected save
would leave the old url serving the new content (HP-1454-02).
The name is reserved atomically, so two uploads racing on the
same filename cannot agree on it (HP-1460-01).
"""
name = reserve_filename(target_dir, safe_name)
try:
os.replace(tmp_path, target_dir / name)
except OSError:
(target_dir / name).unlink(missing_ok=True)
raise
return name
try:
name = await hass.async_add_executor_job(_promote)
except OSError as err:
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
return web.json_response({"error": "io_error"}, status=500)
temps.remove(tmp_path) # it is the attachment now, not a temporary
return web.json_response(
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
)
if blob is None or not filename:
return web.json_response({"error": "no_file"}, status=400)
ext = file_ext(filename)
if ext not in FILE_EXTENSIONS:
return web.json_response(
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
)
safe_name = sanitize_filename(filename)
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
path = target_dir / safe_name
def _write() -> int:
target_dir.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
return int(path.stat().st_mtime)
mtime = await hass.async_add_executor_job(_write)
return web.json_response(
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
)
finally:
# BaseException too: cancelling the request task raises
# asyncio.CancelledError, which an `except Exception` never saw —
# an aborted large upload leaked its temporary file every time
if temps:
await hass.async_add_executor_job(_cleanup, list(temps))
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.43.2"
"version": "1.55.3"
}
+357
View File
@@ -0,0 +1,357 @@
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
The file system is not part of the configuration store's transaction, so who
may write or delete a plan or an attachment, and when, is a correctness
question rather than housekeeping. It lives here, apart from the WebSocket and
HTTP plumbing, precisely because it is the part that has to be reasoned about
and tested.
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
from typing import Any
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
_LOGGER = logging.getLogger(__name__)
# Streaming uploads land here first. The prefix is a dot so the name can never
# collide with an attachment (sanitize_filename strips leading dots) and is easy
# to sweep.
TMP_PREFIX = ".upload-"
def reserve_filename(directory: Path, name: str) -> str:
"""Atomically claim a free name inside `directory` and return it.
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
moment it is chosen. The previous version asked `exists()` and returned a
string; two uploads racing between the check and the write agreed on the
same name and one silently overwrote the other, both reporting success
(HP-1460-01). The caller writes the real bytes over the placeholder — it
owns the name by then — and must remove it if it never gets that far.
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
the content view sanitises the name in the request too, so a name it would
shorten or rewrite is a file that is written and then never served.
"""
directory.mkdir(parents=True, exist_ok=True)
# Split the extension off the RAW name: sanitize_filename() truncates to
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
# attachment would be stored — and served — without its type.
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
stem, dot, suffix = base.rpartition(".")
if not dot:
stem, suffix = base, ""
stem = sanitize_filename(stem)
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
i = 1
while True:
tag = "" if i == 1 else f"-{i}"
# budget the stem so the WHOLE name fits, including the collision tag —
# appending "-2" to an already maximal name produced a url the view
# truncated back to something else, i.e. a permanent 404
room = MAX_FILENAME - len(ext) - len(tag)
candidate = (stem[:room] if room > 0 else "f") + tag + ext
candidate = sanitize_filename(candidate)
if candidate.startswith("."): # a name that is only an extension
candidate = "file" + candidate
try:
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
i += 1
if i > 10000: # pathological directory; do not spin forever
raise
continue
os.close(fd)
return candidate
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
""""<marker>/<file>" for every attachment a configuration references."""
out: set[str] = set()
for m in (cfg or {}).get("markers") or []:
for pdf in m.get("pdfs") or []:
url = pdf.get("url") if isinstance(pdf, dict) else None
if not isinstance(url, str) or "/files/" not in url:
continue
rel = url.split("?", 1)[0].split("/files/", 1)[1]
if rel.count("/") == 1:
out.add(rel)
return out
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
"""Remove abandoned streaming temporaries (HP-1460-02).
The request itself deletes its own, but a hard kill — a restart mid-upload,
an OOM — leaves one behind, and the attachment collector only walks marker
folders, so it would never be seen. Age-gated for the same reason as the
rest: a fresh one belongs to a request still in flight.
"""
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
try:
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
except OSError as err:
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
return 0
for item in items:
if not item.name.startswith(TMP_PREFIX):
continue
try:
if item.stat().st_mtime >= cutoff:
continue
item.unlink()
removed += 1
except OSError:
continue
return removed
def collect_attachments(
files_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
A file the old revision referenced and the new one does not, whose marker
still exists, was removed on purpose — the dialog has a trash button and
promises nothing. It goes. Everything else is kept, except a staging folder
(`up_*`), which by construction only ever holds an upload from a dialog that
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
behind a durable write.
"""
new_refs = attachment_refs(new_cfg)
old_refs = attachment_refs(old_cfg)
# Removing an attachment from a device that still exists is the user saying
# "drop this one" — a trash button, no promise that anything is kept. A
# device that is GONE is a different transition, and its files follow the
# same rule as a deleted space's plan: kept.
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
# Same distinction as for plans. A staging folder (`up_*`) is different: it
# only ever holds an upload from a dialog that was never saved, so the short
# rule is exactly right there even on the timer.
now_s = time.time() if now is None else now
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
removed = 0
try:
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
except OSError as err:
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
return 0
removed += sweep_upload_temps(files_dir, now)
for folder in folders:
# A staging folder only ever holds an upload from a dialog that was never
# saved — unambiguous, so an hour is right, and no device owns it.
staging = folder.name.startswith("up_")
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
continue
for item in items:
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
dropped = rel in old_refs and folder.name in live_markers
if not dropped:
if not staging:
# Same rule as for plans: not asked for, so kept. A file in
# a device's folder that the device does not list is an
# upload whose save was rejected — and ageing those out
# raced the retry that was about to reference them.
continue
try:
if item.stat().st_mtime >= staging_cutoff:
continue
except OSError:
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
try:
next(folder.iterdir())
except StopIteration:
try:
folder.rmdir()
except OSError:
pass
except OSError:
pass
return removed
class QuotaError(Exception):
"""A store limit would be exceeded. Carries what to tell the user."""
def __init__(self, reason: str, detail: str) -> None:
super().__init__(detail)
self.reason = reason
self.detail = detail
def dir_usage(path: Path) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read."""
total = count = 0
if not path.is_dir():
return 0, 0
for item in path.rglob("*"):
try:
if item.is_file():
total += item.stat().st_size
count += 1
except OSError:
continue
return total, count
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
"""Raise QuotaError unless `incoming` more bytes fit.
Deliberately not an age rule. Files are never removed for getting old — that
cost real plans twice — so the limit sits where a decision is being made
anyway: at the moment somebody asks to store something new.
"""
import shutil
used, count = dir_usage(path)
if count + 1 > max_files:
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
if used + incoming > max_bytes:
raise QuotaError(
"quota_exceeded",
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
f"{max_bytes // 1024 // 1024} MB",
)
try:
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
except OSError:
return
if free - incoming < MIN_FREE_BYTES:
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
def plan_basename(url: Any) -> str:
"""File name a stored plan_url points at ('' when there is none)."""
if not isinstance(url, str) or not url:
return ""
return url.split("?", 1)[0].rsplit("/", 1)[-1]
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
"""Plan file names a configuration references."""
out: set[str] = set()
for sp in (cfg or {}).get("spaces") or []:
name = plan_basename(sp.get("plan_url"))
if name:
out.add(name)
return out
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
"""space id -> the plan file it references ('' when it has none)."""
return {
str(sp.get("id")): plan_basename(sp.get("plan_url"))
for sp in (cfg or {}).get("spaces") or []
}
def is_plan_file(name: str) -> bool:
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
parts = name.split(".")
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
def collect_plans(
plans_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""Drop plan files the accepted configuration made obsolete (review R3-1).
Called inside the config write lock, right after the new revision is
stored, so it decides from the two configurations that actually bracket the
commit instead of trusting a client to say what may be deleted. The earlier
design — a `plan/cleanup` command carrying `keep` — could not be ordered
against another client's commit: a delayed call removed the file that
client had just saved, leaving the accepted configuration pointing at
nothing, which is the damage copy-on-write was introduced to prevent.
Two rules, both conservative:
* a file the OLD configuration referenced and the new one does not was
authoritative and has been superseded — remove it;
* any other unreferenced plan file is a rejected or abandoned upload, and
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
belong to a transaction that has not committed yet.
Never raises: the configuration is already stored by the time this runs, so
a file-system problem must not turn a durable commit into a failed call.
"""
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
# A commit knows what it superseded. The timer only knows what nothing
# points at *right now*, and for a plan that is a reversible state: the
# editor detaches the image when a space switches to "draw" and says the
# file stays on disk. So the scheduled pass keeps anything belonging to a
# space that still exists, and waits a month for the rest.
# A space with NO plan_url has had its image detached — reversible, and the
# editor promises the file stays. A space that HAS one is different: any
# other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28.
# The short rule fits exactly one case: a space that HAS a plan, where any
# other file of its own can only be a superseded or rejected upload.
old_by_space = plan_by_space(old_cfg)
new_by_space = plan_by_space(new_cfg)
# A file that left the configuration tells us nothing on its own: replacing a
# plan, detaching one and deleting a space all look identical from
# `old_refs - new_refs`. Only the first is a deletion the user asked for
# (HP-1465-01 — the guards below were written and then never reached,
# because the code decided "superseded" before asking why).
replaced = {
name for space, name in old_by_space.items()
if new_by_space.get(space) and new_by_space[space] != name
}
removed = 0
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
except OSError as err:
# The directory can vanish or turn unreadable between the check and the
# walk. This is housekeeping running behind a commit that is already
# durable, so it reports "nothing collected" instead of failing (R4-1).
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
return 0
for item in items:
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
if item.name not in replaced:
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
# not told to delete is kept, however long it sits there. Detaching
# is one click to undo and the editor says the image stays; deleting
# a space is deliberate but the image was imported and may be
# nowhere else. The errors are not symmetrical — unnecessary
# megabytes can be removed by hand, a deleted file cannot be
# brought back.
#
# There is deliberately no age rule here. An earlier version aged
# out "rejected uploads" — a file of a space that has a plan, which
# was never the plan — and that raced a save: the sweep deleted the
# upload from the failed attempt while a retry was committing a
# reference to it. A rule that can delete a file somebody is about
# to point at is not worth the disk it reclaims.
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
return removed
+14 -5
View File
@@ -51,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
)
# clear stale issues for spaces that are fine again (or gone)
for sp in spaces:
sid = sp.get("id", "?")
if sid not in broken:
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
# issues for spaces that still exist, so deleting or renaming a space with a
# missing plan left its warning in Repairs forever, with nothing left to fix
# it (HP-1454-09). Enumerate what we actually published instead.
registry = ir.async_get(hass)
stale = [
issue_id
for (domain, issue_id) in list(registry.issues)
if domain == DOMAIN
and issue_id.startswith("broken_plan_")
and issue_id[len("broken_plan_") :] not in broken
]
for issue_id in stale:
ir.async_delete_issue(hass, DOMAIN, issue_id)
+12
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -42,6 +43,17 @@ class HouseplanData:
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# A separate, narrower lock for the check-quota→write-file pair of an
# upload. Without it N parallel uploads all measure the store BEFORE any
# of them writes, and all pass a quota only one of them fits under
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
# stall config/layout commits.
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Collect files nothing references any more. Set during setup, which also
# runs it once and schedules it daily. Exposed so it can be invoked
# directly — a test that fakes a 24 h jump proves the timer fires, not that
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
+242
View File
@@ -0,0 +1,242 @@
"""Server-side vacuum trails.
The integration records the robot's path ITSELF by watching the source
entity's state changes — no card involvement. This removes every client-side
race (N open tabs would fight over writes), survives page reloads by
construction, and keeps recording while no card is open at all. Stored: the
current run and one previous run per marker (owner call 2026-07-31 — users
want to see where the cleanup has already been).
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
from homeassistant.helpers.storage import Store
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
value that is not None wins — truthiness is wrong here: a zero-based
`map_index: 0` is a valid first map and an empty string is still an id.
The old `or`-chain dropped the zero, so the server stored trails under a
key the renderer never looked up (HP-1540-02).
"""
for v in (
src_attrs.get("map_name"),
src_attrs.get("current_map"),
src_attrs.get("map_index"),
src_attrs.get("selected_map"),
vac_attrs.get("selected_map"),
):
if v is not None:
return str(v)
return "default"
class TrailBook:
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
robot coordinates — recalibration never invalidates a stored trail.
"""
def __init__(self, data: dict[str, Any] | None = None) -> None:
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
rec["previous"] = cur
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
return False
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
half = pts[0::2]
if half[-1] != pts[-1]:
half.append(pts[-1])
cur["points"] = half
return True
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and not cur.get("ended"):
cur["ended"] = now
return True
return False
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
self.hass = hass
self.rt = rt
self.store = Store(hass, 1, f"{DOMAIN}.trails")
self.book = TrailBook()
# HP-1540-03: one source may feed SEVERAL markers — the same robot
# placed on two floors is the documented multi-floor case, and a plain
# source → (marker, vacuum) dict silently kept only the last one
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
self._unsub_track = None
self._unsub_save = None
self._last_fire = 0.0
# HP-1540-05: config/set fires refresh as a detached task; two of them
# interleaving across the awaited load both subscribed and the loser's
# unsub handle was overwritten — a leak until HA restart
self._refresh_lock = asyncio.Lock()
self._closed = False
async def async_setup(self) -> None:
self.book = TrailBook(await self.store.async_load() or {})
await self.async_refresh()
async def async_refresh(self) -> None:
"""(Re)subscribe after any config change — markers may come and go.
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
atomic across the awaited config load, so overlapping refresh tasks can
no longer both subscribe and strand one callback forever. The _closed
check covers teardown() racing a refresh that is parked on its await.
"""
async with self._refresh_lock:
stored = await self.rt.config_store.async_load() or {}
if self._closed:
return
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
for m in cfg.get("markers") or []:
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
vac = self._vacuum_entity(m)
if vac:
# HP-1540-03: append, never overwrite — every floor's
# marker records its own copy of the run
pairs.setdefault(src, []).append((str(m.get("id")), vac))
self.pairs = pairs
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
# A run already in progress (HA restarted mid-cleanup, or the user
# just finished calibrating) must start recording NOW, not at the
# next state change — otherwise the first seconds of the path are
# lost.
for src in self.pairs:
self._sample(src, time.time())
def teardown(self) -> None:
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
# not re-subscribe after this cleanup has already run
self._closed = True
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
b = str(m.get("binding") or "")
if b.startswith("entity:vacuum."):
return b[len("entity:"):]
if b.startswith("device:"):
reg = er.async_get(self.hass)
for e in er.async_entries_for_device(reg, b[len("device:"):]):
if e.entity_id.startswith("vacuum."):
return e.entity_id
return None
def _sample(self, src: str, now: float) -> bool:
"""Record one point (or end the run) for EVERY marker fed by src.
HP-1540-03: the same source serves one marker per floor — all of them
must receive the point, not just whichever survived the dict.
"""
changed = False
for marker, vac in self.pairs.get(src) or ():
st_vac = self.hass.states.get(vac)
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
# unavailable and ending the run here would split one cleanup into
# current+previous on every restart (observed live: 21 points
# became previous, the same run restarted at 5)
if not st_vac or st_vac.state in ("unavailable", "unknown"):
continue
if st_vac.state not in MOVING_STATES:
changed |= self.book.end_run(marker, now)
continue
st_src = self.hass.states.get(src)
attrs = st_src.attributes if st_src else {}
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
# Server-side these attributes are often OBJECTS (Tasshack keeps a
# Point dataclass in memory — it only becomes a dict when
# serialised to the frontend). Caught live on the owner's X50: the
# recorder saw every state change and rejected every single one.
if isinstance(raw, dict):
px, py = raw.get("x"), raw.get("y")
else:
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
try:
x, y = float(px), float(py) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
map_id = resolve_map_id(attrs, st_vac.attributes)
changed |= self.book.on_point(marker, map_id, x, y, now)
return changed
@callback
def _on_state(self, event: Any) -> None:
eid = event.data.get("entity_id")
now = time.time()
changed = False
for src, pair_list in self.pairs.items():
if eid == src or any(eid == vac for _, vac in pair_list):
changed |= self._sample(src, now)
if changed:
self._schedule_save()
if now - self._last_fire >= FIRE_THROTTLE_S:
self._last_fire = now
self.hass.bus.async_fire("houseplan_trail_updated", {})
def _schedule_save(self) -> None:
if self._unsub_save:
return
async def _save(_now: Any) -> None:
self._unsub_save = None
await self.store.async_save(self.book.data)
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
+132 -33
View File
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# The name length the content view will accept back in a request. Anything a
# generated name must fit inside, collision tag included (HP-1460-01).
MAX_FILENAME = 120
# ---------- sanitizers ----------
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
def sanitize_filename(value: str) -> str:
"""Drop the path and leading dots, keep a safe file name."""
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
def file_ext(filename: str) -> str:
@@ -66,14 +69,67 @@ MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_LAYOUT = 5000
# Inner limits (HP-1454-05). The outer collections were capped, the collections
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
# list passed validation, then made the card build gigantic SVG attributes and
# walk them on every render. Any authenticated writer could store one, and with
# `admin_only` off that is every user. These are product limits, not guesses: a
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
# one switch.
MAX_POLY_POINTS = 500
MAX_OPEN_TO = 50
MAX_CONTROLS = 200
MAX_PDFS = 50
MAX_KNOWN_DEVICES = 20000
MAX_TEXT = 500 # names, models, ids
MAX_DESCRIPTION = 4000
MAX_URL = 2000
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
# payload larger than the frame never reaches the handler at all — the socket
# closes with 1009 and the user sees a dropped connection instead of an error
# they can act on. For scale, a real three-floor home with ~200 devices stores
# about 70 KB, so this is ~30x headroom.
MAX_CONFIG_BYTES = 2 * 1024 * 1024
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
# Positions are normalised to the canvas (0..1). Allow generous slack for an
# icon dragged past an edge, but not arbitrary magnitudes: any finite float
# used to pass, and a single stored 1e100 stretched every client's view of the
# space until the plan was invisible (HP-1500-03).
_COORD = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
POS_SCHEMA = vol.Schema(
{vol.Required("x"): _finite, vol.Required("y"): _finite},
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
)
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))
# Geometry is normalised to the canvas (0..1). ±4 is generous slack for a
# vertex nudged past an edge, not an envelope for arbitrary magnitudes: any
# finite float used to pass here, and a single 1e100 room vertex stretched the
# frame until the whole space was unviewable for every client (HP-1501-01) —
# the exact failure HP-1500-03 closed for layout positions, one schema over.
_GEOM = vol.All(_finite, vol.Range(min=-4.0, max=4.0))
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
# client. The floor is one thousandth of the canvas (1 render unit): far below
# any real room, but keeps the maths finite.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=4.0))
def _view_box(value):
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
if not isinstance(value, (list, tuple)) or len(value) != 4:
raise vol.Invalid("view_box must be [x, y, w, h]")
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
def _require_geometry(room: dict) -> dict:
@@ -85,10 +141,10 @@ def _require_geometry(room: dict) -> dict:
ROOM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str],
vol.Required("id"): _TEXT,
vol.Required("name"): _TEXT,
vol.Optional("area"): _TEXT_OR_NONE,
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
vol.Optional("settings"): vol.Any(
None,
vol.Schema(
@@ -102,11 +158,11 @@ 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("poly"): vol.All([POINT], vol.Length(min=3)),
vol.Optional("x"): _GEOM,
vol.Optional("y"): _GEOM,
vol.Optional("w"): _EXTENT,
vol.Optional("h"): _EXTENT,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
},
extra=vol.ALLOW_EXTRA,
),
@@ -117,6 +173,8 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("show_borders"): bool,
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
# per-space background around the plan; absent = inherit the global one
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
vol.Optional("temp_min"): vol.Coerce(float),
@@ -160,18 +218,26 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("title"): str,
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)),
# The canvas is square since v1.48.0. What used to be the space's own
# `aspect` is gone; the background image keeps its own proportions and
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
# send the old field — it is dropped rather than trusted, because the
# coordinates it comes with were normalised against a different box.
vol.Remove("aspect"): object,
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
vol.Required("view_box"): _view_box,
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"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
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,11 +247,14 @@ 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.
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
# list is not a limit, it is a hope. `Remove` returns the key stripped.
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
@@ -197,23 +266,51 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("model"): vol.Any(str, None),
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], None),
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", None),
# the 'run' target: only the runnable domains, nothing else is callable
vol.Optional("tap_target"): vol.Any(
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
),
vol.Optional("tap_confirm"): vol.Any(bool, None),
# live robot vacuums (docs/VACUUM.md): everything optional so configs
# from older versions stay valid untouched
vol.Optional("vacuum"): vol.Any(
None,
vol.Schema({
vol.Optional("live"): vol.Any(bool, None),
vol.Optional("trail"): vol.Any(bool, None),
vol.Optional("trail_mode"): vol.Any(
None, vol.In(["never", "cleaning", "always"])
),
vol.Optional("room_highlight"): vol.Any(bool, None),
vol.Optional("source"): vol.Any(str, None),
# one 6-number affine per robot map; numbers must be finite
vol.Optional("calibration"): vol.Schema(
{str: vol.All([_finite], vol.Length(min=6, max=6))}
),
vol.Optional("segment_map"): vol.Schema({str: str}),
}),
),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("ripple_color"): vol.Any(str, None),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
vol.Optional("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
],
vol.Optional("pdfs"): vol.All(
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
vol.Length(max=MAX_PDFS),
),
},
extra=vol.ALLOW_EXTRA,
)
@@ -224,8 +321,10 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
+508 -55
View File
@@ -5,6 +5,8 @@ import logging
import base64
import binascii
import json
import secrets
from pathlib import Path
from typing import Any
@@ -14,13 +16,21 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import (
DOMAIN,
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, PLANS_DIR, PLANS_URL,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .plans import (
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
)
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,
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
)
@@ -31,13 +41,19 @@ _LOGGER = logging.getLogger(__name__)
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
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_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
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:
@@ -54,18 +70,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 ----------------
@@ -79,7 +85,9 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {"layout": data.get("layout", {})})
connection.send_result(
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
)
@websocket_api.websocket_command(
@@ -112,8 +120,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": msg["layout"], "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
@@ -136,8 +147,102 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
# keep the revision: a point-wise write used to drop it, which made the
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/geometry/repair",
vol.Required("space_id"): str,
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Optional("dry_run"): bool,
vol.Optional("undo"): bool,
}
)
@websocket_api.async_response
async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Re-apply the square-canvas transform to ONE space's layout, explicitly.
For installations that hit the v1.48/v1.49 crash window: the config write
of the migration landed, the layout write did not, and the trigger fields
were already gone — markers and labels of that space are stranded in the
old coordinates with nothing able to tell (HP-1500-01). Nothing can be
detected reliably after the fact, and re-running a transform on a layout
that is already correct would corrupt it, so this NEVER runs by itself:
an administrator names the space and its old aspect, may preview with
`dry_run`, and gets a one-deep backup written in the same store write —
`undo` restores it.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may repair the layout")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .geometry_migration import migrate_layout
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
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout") or {}
current_rev = int(data.get("rev", 0))
if msg.get("undo"):
backup = data.get("repair_backup")
if not isinstance(backup, dict) or backup.get("space") != space_id:
connection.send_error(msg["id"], "no_backup", "No repair backup stored for this space")
return
restored = dict(layout)
for key, pos in (backup.get("positions") or {}).items():
restored[key] = pos
new_rev = current_rev + 1
await rt.store.async_save({"layout": restored, "rev": new_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
"restored": len(backup.get("positions") or {})})
return
touched = {
k: dict(v) for k, v in layout.items()
if isinstance(v, dict) and str(v.get("s")) == space_id
}
if not touched:
# A typo'd space id used to "succeed" with moved: 0 — and its
# empty result REPLACED the one-deep backup, destroying the very
# undo this endpoint promises (HP-1501-02). Nothing to move means
# nothing to save: no write, no revision bump, the backup stays.
connection.send_error(
msg["id"], "nothing_to_repair",
f"No stored positions belong to space '{space_id}'",
)
return
preview = {k: dict(v) for k, v in touched.items()}
migrate_layout(preview, {space_id: msg["aspect"]})
if msg.get("dry_run"):
connection.send_result(msg["id"], {
"ok": True, "dry_run": True, "moved": len(preview),
"before": touched, "after": preview,
})
return
new_layout = {**layout, **preview}
new_rev = current_rev + 1
# the backup rides the same store write: either both are durable or
# neither — the deletion-shy rules of this project apply to positions
# too
await rt.store.async_save({
"layout": new_layout, "rev": new_rev,
"repair_backup": {"space": space_id, "positions": touched},
})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
@websocket_api.websocket_command(
@@ -149,17 +254,24 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
)
@websocket_api.async_response
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Move a marker's uploaded files to its new id (rebinding changes the id).
"""COPY a marker's uploaded files to its new id and report the exact mapping.
Without this the PDF urls keep pointing at the OLD id's folder, which then
looks orphaned and is one cleanup away from deletion — the exact way the
owner lost the sauna heater manuals (field incident, 2026-07-26).
Rebinding changes the marker id, so the files must follow (that is how the
owner lost a set of manuals, 2026-07-26). This used to MOVE them before the
revision-checked config save: when that save was rejected, the server kept
the old urls while the files had already left the old folder — a permanent
broken link (review CR-2, 2026-07-27).
Now it copies, never overwrites, and returns {src: dst} for every file so
the client can rewrite EXACTLY the urls that made it (review CR-3). The old
folder is removed later by houseplan/files/cleanup, once the config is
safely committed.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
from pathlib import Path
import shutil
from pathlib import Path
from .const import FILES_DIR
from .validation import sanitize_marker_id
@@ -167,32 +279,251 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
src_id = sanitize_marker_id(msg["from_id"])
dst_id = sanitize_marker_id(msg["to_id"])
if not src_id or not dst_id or src_id == dst_id:
connection.send_result(msg["id"], {"ok": True, "moved": 0})
connection.send_result(msg["id"], {"ok": True, "mapping": {}, "copied": 0})
return
base = Path(hass.config.path(FILES_DIR))
src = base / src_id
dst = base / dst_id
def _move() -> int:
def _copy() -> dict[str, str]:
if not src.is_dir():
return 0
return {}
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in src.iterdir():
mapping: dict[str, str] = {}
for f in sorted(src.iterdir()):
if not f.is_file():
continue
target = dst / f.name
if not target.exists():
shutil.move(str(f), str(target))
n += 1
try:
src.rmdir() # only when empty
except OSError:
pass
return n
# a different file may already own this name — do NOT silently point
# the url at it. The shared helper CLAIMS a free one atomically, so
# a concurrent migrate or upload cannot pick the same one, and the
# name it returns is one the content view accepts back in a request.
name = reserve_filename(dst, f.name)
target = dst / name
try:
shutil.copy2(str(f), str(target))
except OSError:
target.unlink(missing_ok=True) # never leave an empty placeholder
raise
mapping[f.name] = name
return mapping
moved = await hass.async_add_executor_job(_move)
connection.send_result(msg["id"], {"ok": True, "moved": moved})
try:
mapping = await hass.async_add_executor_job(_copy)
except OSError as err:
connection.send_error(msg["id"], "io_error", f"Could not copy marker files: {err}")
return
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
@websocket_api.websocket_command({vol.Required("type"): "houseplan/plans/list"})
@websocket_api.async_response
async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Plan images on the server, with what still uses them.
Files are never removed for being unreferenced (docs/SCOPE.md), which only
works as a policy if the user can see them: detaching a plan keeps the
image, and this is how it gets picked up again — or deleted on purpose.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
used: dict[str, list[str]] = {}
for space in cfg.get("spaces") or []:
name = plan_basename(space.get("plan_url"))
if name:
used.setdefault(name, []).append(space.get("title") or space.get("id") or "?")
plans_dir = Path(hass.config.path(PLANS_DIR))
def _scan() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if not plans_dir.is_dir():
return out
for item in sorted(plans_dir.iterdir()):
if not item.is_file() or not is_plan_file(item.name):
continue
try:
st = item.stat()
except OSError:
continue
out.append({
"name": item.name,
"url": f"{CONTENT_URL}/plans/_/{item.name}",
"size": st.st_size,
"modified": int(st.st_mtime),
"used_by": used.get(item.name, []),
})
out.sort(key=lambda x: -x["modified"])
return out
plans = await hass.async_add_executor_job(_scan)
# newest first and capped: a folder with thousands of files would otherwise
# become one huge message, one huge list and a signing request per row
connection.send_result(
msg["id"], {"plans": plans[:MAX_PLANS_LISTED], "total": len(plans)}
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plans/delete",
vol.Required("name"): str,
}
)
@websocket_api.async_response
async def ws_plans_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a plan image because the user asked — the only way one goes.
Refuses while a space still references it: the answer to "can I delete this"
is the stored configuration's, not the client's.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
name = sanitize_filename(msg["name"])
if not is_plan_file(name):
connection.send_error(msg["id"], "invalid_name", "Not a plan file")
return
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
cfg = stored.get("config") or {}
if name in plan_refs(cfg):
connection.send_error(
msg["id"], "in_use", "A space still uses this plan — detach it first"
)
return
path = Path(hass.config.path(PLANS_DIR)) / name
def _rm() -> bool:
try:
path.unlink()
return True
except FileNotFoundError:
return False
except OSError as err:
_LOGGER.warning("House Plan: could not delete %s: %s", path, err)
return False
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
@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",
vol.Required("marker_id"): str,
}
)
@websocket_api.async_response
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Drop a marker folder's leftovers after its files moved elsewhere.
Called after a rebind: the files were copied to the new marker id and the
config that references them is committed, so the source folder is spent.
It used to `rmtree` the folder on the client's word alone. Two ways that
ends badly: a partial copy leaves some urls still pointing INTO this folder
(the migration deliberately does not rewrite those), and a wrong or stale
id from any client deletes a live marker's attachments outright. So the
server checks for itself — under the config lock — and removes only files
the stored configuration does not reference. Same principle as the
collector: a client may say what it no longer needs, never what may go.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
from .const import FILES_DIR
from .plans import attachment_refs
from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"])
base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve() if mid else base
if not mid or not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
return
async with rt.write_lock:
stored = await rt.config_store.async_load() or {}
refs = attachment_refs(stored.get("config") or {})
def _rm() -> tuple[int, int]:
if not target.is_dir():
return 0, 0
removed = kept = 0
for item in sorted(target.iterdir()):
if not item.is_file():
continue
if f"{mid}/{item.name}" in refs:
kept += 1
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
if not kept:
try:
target.rmdir()
except OSError:
pass
return removed, kept
removed, kept = await hass.async_add_executor_job(_rm)
if kept:
_LOGGER.info(
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
)
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
@websocket_api.websocket_command(
@@ -210,13 +541,18 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
new_rev: int | None = None
async with rt.write_lock:
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
await rt.store.async_save({"layout": layout})
connection.send_result(msg["id"], {"ok": True})
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- space configuration ----------------
@@ -234,6 +570,47 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
def _internal_plan_names(config: dict[str, Any]) -> set[str]:
"""Plan file names a configuration names through OUR urls.
Only `/api/houseplan/content/plans/_/<name>` and the legacy static path
count. Anything else belongs to the user and may point wherever they like.
"""
out: set[str] = set()
for space in (config or {}).get("spaces") or []:
url = space.get("plan_url")
if not isinstance(url, str) or not url:
continue
if not (url.startswith(CONTENT_URL + "/plans/") or url.startswith(PLANS_URL + "/")):
continue
name = plan_basename(url)
if name:
out.add(name)
return out
def _missing_internal_plans(
plans_dir: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
) -> set[str]:
"""Newly named plan files that are not on disk.
Guards the pick-then-save window: another client may delete a plan between
the moment this one chose it and the moment it saves, which would otherwise
store a url with nothing behind it (HP-1470-02).
A name the stored configuration already carries is deliberately let through.
It is already broken — repairs says so — and refusing the write would lock
the owner out of every other edit, including the one that detaches it.
"""
known = _internal_plan_names(previous or {})
return {
name
for name in _internal_plan_names(config)
if name not in known and not (plans_dir / name).is_file()
}
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -255,6 +632,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
# Per-field limits bound each list; this bounds their product (HP-1454-05).
# Everything below the caps can still add up to something no dashboard can
# render, and the store writes it to disk on every save.
size = len(json.dumps(msg["config"], separators=(",", ":")))
if size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
current_rev = data.get("rev", 0)
@@ -273,9 +660,41 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
# nothing about whether the file survived (HP-1470-02). External and
# legacy urls are not ours to check and are left alone.
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
# Still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1).
# It is best-effort housekeeping behind an already durable write — a
# failure here must not withhold the event and the success response,
# or the client retries an edit the server has already accepted and
# gets a conflict for its trouble (R4-1).
def _collect() -> None:
collect_plans(Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"])
collect_attachments(Path(hass.config.path(FILES_DIR)), data.get("config"), msg["config"])
try:
await hass.async_add_executor_job(_collect)
except Exception: # noqa: BLE001 — see above: the commit stands regardless
_LOGGER.exception("House Plan: collecting superseded files failed")
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
_refresh_trail_recorder(hass)
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
if entry is not None:
@@ -315,20 +734,54 @@ 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
# nothing here; the file a commit REPLACES is collected by `config/set`
# itself, inside the write lock (review R3-1). An upload that never gets
# committed is not collected at all — it is offered back in the space
# dialog's "already uploaded" list, where the user can attach or delete it.
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
#
# `.` 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 _check_and_write() -> None:
# one executor job for the pair, under upload_lock: the measurement
# is only a bound if nothing else writes between it and our write
# (HP-1490-02). A failed write reserves nothing — the file either
# exists and is counted by the next scan, or does not and is not.
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
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}"}
)
data = _runtime(hass, connection, msg["id"])
if data is None:
return
async with data.upload_lock:
try:
await hass.async_add_executor_job(_check_and_write)
except QuotaError as err:
connection.send_error(msg["id"], err.reason, err.detail)
return
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
def _refresh_trail_recorder(hass: HomeAssistant) -> None:
"""Markers changed — the trail recorder must re-resolve what it watches."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
if rec:
hass.async_create_task(rec.async_refresh())
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
@websocket_api.async_response
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Current + previous cleanup runs per marker, raw robot coordinates."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
+4
View File
@@ -56,6 +56,10 @@ export async function launch(viewport = { width: 820, height: 760 }, scale = 1)
});
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
// the stage height settles — real pointer interaction cannot hit a hidden
// plan, so every smoke starts where the user does: with the plan revealed.
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
// hass flows continuously in production; the stub sets it once — nudge a rebuild
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
+31
View File
@@ -0,0 +1,31 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
await page.evaluate(async () => {
const c = window.__card;
// the flash is keyed to a WITNESSED off→on transition (owner's rule
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
const set = async (state) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
await c.updateComplete;
};
await set('off');
await set('on');
// freeze the pulse at a visible frame for a deterministic capture
const st = document.createElement('style');
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(300);
const box = await page.evaluate(() => {
const c = window.__card;
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const pad = 70;
await page.screenshot({ path: process.argv[2] || '/tmp/motion_sense.png',
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
await browser.close();
console.log('shot ok');
+2 -2
View File
@@ -33,7 +33,7 @@ const res = await page.evaluate(async () => {
const a = devs[0], b = devs[1];
const pa = c._pos(a);
// поставим b на тот же Y, начнём drag
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
c.requestUpdate(); await c.updateComplete;
out.devGuide = guides() >= 1;
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
c._setMode('decor'); await c.updateComplete;
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
const W = 1000, H = 1000; // square canvas
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
c.requestUpdate(); await c.updateComplete;
out.decorGuide = guides() >= 1;
+75
View File
@@ -0,0 +1,75 @@
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
const out = {};
// ---- HP-1490-03: content-fit только в просмотре -------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
// рукописное пространство: одна маленькая комната в центре квадрата
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inView = c._baseVb();
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
c._setMode('plan'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const inPlan = c._baseVb();
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
const v = c._viewOr(c._baseVb());
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
// в редакторе можно ткнуть в дальний угол холста
o.canReachFarCorner = (() => {
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
return pt[0] > 900 || pt[1] > 900;
})();
c._setMode('view'); await c.updateComplete;
await new Promise((r) => requestAnimationFrame(r));
const back = c._baseVb();
o.contentFitRestored = back[2] < 999;
return o;
}));
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const base = c.hass.callWS;
let saved = null;
let signDelay = 500; // подпись приходит поздно
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: [
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
] };
if (m.type === 'houseplan/content/sign') {
await new Promise((r) => setTimeout(r, signDelay));
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
}
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
return base(m);
} };
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
await c.updateComplete;
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
// Save сразу, до прихода подписи
const p = c._saveSpaceDialog();
await p;
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
const a = saved?.spaces?.[0]?.plan_aspect;
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
o.notTheOldAspect = a !== 1.25;
return o;
}));
await finish(browser, checkAll(out));
+144
View File
@@ -0,0 +1,144 @@
// New setting "background color around the plan": global (config.settings.bg_color,
// gear dialog) with a per-space override (space dialog, empty = inherit), applied
// to the stage in view and kiosk modes and to the static space-card. Editors keep
// their own canvas; the open dialogs preview the pending color live.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const stageBg = async () => {
c.requestUpdate();
await c.updateComplete;
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
};
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
const themeBg = await stageBg(); // theme default from the stylesheet
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
// 1) global setting colors the stage
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#224466' };
out.globalApplies = (await stageBg()) === rgb('#224466');
// 2) per-space override wins
sp.settings = { ...(sp.settings || {}), bg_color: '#663311' };
out.spaceOverrides = (await stageBg()) === rgb('#663311');
// 3) clearing the space setting inherits the global again
delete sp.settings.bg_color;
out.clearInheritsGlobal = (await stageBg()) === rgb('#224466');
// 4) clearing the global returns the theme default
delete c._serverCfg.settings.bg_color;
out.clearRestoresTheme = (await stageBg()) === themeBg;
// 5) invalid stored value is ignored (renderer never trusts garbage)
c._serverCfg.settings.bg_color = 'url(javascript:alert(1))';
out.garbageIgnored = (await stageBg()) === themeBg;
delete c._serverCfg.settings.bg_color;
// 6) the settings dialog previews its pending color live + saves it
let saved = null;
const origWS = c.hass.callWS;
c.hass.callWS = async (m) => {
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
return origWS.call(c.hass, m);
};
c._openSettingsDialog();
await c.updateComplete;
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
await c._saveSettingsDialog();
out.dialogSaves = saved?.settings?.bg_color === '#0a2a4a';
out.savedApplies = (await stageBg()) === rgb('#0a2a4a');
// 7) the space dialog previews and saves the override
c._openSpaceDialog('edit', c._space);
await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, bgColor: '#4a0a2a' };
out.spaceDialogPreviews = (await stageBg()) === rgb('#4a0a2a');
saved = null;
await c._saveSpaceDialog();
const savedSp = saved?.spaces?.find((s) => s.id === c._space);
out.spaceDialogSaves = savedSp?.settings?.bg_color === '#4a0a2a';
out.spaceSavedApplies = (await stageBg()) === rgb('#4a0a2a');
// ...and clearing it in the dialog inherits the global again
c._openSpaceDialog('edit', c._space);
await c.updateComplete;
out.spaceDialogPrefilled = c._spaceDialog.bgColor === '#4a0a2a';
c._spaceDialog = { ...c._spaceDialog, bgColor: null };
await c._saveSpaceDialog();
// over the wire `undefined` keys vanish — check the JSON form, like the backend sees it
const wire = JSON.parse(JSON.stringify(saved));
const savedSp2 = wire?.spaces?.find((s) => s.id === c._space);
out.spaceDialogClears = !('bg_color' in (savedSp2?.settings || {}));
out.backToGlobal = (await stageBg()) === rgb('#0a2a4a');
c.hass.callWS = origWS;
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
const editorBg = await stageBg();
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
// 9) kiosk instance respects the setting
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c.hass;
k.style.cssText = 'position:fixed;left:0;top:0;width:860px;height:860px;z-index:99';
document.body.appendChild(k);
await new Promise((r) => setTimeout(r, 300));
// the kiosk instance re-reads the pristine demo config — inject the color
// into ITS copy; what matters here is the kiosk rendering path
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
k.hass = { ...c.hass };
k.requestUpdate();
await k.updateComplete;
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
k.remove();
// 10) the static space-card paints the same background
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
const hass2 = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return origWS.call(c.hass, m);
} };
const scHost = document.createElement('div');
document.body.appendChild(scHost);
const sc = document.createElement('houseplan-space-card');
sc.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
sc.hass = hass2;
scHost.appendChild(sc);
const t0 = Date.now();
while (!sc.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 80));
await sc.updateComplete;
const st = sc.renderRoot.querySelector('.hp-static-stage');
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
// ...and the space override beats the global there too
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
sc.hass = { ...hass2 };
sc._cfg = null; // force a config re-read
await new Promise((r) => setTimeout(r, 200));
sc.requestUpdate(); await sc.updateComplete;
const st2 = sc.renderRoot.querySelector('.hp-static-stage');
out.staticCardSpaceWins = !!st2 && getComputedStyle(st2).backgroundColor === rgb('#654321');
scHost.remove();
// tidy: drop the color so later smokes on this page see the theme default
delete c._serverCfg.settings.bg_color;
const spx = c._serverCfg.spaces.find((s) => s.id === c._space);
if (spx?.settings) delete spx.settings.bg_color;
await stageBg();
return out;
});
checkAll(res);
await finish(browser, res);
+44
View File
@@ -0,0 +1,44 @@
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 calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
// устройство с управляемой сущностью
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('.dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|| body.querySelector('.entlist') === body.children[0];
const rows = [...sr().querySelectorAll('.entrow')];
out.rows = rows.length > 0;
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
const btn = sr().querySelector('.entbtn');
out.hasButton = !!btn;
const box = btn?.getBoundingClientRect();
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
// 3) кнопка реально переключает
const before = calls.length;
btn.click(); await c.updateComplete;
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
// 4) замок никогда не переключается из карточки
const n = calls.length;
c._cardToggle('lock.front_door');
out.lockNeverToggles = calls.length === n;
// 5) диагностические/конфиг-сущности не засоряют список
const ents = c._cardEntities(dev).map((e) => e.eid);
out.noConfigEntities = ents.every((e) => {
const cat = c.hass.entities[e]?.entity_category;
return cat !== 'config' && cat !== 'diagnostic';
});
c._infoCard = null; await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+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);
+67
View File
@@ -0,0 +1,67 @@
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
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 writes = [];
let rev = 10;
let releaseFirst;
const firstGate = new Promise((r) => { releaseFirst = r; });
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/set') {
const n = writes.length + 1;
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
if (n === 1) await firstGate; // первый ответ задержан
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
rev += 1;
return { ok: true, rev };
}
if (m.type === 'houseplan/config/get') {
const r = await base(m);
return { config: JSON.parse(JSON.stringify(r.config)), rev };
}
return base(m);
} };
c._cfgRev = rev;
// правка №1 и, пока первая запись висит, правка №2
c._serverCfg.spaces[0].title = 'FIRST';
c._saveConfig();
c._saveConfigDebounced.flush();
await new Promise((r) => setTimeout(r, 30));
out.oneInFlight = writes.length === 1;
c._serverCfg.spaces[0].title = 'SECOND';
c._saveConfig();
c._saveConfigDebounced.flush();
await new Promise((r) => setTimeout(r, 30));
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
releaseFirst();
await new Promise((r) => setTimeout(r, 120));
out.writes = writes.length;
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
out.noConflictToast = !(c._toast || '').length;
return out;
});
// зафиксировано прогоном на v1.46.0 и сверено с кодом
checkAll(res, {
oneInFlight: true,
stillOneInFlight: true,
writes: 2,
revisions: [10, 11],
secondCarriedTheEdit: true,
editSurvived: true,
noConflictToast: true,
});
await finish(browser);
+12
View File
@@ -25,10 +25,22 @@ const res = await page.evaluate(async () => {
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
// 3) линия и овал
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
await c.updateComplete;
// превью рисуемой линии уже с круглыми концами (зубцы на стыках)
const draftLine = sr().querySelector('.decorlayer line.ddraft');
out.draftLineRound = !!draftLine
&& draftLine.getAttribute('stroke-linecap') === 'round'
&& draftLine.getAttribute('stroke-linejoin') === 'round';
c._decorCommitDraft();
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
c._decorCommitDraft(); await c.updateComplete;
out.threeShapes = c._decorList.length === 3;
// сохранённые линии рендерятся с круглыми концами: диаметр = толщине линии,
// стык двух линий под углом — без зубца (скриншот владельца)
await c.updateComplete;
const lines = [...sr().querySelectorAll('.decorlayer line.dshape')];
out.savedLineRound = lines.length > 0 && lines.every((l) =>
l.getAttribute('stroke-linecap') === 'round' && l.getAttribute('stroke-linejoin') === 'round');
// вырожденная фигура не сохраняется
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
+2 -2
View File
@@ -11,7 +11,7 @@ const res = await page.evaluate(async () => {
document.body.appendChild(c1);
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150));
@@ -31,7 +31,7 @@ const res = await page.evaluate(async () => {
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, view_box:[0,0,1,1],
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
+65
View File
@@ -0,0 +1,65 @@
// На телефоне в редакторах не работали зум и навигация жестами: pointerdown
// сцены выходил сразу при _markup, так что пинч и пан не начинались вовсе.
// Рисование кликается, жесты двигаются — они совместимы; палец с движением
// панорамирует, два пальца зумируют, отпускание после жеста не рисует точку.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const pd = (c, id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (c, id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (c, id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
// --- пинч-зум в редакторе плана -----------------------------------------
out.pinchZoomsInPlanEditor = await page.evaluate(() => {
const c = window.__card;
c._setMode('plan');
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
c._resetZoom();
const z0 = c._zoom;
pd(1, 300, 300); pd(2, 400, 300); // два пальца
pm(1, 250, 300); pm(2, 450, 300); // разводим
const zoomed = c._zoom > z0 * 1.5;
pu(1, 250, 300); pu(2, 450, 300);
return zoomed && c._path.length === 0;
});
// --- пан одним пальцем в редакторе, точка не рисуется --------------------
out.panWorksAndDoesNotDraw = await page.evaluate(async () => {
const c = window.__card;
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pm = (id, x, y) => c._stagePointerMove({ pointerId: id, clientX: x, clientY: y });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
const st = c._stageEl;
c._zoomAt(st.clientWidth / 2, st.clientHeight / 2, 3); // есть куда панорамировать
const v0 = { ...c._view };
pd(3, 300, 300);
pm(3, 380, 340); pm(3, 420, 360);
const panned = Math.abs(c._view.x - v0.x) > 1 || Math.abs(c._view.y - v0.y) > 1;
const suppressed = c._suppressClick === true;
c._markupClick({ composedPath: () => [], clientX: 420, clientY: 360 }); // синтезированный click после пана
const noDot = c._path.length === 0;
pu(3, 420, 360);
await new Promise((r) => setTimeout(r, 10));
return panned && suppressed && noDot;
});
// --- обычный клик без движения по-прежнему рисует ------------------------
out.tapStillDraws = await page.evaluate(() => {
const c = window.__card;
const pd = (id, x, y) => c._stagePointerDown({ pointerId: id, clientX: x, clientY: y, target: c._stageEl, preventDefault() {} });
const pu = (id, x, y) => c._stagePointerUp({ pointerId: id, clientX: x, clientY: y });
c._resetZoom();
c._tool = 'draw';
const before = c._path.length;
pd(4, 300, 300); pu(4, 300, 300);
const st = c._stageEl.getBoundingClientRect();
c._markupClick({ composedPath: () => [], clientX: st.left + 200, clientY: st.top + 200 });
const drew = c._path.length > before;
c._path = []; c._setMode('view');
return drew;
});
await finish(browser, checkAll(out));
+52
View File
@@ -0,0 +1,52 @@
import { launch, check, 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;
// 1) кнопка настроек комнаты в редакторе плана: заметная, фиксированного размера
c._setMode('plan'); await c.updateComplete;
const btn = sr().querySelector('.rlgearbtn');
out.gearButtonShown = !!btn;
const cs = btn ? getComputedStyle(btn) : null;
// 2026-07-29: владелец вдвое уменьшил кнопку — контракт теперь не «не
// меньше N px», а «размер от иконки устройства и кликабельность»
out.gearReadable = cs ? parseFloat(cs.fontSize) > 0 && cs.pointerEvents === 'auto' : null;
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
const box = btn?.getBoundingClientRect();
// (в редакторе плана .dev скрыты display:none — сравнивать не с чем)
out.gearTapTarget = box ? box.height > 6 && box.height < 40 : null;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
c._roomDialogCancel(); await c.updateComplete;
// 2) комната без имени тоже получает кнопку (её там и называют)
const room = c._curSpaceCfg.rooms[0];
const savedName = room.name;
room.name = '';
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.unnamedStillHasGear = sr().querySelectorAll('.rlgearbtn').length >= 1;
room.name = savedName; c._saveConfig(); c.requestUpdate(); await c.updateComplete;
// 3) метрики стали крупнее: 0.75em вместо 0.62em
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
c._setMode('view'); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 150));
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.querySelector('.rlmetrics'));
if (lbl) {
const nameSz = parseFloat(getComputedStyle(lbl.querySelector('.rlname')).fontSize);
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
} else out.metricsRatio = 'no-metrics';
// 4) касание помечает сессию как тач и гасит тултип
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
out.touchClearsTip = c._tip === null;
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
out.noTipAfterTouch = !c._tip;
return out;
});
check('metricsRatio 0.75', res.metricsRatio, 0.75);
delete res.metricsRatio;
checkAll(res);
await finish(browser, res);
+2 -2
View File
@@ -28,8 +28,8 @@ const res = await page.evaluate(async () => {
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 11,
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
"rows": 12, // 10 цветов + радиус свечения + фон вокруг плана
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background"],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
+19 -4
View File
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
const c2 = c._roomCenter(r2);
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
// общая стена вертикальная — дверь ставим на неё
const H = 1000 / (c._curSpaceCfg.aspect || 1);
const H = 1000; // square canvas
const doorPt = (() => {
let best = null, bd = 1e9;
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
@@ -57,9 +57,8 @@ const res = await page.evaluate(async () => {
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete;
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
const aspect = c._curSpaceCfg.aspect || 1;
const c1 = c._roomCenter(r1);
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
c.requestUpdate(); await c.updateComplete;
@@ -73,7 +72,7 @@ const res = await page.evaluate(async () => {
const minX = Math.min(...poly1.map((p) => p[0]));
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete;
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
@@ -93,6 +92,22 @@ const res = await page.evaluate(async () => {
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
if (swDev) {
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
c._serverCfg = { ...c._serverCfg, markers: [
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
+237
View File
@@ -0,0 +1,237 @@
// docs/FILTERING.md: скрытие — явная галка. Конфиг материализуется сеятелем
// (filter_seeded), галка в диалоге у всех устройств, «Показать скрытые» —
// локальный режим редактора, скрытые рисуются призраками и только там.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// --- сеятель материализует конфиг при загрузке ---------------------------
out.configSeeded = await page.evaluate(async () => {
const c = window.__card;
const t0 = Date.now();
while (!c._serverCfg?.settings?.filter_seeded && Date.now() - t0 < 3000) {
await new Promise((r) => setTimeout(r, 60));
}
return c._serverCfg?.settings?.filter_seeded === true;
});
// --- галка прячет, счётчик не считает, призрак только в редакторе --------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const visibleIds = () => [...sr().querySelectorAll('.dev')].length;
const before = visibleIds();
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
o.hiddenNotRendered = visibleIds() === before - 1;
const built = c._devices.find((x) => x.id === d.id);
o.stillBuilt = !!built && built.hidden === true;
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
o.noGhostsInView = !sr().querySelector('.dev.ghost');
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
const g = sr().querySelector('.dev.ghost');
o.ghostHasNoState = !!g && !g.classList.contains('on') && !g.classList.contains('open')
&& !g.classList.contains('unavail') && !g.classList.contains('alarm');
// и он синий, а не тёмный — отличим от недоступного устройства
o.ghostIsBlue = !!g && getComputedStyle(g).borderStyle.includes('dashed')
&& getComputedStyle(g).borderColor !== 'rgb(255, 255, 255)';
// тумблер локальный: конфиг не трогается
o.toggleIsLocal = c._serverCfg.settings.show_all === undefined;
// --- галка в диалоге у авто-устройства, кнопки «Удалить» нет -----------
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:' + d.bindingRef);
o.untickKeepsMarker = !!m && m.hidden === false;
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
o.deviceVisibleAgain = !!back && !back.hidden;
// --- у виртуального кнопка «Удалить» есть -------------------------------
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
c._markerDialog = null; c._setMode('view');
return o;
}));
// --- HP-1510-02: призрак не показывает live-значения ----------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// датчик с числом + display:value, скрыт
c.hass = { ...c.hass, states: { ...c.hass.states,
'sensor.power_meter': { state: '42', attributes: { unit_of_measurement: 'kW' } } } };
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: 'pm', binding: 'entity:sensor.power_meter',
display: 'value', hidden: true, space: 'f1' });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('devices'); c._showHidden = true;
c.requestUpdate(); await c.updateComplete;
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
const pm = ghosts.find((g) => g.textContent.includes('42')) || null;
o.ghostHidesValue = pm === null; // «42 kW» не отрисован
o.ghostNoLiveBadges = ghosts.every((g) =>
!g.querySelector('.valtext') && !g.querySelector('.tval') && !g.querySelector('.hval') && !g.querySelector('.lqi'));
// у КАЖДОГО призрака есть базовая иконка — «noicon» не оправдание
// (HP-1511-02: ripple-призрак был безликим пульсом)
o.ghostKeepsIcon = ghosts.every((g) => !!g.querySelector('ha-icon') && !g.classList.contains('noicon'));
c._setMode('view'); c._showHidden = false;
return o;
}));
// --- HP-1511-02: ripple-призрак с базовой иконкой, без пульса -------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const lamp = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device' && !x.hidden);
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._serverCfg.markers.push({ id: lamp.id, binding: 'device:' + lamp.bindingRef,
display: 'ripple', hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('devices'); c._showHidden = true;
c.requestUpdate(); await c.updateComplete;
const g = [...sr().querySelectorAll('.dev.ghost')].find((x) => x.querySelector('.ripple') || !x.classList.contains('noicon'));
const ghosts = [...sr().querySelectorAll('.dev.ghost')];
o.rippleGhostHasIcon = ghosts.length > 0 && ghosts.every((x) => !!x.querySelector('ha-icon'));
o.rippleGhostNoNoicon = ghosts.every((x) => !x.classList.contains('noicon'));
o.rippleGhostNoRipple = ghosts.every((x) => !x.querySelector('.ripple'));
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lamp.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c._setMode('view'); c._showHidden = false;
return o;
}));
// --- HP-1511-01: авто-сетка одинакова на обеих карточках ------------------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
// один видимый, остальные зоны living_room скрыты; layout пуст
const vis = c._devices.filter((x) => x.area === 'living_room' && !x.virtual);
const keep = vis[0];
for (const d of vis.slice(1)) {
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
}
cfg.markers = cfg.markers.filter((m) => m.id !== keep.id || !m.hidden);
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._layout = {}; // пустой layout: работают только авто-позиции
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const full = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
.find((el) => el.style.left && !el.classList.contains('ghost'));
// проценты полной карточки считаются от content-fit view — переводим в vb
const v = c._viewOr(c._baseVb());
const fullVb = full && [
v.x + (parseFloat(full.style.left) / 100) * v.w,
v.y + (parseFloat(full.style.top) / 100) * v.h,
];
const hass = { ...c.hass, callWS: async (m) => {
// rev уникален: у статичной карточки модульный кэш конфига по rev,
// и одинаковый rev в соседних тестах подсовывает чужой конфиг
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 31 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 31 };
return { ok: true };
},
// модульный кэш конфига инвалидируется только через это событие — стаб
// сохраняет колбэк, чтобы следующий тест мог сбросить кэш
connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('[style*="left"]') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
// статичная карточка рендерит от полного квадрата 0..1000
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
o.autoGridParity = !!fullVb && !!stVb
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
host.remove();
return o;
}));
// --- HP-1510-01: LQI комнаты одинаков на полной и статичной карточке ------
Object.assign(out, await page.evaluate(async () => {
const o = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.settings = { ...(f1.settings || {}), fill_mode: 'lqi', show_borders: true };
// прячем ВСЕ устройства зоны living_room: комнату красит только скрытое
for (const d of c._devices.filter((x) => x.area === 'living_room' && !x.virtual)) {
if (!cfg.markers.some((m) => m.id === d.id)) {
cfg.markers.push({ id: d.id, binding: d.bindingKind + ':' + d.bindingRef, hidden: true });
} else {
cfg.markers = cfg.markers.map((m) => (m.id === d.id ? { ...m, hidden: true } : m));
}
}
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const fullRoom = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.room')]
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
const fullFill = fullRoom ? (fullRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
window.__hpInvalidate?.(); // сбросить модульный кэш от предыдущей карточки
const hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 57 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 57 };
return { ok: true };
},
connection: { subscribeEvents: async () => () => {} } };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.room') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const stRoom = [...card.renderRoot.querySelectorAll('.room')]
.find((r) => (r.getAttribute('style') || '').includes('--room-fill'));
const stFill = stRoom ? (stRoom.getAttribute('style').match(/--room-fill:([^;]+)/) || [])[1] : null;
o.fullPaintsHiddenLqi = !!fullFill;
o.staticPaintsHiddenLqi = !!stFill;
o.lqiParity = !!fullFill && fullFill === stFill;
host.remove();
return o;
}));
await finish(browser, checkAll(out));
+94
View File
@@ -0,0 +1,94 @@
// Множитель «размер значка» обязан масштабировать ГЛИФ вместе с подложкой:
// --mdc-icon-size считался от базового --icon-size, и «увеличенный» значок
// был пустой рамкой вокруг дефолтной иконки (репорт пользователя, 2026-07-29).
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
const glyphW = () => {
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
const ic = el?.querySelector('ha-icon');
return ic ? ic.getBoundingClientRect().width : 0;
};
const badgeW = () => {
const el = [...sr().querySelectorAll('.dev')].find((e) => !e.classList.contains('ghost'));
return el ? el.getBoundingClientRect().width : 0;
};
// базовый размер
const g1 = glyphW(), b1 = badgeW();
// множитель ×3 на всех авто-устройствах через маркер первого
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== d.id);
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3 });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 100));
const scaled = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
const g3 = scaled?.querySelector('ha-icon')?.getBoundingClientRect().width || 0;
const b3 = scaled?.getBoundingClientRect().width || 0;
o.badgeScales = b3 > b1 * 2.5;
o.glyphScalesWithBadge = g3 > g1 * 2.5; // раньше глиф оставался базовым
o.glyphFillsBadge = g3 / b3 > 0.45 && g3 / b3 < 0.8; // пропорция сохранена
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
checkAll(out);
// Тот же класс бага для СПУТНИКОВ значка (репорт владельца, 2026-08-01):
// бейдж значения (display:value) и temp-плашка считались от базового
// --icon-size и оставались маленькими при size=2. Всё, что визуально
// принадлежит устройству, обязано расти вместе с --dev-scale.
const out2 = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const devEl = () => {
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === 'd_temp');
return sr().querySelectorAll('.dev')[idx];
};
const setMarker = async (extra) => {
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== 'd_temp');
c._serverCfg.markers.push({ id: 'd_temp', binding: 'device:d_temp', ...extra });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 80));
};
const fs = (el) => parseFloat(getComputedStyle(el).fontSize);
// базовый --icon-size в px: ширина немасштабированного бейджа минус рамка 1px×2
const plain = [...sr().querySelectorAll('.dev')].find(
(e) => !e.classList.contains('ghost') && !e.classList.contains('valonly') && !(e.getAttribute('style') || '').includes('--dev-scale'));
const iconPx = plain.getBoundingClientRect().width - 2;
// --- бейдж значения (valonly) ---
await setMarker({ display: 'value', size: 1 });
const v1 = devEl().getBoundingClientRect();
const vf1 = fs(devEl().querySelector('.valtext'));
// при size=1 дефолт не изменился: шрифт ровно прежние 0.45 * icon-size
o.valDefaultKept = Math.abs(vf1 - iconPx * 0.45) < 0.75;
await setMarker({ display: 'value', size: 2 });
const v2 = devEl().getBoundingClientRect();
const vf2 = fs(devEl().querySelector('.valtext'));
o.valFontScales = vf2 / vf1 > 1.8 && vf2 / vf1 < 2.2;
o.valPlateScales = (v2.height - 2) / (v1.height - 2) > 1.8 && (v2.height - 2) / (v1.height - 2) < 2.2
&& v2.width / v1.width > 1.6 && v2.width / v1.width < 2.4;
// --- temp-плашка (.tval) рядом со значком ---
await setMarker({ size: 1 });
const t1 = devEl().querySelector('.tval').getBoundingClientRect();
const tf1 = fs(devEl().querySelector('.tval'));
// дефолт: line-height 0.68 * icon-size + рамка 1px×2
o.tvalDefaultKept = Math.abs(t1.height - (iconPx * 0.68 + 2)) < 1;
await setMarker({ size: 2 });
const t2 = devEl().querySelector('.tval').getBoundingClientRect();
const tf2 = fs(devEl().querySelector('.tval'));
o.tvalScales = (t2.height - 2) / (t1.height - 2) > 1.8 && (t2.height - 2) / (t1.height - 2) < 2.2
&& tf2 / tf1 > 1.8 && tf2 / tf1 < 2.2;
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== 'd_temp');
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out2));
+103
View File
@@ -0,0 +1,103 @@
// HP-1460-03: позиции — отдельное состояние. В v1.46.0 событие layout_updated
// научилась слушать статическая карточка, а полная — нет, поэтому две полные
// карточки рядом расходились до перезагрузки. Проверяем и обратное: приход
// чужой ревизии не должен затирать перетаскивание, которое ещё не улетело.
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;
// общий «сервер»: layout с ревизией и подписчики на событие
let rev = 5;
let layout = { dev_a: { x: 10, y: 10 }, dev_b: { x: 20, y: 20 } };
const subs = [];
let gets = 0;
const hass = { ...c.hass,
callWS: async (m) => {
if (m.type === 'houseplan/layout/get') { gets++; return { layout: JSON.parse(JSON.stringify(layout)), rev }; }
if (m.type === 'houseplan/layout/update') {
layout = { ...layout, [m.device_id]: m.pos }; rev += 1;
subs.forEach((f) => f({ data: { rev } }));
return { ok: true, rev };
}
return base(m);
},
connection: { subscribeEvents: async (cb, ev) => {
if (ev === 'houseplan_layout_updated') { subs.push(cb); return () => {}; }
return () => {};
} },
};
c.hass = hass;
c._serverStorage = true;
c._layout = JSON.parse(JSON.stringify(layout));
c._layoutRev = rev;
c._unsubLayout = await hass.connection.subscribeEvents(
(e) => c._onLayoutEvent(Number(e?.data?.rev ?? -1)),
'houseplan_layout_updated',
);
out.subscribed = subs.length === 1;
// 1) чужая карточка подвинула иконку — наша обязана подхватить без перезагрузки
layout = { ...layout, dev_a: { x: 77, y: 88 } }; rev += 1;
subs.forEach((f) => f({ data: { rev } }));
await new Promise((r) => setTimeout(r, 350));
out.adoptedRemoteMove = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 77, y: 88 });
out.revFollowed = c._layoutRev === rev;
// 2) собственная запись не вызывает лишнего перечитывания
const before = gets;
c._layout = { ...c._layout, dev_b: { x: 31, y: 32 } };
c._dirtyPos.add('dev_b');
c._persistLayout();
c._persistLayout.flush();
await new Promise((r) => setTimeout(r, 350));
out.ownWriteNoReload = gets === before;
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
// 3) НАСТОЯЩЕЕ перетаскивание: debounce запланирован, запись задержана, а
// layout/get отвечает мгновенно. Именно этот порядок и терял позицию:
// flush() внутри перечитывания опустошал _dirtyPos ДО снятия снимка.
let releaseUpdate;
const updateGate = new Promise((r) => { releaseUpdate = r; });
let delayUpdate = true;
const plain = hass.callWS;
c.hass = { ...hass, callWS: async (m) => {
if (m.type === 'houseplan/layout/update' && delayUpdate) {
delayUpdate = false;
await updateGate;
}
return plain(m);
} };
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
c._dirtyPos.add('dev_a');
c._persistLayout(); // debounce запланирован, не сброшен вручную
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
subs.forEach((f) => f({ data: { rev } }));
await new Promise((r) => setTimeout(r, 400));
out.dragKeptWhileWriteInFlight = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
releaseUpdate();
await new Promise((r) => setTimeout(r, 350));
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.serverAgrees = JSON.stringify(layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
out.sentPosDrained = c._sentPos.size === 0;
return out;
});
// зафиксировано прогоном на v1.46.1 и сверено с кодом
checkAll(res, {
subscribed: true,
adoptedRemoteMove: true,
revFollowed: true,
ownWriteNoReload: true,
ownWriteKept: true,
dragKeptWhileWriteInFlight: true,
remoteChangeApplied: true,
localDragSurvived: true,
serverAgrees: true,
sentPosDrained: true,
});
await finish(browser);
+65
View File
@@ -0,0 +1,65 @@
// HP-1503-01: один и тот же повреждённый store обязан рендериться одинаково в
// ОБЕИХ карточках. Полная карточка строила модель рукописной копией
// spaceModels и прошла мимо safeViewBox/normRect: статическая показывала
// восстановленный план, полная — viewBox="0 0 0 0" и rect с отрицательной
// шириной. Вектор аудита исполняется через обе модели и оба DOM-дерева.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
// ровно вектор из отчёта: нулевой viewport + отрицательный legacy-rect
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.plan_url = null; f1.plan_aspect = null;
f1.view_box = [0, 0, 0, 0];
f1.rooms = [{ id: 'r1', name: 'R', area: 'living_room', x: 0.6, y: 0.7, w: -0.2, h: -0.3 }];
f1.settings = { ...(f1.settings || {}), show_borders: true };
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return { ok: true };
} };
main._serverCfg = cfg;
main._cfgEpoch++;
main._view = null;
main.requestUpdate(); await main.updateComplete;
await new Promise((r) => setTimeout(r, 150));
// модель полной карточки: fallback на весь холст + нормализованный rect
const m = main._spaceModel('f1');
out.fullVbFallsBack = JSON.stringify(m.vb) === JSON.stringify([0, 0, 1000, 1000]);
const r = m.rooms[0];
out.fullRectNormalised =
Math.round(r.x) === 400 && Math.round(r.y) === 400
&& Math.round(r.w) === 200 && Math.round(r.h) === 300;
// DOM полной карточки: конечный положительный viewBox, никаких минусов в rect
const svg = (main.shadowRoot || main.renderRoot).querySelector('.stage svg');
const vbAttr = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
out.fullDomViewBoxSane = vbAttr.length === 4 && vbAttr[2] > 0 && vbAttr[3] > 0;
const rect = (main.shadowRoot || main.renderRoot).querySelector('.stage svg rect.room, .stage svg .room rect, .stage svg rect[width]');
out.fullDomRectSane = !rect || (Number(rect.getAttribute('width')) >= 0 && Number(rect.getAttribute('height')) >= 0);
// статическая карточка того же store — паритет
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('svg') && Date.now() - t0 < 6000) {
await new Promise((r2) => setTimeout(r2, 60));
}
await card.updateComplete;
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
out.staticDomViewBoxSane = svb.length === 4 && svb[2] > 0 && svb[3] > 0;
out.parity = JSON.stringify(vbAttr.length === 4 && svb.length === 4
? [vbAttr[2] > 0, vbAttr[3] > 0] : null) === JSON.stringify([svb[2] > 0, svb[3] > 0]);
return out;
});
await finish(browser, checkAll(res));
+78
View File
@@ -0,0 +1,78 @@
// Правило владельца (2026-07-29): у источников света подложка в glow-режиме
// всегда стандартная — индикатор включения это ПЯТНО; в остальных режимах
// горящий источник жёлтый, как греющая термоголовка. RGB лампы красит только
// пятно (и фолбэк цвета пульсации); окраска иконки/рамки убрана. Морф иконки
// остаётся везде. Розетка в glow-режиме остаётся жёлтой — она не источник.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const setFill = async (mode) => {
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.settings = { ...(f1.settings || {}), fill_mode: mode };
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 80));
};
const lamp = c._devices.find((x) => x.id === 'd_lamp');
const kettle = c._devices.find((x) => x.id === 'd_kettle'); // розетка/чайник
// зажигаем лампу с RGB и розетку
c.hass = { ...c.hass, states: { ...c.hass.states,
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'on',
attributes: { ...(c.hass.states[lamp.primary]?.attributes || {}), rgb_color: [255, 120, 40] } },
[kettle.primary]: { ...c.hass.states[kettle.primary], state: 'on' } } };
const devEl = (id) => {
// надёжнее по индексу в списке devs: ищем по позиции через _pos
const d = c._devices.find((x) => x.id === id);
const v = c._viewOr(c._baseVb());
const p = c._pos(d);
const left = (((p.x - v.x) / v.w) * 100).toFixed(0);
return [...sr().querySelectorAll('.dev')].find((e) => Math.abs(parseFloat(e.style.left) - left) < 1.5);
};
// --- режим light: горящая RGB-лампа ЖЁЛТАЯ, без rgb-класса --------------
await setFill('light');
let le = devEl('d_lamp');
o.litLampYellowInLightMode = !!le && le.classList.contains('on') && !le.classList.contains('rgb');
o.morphInLightMode = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- режим glow: лампа ТЁМНАЯ (индикатор — пятно), розетка жёлтая -------
await setFill('glow');
le = devEl('d_lamp');
const ke = devEl('d_kettle');
o.litLampDarkInGlow = !!le && !le.classList.contains('on') && !le.classList.contains('rgb');
o.glowSpotPresent = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
o.socketStaysYellowInGlow = !!ke && ke.classList.contains('on');
o.morphInGlow = le?.querySelector('ha-icon')?.getAttribute('icon')?.includes('lightbulb') ?? false;
// --- HP-1520-01: в редакторах, где glow-слой скрыт, индикатор возвращается
// (лампа горит; в devices-редакторе слой рисуется — там лампа тёмная,
// в plan-редакторе слоя нет — там класс on должен вернуться)
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
le = devEl('d_lamp');
o.devicesEditorLampDark = !!le && !le.classList.contains('on');
o.devicesEditorHasGlow = !!sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// ИМЕННО лампа (mutation-проверка аудита HP-1521-01: розетка тоже on и
// удовлетворяла бы поиску «любой .dev.on», пряча регресс самой лампы)
le = devEl('d_lamp');
o.planEditorLampYellow = !!le && le.classList.contains('on'); // слой скрыт — жёлтый вернулся
o.planEditorKettleStillOn = !!devEl('d_kettle')?.classList.contains('on');
o.planEditorNoGlow = !sr().querySelector('.stage svg radialGradient, .stage svg [id*=glow]');
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
// --- выключенная лампа тёмная в обоих режимах ---------------------------
c.hass = { ...c.hass, states: { ...c.hass.states,
[lamp.primary]: { ...c.hass.states[lamp.primary], state: 'off' } } };
c.requestUpdate(); await c.updateComplete;
le = devEl('d_lamp');
o.offLampDarkInGlow = !!le && !le.classList.contains('on');
await setFill('light');
le = devEl('d_lamp');
o.offLampDarkInLightMode = !!le && !le.classList.contains('on');
return o;
});
await finish(browser, checkAll(out));
+1
View File
@@ -5,6 +5,7 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
window.confirm = () => true; // review CR-1: unlocking now confirms
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
await c.updateComplete;
// добавить дверь с замком на f1
+48
View File
@@ -0,0 +1,48 @@
// review CR-1: exercise EVERY actuation path and prove locks/alarms are safe
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 calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push(`${d}.${s}:${data.entity_id}`); return Promise.resolve(); },
states: { ...c.hass.states,
'lock.front_door': { state: 'locked', attributes: { friendly_name: 'Front door' } },
'alarm_control_panel.home': { state: 'armed_away', attributes: {} } } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
const lockCalls = () => calls.filter((x) => x.includes('lock.') || x.includes('alarm_control_panel.'));
// 1) тап по значку устройства с замком
const lockDev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('lock.'))) || c._devices[0];
const fake = { ...lockDev, primary: 'lock.front_door', tapAction: 'toggle',
marker: { ...(lockDev.marker || {}), tap_action: 'toggle' } };
c._clickDevice(new MouseEvent('click'), fake);
out.iconTapSafe = lockCalls().length === 0;
// 2) controls[] с замком внутри
const withControls = { ...fake, tapAction: 'toggle',
marker: { controls: ['lock.front_door', 'alarm_control_panel.home'], tap_action: 'toggle' } };
c._clickDevice(new MouseEvent('click'), withControls);
out.controlsSafe = lockCalls().length === 0;
// 3) карточка устройства: замок отдаётся в more-info, а не тумблером
const kinds = c._cardEntities({ ...fake, entities: ['lock.front_door', 'alarm_control_panel.home'] });
out.cardNoToggleForLocks = kinds.every((k) => k.kind !== 'toggle');
c._cardToggle('lock.front_door');
c._cardToggle('alarm_control_panel.home');
out.cardToggleRefuses = lockCalls().length === 0;
// 4) кнопка в карточке двери — единственная разрешённая поверхность, и спрашивает подтверждение
let asked = null;
window.confirm = (msg) => { asked = msg; return false; };
c._lockAction('lock.front_door', 'unlock');
out.unlockAsksConfirm = asked !== null && lockCalls().length === 0;
window.confirm = () => true;
c._lockAction('lock.front_door', 'unlock');
out.unlockAfterConfirm = calls.at(-1) === 'lock.unlock:lock.front_door';
// запирание не спрашивает
asked = null;
window.confirm = (m) => { asked = m; return true; };
c._lockAction('lock.front_door', 'lock');
out.lockNoConfirm = asked === null && calls.at(-1) === 'lock.lock:lock.front_door';
return out;
});
checkAll(res);
await finish(browser, res);
+1 -2
View File
@@ -38,8 +38,7 @@ const res = await page.evaluate(async () => {
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
const center = c._roomCenter(room);
const vpos = c._layout[vid];
const aspect = c._curSpaceCfg.aspect || 1;
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
return out;
});
checkAll(res);
+1 -1
View File
@@ -9,7 +9,7 @@ const restore = () => page.evaluate((s) => {
}, snap);
// norm→render helper mirrors what _markupClick passes to handlers
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
return [nx * 1000, ny * 1000]; // the canvas is square (v1.48.0)
}, [nx, ny]);
const S = () => page.evaluate(() => {
const c = window.__card;
+2 -1
View File
@@ -13,7 +13,8 @@ const st = () => page.evaluate(() => {
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
stageClass: sr.querySelector('.stage').className,
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
};
});
// 1) старт: view, чистая шапка
+122
View File
@@ -0,0 +1,122 @@
// Правило владельца (2026-08-01, вариант «б»): «движение = разовая вспышка
// в момент обнаружения; cool-down не пульсирует; присутствие = статичное
// кольцо пока обитаемо». Датчик ДВИЖЕНИЯ (device_class motion) при переходе
// off→on получает класс 'senseflash' — короткая вспышка кольца (hp-sense,
// 3 удара, ~3.3 c), после чего класс снимается, ДАЖЕ если сущность всё ещё
// висит в on (это cool-down датчика, а не движение). Новый цикл off→on —
// новая вспышка. Датчики ПРИСУТСТВИЯ (occupancy/presence) при on несут
// 'sensehold' — статичное жёлтое кольцо без анимации (opacity 0.4). Жёлтая
// заливка .dev.on в обоих случаях не участвует; ghost — без кольца
// (призрак — конфигурация, не статус).
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 setMotion = async (state, device_class) => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
attributes: { friendly_name: 'Motion', device_class, linkquality: 64 } } } };
await c.updateComplete;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// --- motion off→on: вспышка senseflash, 3 удара, подложка НЕ жёлтая -----
await setMotion('off', 'motion');
out.offNoFlash = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
await setMotion('on', 'motion');
const flashEl = sr().querySelector('.dev.senseflash');
out.motionTripFlashes = !!flashEl;
out.flashIsNotOn = !!flashEl && !flashEl.classList.contains('on') && !flashEl.classList.contains('open');
const after = flashEl ? getComputedStyle(flashEl, '::after') : null;
out.flashAnimated = !!after && after.animationName === 'hp-sense';
out.flashFinite = !!after && after.animationIterationCount === '3'; // не infinite!
// подложка нейтральная — как у обычного .dev, не как у .dev.on
const neutral = [...sr().querySelectorAll('.dev')].find((e) =>
!e.classList.contains('senseflash') && !e.classList.contains('sensehold')
&& !e.classList.contains('on') && !e.classList.contains('open')
&& !e.classList.contains('alarm') && !e.classList.contains('unavail') && !e.classList.contains('valonly'));
const bg = flashEl ? getComputedStyle(flashEl).backgroundColor : '';
out.flashBgNeutral = !!neutral && bg === getComputedStyle(neutral).backgroundColor;
out.flashBgNotYellow = bg !== '' && bg !== 'rgb(255, 212, 92)'; // --hp-on
// --- КЛЮЧЕВОЙ ассерт: вспышка гаснет, хотя state всё ещё on -------------
// (cool-down датчика — не движение: класс обязан уйти сам по таймеру)
await sleep(3600);
await c.updateComplete;
out.stateStillOn = c.hass.states['binary_sensor.hall_motion'].state === 'on';
out.flashEndsWhileOn = sr().querySelectorAll('.dev.senseflash').length === 0;
// --- повторный цикл off→on: новая вспышка --------------------------------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
out.retripFlashesAgain = sr().querySelectorAll('.dev.senseflash').length === 1;
// --- HP-1543-02: быстрый повторный off→on ДО конца текущей вспышки ------
// обязан ПЕРЕЗАПУСТИТЬ анимацию (новый timeline). Раньше класс senseflash
// не снимался и animation-name не менялся — браузер продолжал старый
// timeline, и после его конца (base opacity 0) второй trip был невидим.
await sleep(3600); await c.updateComplete; // дать текущей вспышке догореть
const senseAnim = (el) => el && el.getAnimations({ subtree: true })
.find((a) => a.animationName && a.animationName.startsWith('hp-sense'));
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №1
out.rapidFirstTripFlashes = sr().querySelectorAll('.dev.senseflash').length === 1;
await sleep(1200); // середина первой вспышки (< 3.3s)
const a1 = senseAnim(sr().querySelector('.dev.senseflash'));
out.rapidFirstAnimMidway = !!a1 && a1.currentTime > 900 && a1.currentTime < 3300;
await setMotion('off', 'motion');
await setMotion('on', 'motion'); // trip №2 — retrip до конца вспышки
out.rapidRetripKeepsFlash = sr().querySelectorAll('.dev.senseflash').length === 1;
const a2 = senseAnim(sr().querySelector('.dev.senseflash'));
// новый timeline: currentTime у начала (старый уже был ~1.2s и продолжал бы расти)
out.rapidRetripRestartsAnim = !!a2 && a2.currentTime != null && a2.currentTime < 300;
// кольцо реально играет ПОСЛЕ момента, где закончилась бы первая анимация:
// ~2.3s после trip №2 = ~3.5s после trip №1 (старый timeline уже отыграл бы)
await sleep(2300);
const el3 = sr().querySelector('.dev.senseflash');
const a3 = senseAnim(el3);
out.rapidSecondFlashStillPlays = !!a3 && a3.playState === 'running';
out.rapidRingVisibleAfterFirstWindow = !!el3
&& parseFloat(getComputedStyle(el3, '::after').opacity) > 0.03;
// и класс уходит через полные ~3.3s после ВТОРОГО trip (flashTs/таймер обновлены)
await sleep(1400); await c.updateComplete;
out.rapidFlashEndsAfterSecondWindow = sr().querySelectorAll('.dev.senseflash').length === 0;
// --- occupancy: статичное кольцо sensehold, без анимации ----------------
await setMotion('on', 'occupancy');
const holdEl = sr().querySelector('.dev.sensehold');
out.occupancyHolds = !!holdEl;
const hAfter = holdEl ? getComputedStyle(holdEl, '::after') : null;
out.holdNotAnimated = !!hAfter && hAfter.animationName === 'none';
out.holdRingFaint = !!hAfter && Math.abs(parseFloat(hAfter.opacity) - 0.4) < 0.01;
out.holdBgNeutral = !!holdEl && !!neutral
&& getComputedStyle(holdEl).backgroundColor === getComputedStyle(neutral).backgroundColor;
out.holdNoFlashClass = !!holdEl && !holdEl.classList.contains('senseflash');
await setMotion('off', 'occupancy');
out.occupancyOffClears = sr().querySelectorAll('.dev.sensehold').length === 0;
// --- presence аналогично -------------------------------------------------
await setMotion('on', 'presence');
out.presenceHolds = sr().querySelectorAll('.dev.sensehold').length === 1;
// и это именно sense-состояние, не тревога и не «открыто»
out.noAlarmNoOpen = !sr().querySelector('.dev.sensehold.alarm') && !sr().querySelector('.dev.sensehold.open');
// --- ghost не рисует ничего (призрак — конфигурация, не статус) ---------
await setMotion('off', 'motion');
await setMotion('on', 'motion');
const d = c._devices.find((x) => x.entities.includes('binary_sensor.hall_motion'));
c._serverCfg.markers = c._serverCfg.markers || [];
c._serverCfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, hidden: true });
c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.hiddenNotInView = sr().querySelectorAll('.dev.senseflash, .dev.sensehold').length === 0;
c._showHidden = true; c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
const ghost = sr().querySelector('.dev.ghost');
out.ghostNoSense = !!ghost && !ghost.classList.contains('senseflash') && !ghost.classList.contains('sensehold')
&& getComputedStyle(ghost, '::after').animationName !== 'hp-sense';
return out;
});
checkAll(res, {});
await finish(browser, res);
+2 -3
View File
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
const H = 1000 / (c._curSpaceCfg.aspect || 1);
const H = 1000; // square canvas
c._openWallClick([550, 0.25 * H]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
@@ -53,8 +53,7 @@ const res = await page.evaluate(async () => {
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
const aspect = c._curSpaceCfg.aspect || 1;
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / 1000 } };
c.requestUpdate(); await c.updateComplete;
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
+1 -1
View File
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
const H = 1000 / (c._curSpaceCfg.aspect || 1);
const H = 1000; // square canvas
// 1) без наведения: курсор default, превью нет
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
+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.sentPlanAspect = sentF1?.plan_aspect; // the IMAGE's ratio; the canvas is square
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.atticPlanAspect = attic?.plan_aspect;
return out;
});
// зафиксировано прогоном на v1.44.8 и сверено с кодом
checkAll(res, {
reloadHappened: true,
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
sentPlanAspect: 1.6,
sentTitle: 'Ground',
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
dialogClosed: true,
atticSaved: true,
atticHasPlan: true,
atticPlanAspect: 0.8,
});
await finish(browser);
+71
View File
@@ -0,0 +1,71 @@
// Граница транзакции загрузки подложки (ревью R2-1, уточнено в R3-1).
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
// сохранение не имеет права трогать сохранённый план. Со стороны карточки
// контракт теперь такой: она НЕ управляет удалением файлов вообще — уборку
// делает сам config/set под блокировкой (клиент не может упорядочить свою
// уборку относительно чужого коммита, R3-1). Здесь проверяем, что карточка
// не отправляет никаких команд удаления и корректно ведёт себя при отказе.
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' };
}
// любая команда удаления файлов от клиента — нарушение контракта R3-1
if (m.type === 'houseplan/plan/cleanup' || m.type === 'houseplan/plan/delete') { cleanups.push(m); return { ok: true }; }
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;
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
out.savedPlanUrl = f1?.plan_url;
out.dialogClosedOnAccept = c._spaceDialog === null;
// вторая загрузка не переиспользует имя первой: старый файл жив до коммита
out.versionedNames = uploads === 2;
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом
checkAll(res, {
uploadedOnReject: true,
cleanupsAfterReject: 0,
dialogStaysOpenOnReject: true,
cleanupsAfterAccept: 0,
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
dialogClosedOnAccept: true,
versionedNames: true,
});
await finish(browser);
+144
View File
@@ -0,0 +1,144 @@
// HP-1552: residual jump on first open in NORMAL (non-kiosk) mode. The stage is
// calc(100dvh - _hdrH) and _hdrH is measured from HA's chrome, which loads
// AFTER the card's first paint — so the plan painted, then jumped when the
// panels landed. The fix hides the plan behind a boot veil (pulsing house)
// until the height reads the same twice in a row (or ~600 ms). This smoke
// simulates the late HA panel: a spacer above the card grows 0 → 56 px at
// t=300 ms. Assertions: the veil is shown, and there is NOT A SINGLE frame
// where the plan is visible at a non-final stage height. Kiosk: no veil ever.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- phase 1: normal mode, HA panel lands late ----------
const res = await page.evaluate(async () => {
const out = {};
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const frames = [];
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let grown = false;
await new Promise((done) => {
const sample = () => {
const t = performance.now() - t0;
if (!grown && t >= 300) {
grown = true; // the "HA panel" arrives: everything above the card grows
spacer.style.height = '56px';
window.dispatchEvent(new Event('resize'));
}
const stage = sr().querySelector('.stage');
if (stage) {
const veil = sr().querySelector('.bootveil');
const wrapEl = stage.querySelector(':scope > .zoomwrap');
const planHidden =
!wrapEl ||
getComputedStyle(wrapEl).visibility === 'hidden' ||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
frames.push({
t: Math.round(t),
planVisible: !planHidden,
veil: !!veil,
veilBg: veil ? getComputedStyle(veil).backgroundColor : null,
house: !!(veil && veil.querySelector('.boothouse path')),
stageH: stage.clientHeight,
});
} else {
frames.push(null);
}
if (t < 1400) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames.filter(Boolean);
const finalH = seen[seen.length - 1].stageH;
out.sampledFrames = seen.length > 40;
out.veilShownWhileSettling = seen.some((f) => f.veil && !f.planVisible && f.house);
out.veilIsDark = (() => {
const f = seen.find((x) => x.veilBg);
const m = f && f.veilBg.match(/(\d+),\s*(\d+),\s*(\d+)/);
return !!m && +m[1] + +m[2] + +m[3] < 300; // dark, not white/transparent
})();
// THE assertion: no frame may show the plan at a stale (pre-panel) height
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
out.noVisibleFrameAtStaleHeight = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms — the jump is visible`;
const shown = seen.filter((f) => f.planVisible);
out.planShownAfterSettle = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.veilGoneInTheEnd = !seen[seen.length - 1].veil;
out.heightReactedToPanel = finalH !== seen[0].stageH; // the simulated panel actually moved the stage
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---------- phase 2: kiosk never shows the veil ----------
const kiosk = await page.evaluate(async () => {
const out = {};
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = window.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:700px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
let veilEver = false;
let planHiddenEver = false;
const t0 = performance.now();
await new Promise((done) => {
const sample = () => {
const stage = sr().querySelector('.stage');
if (stage) {
if (sr().querySelector('.bootveil')) veilEver = true;
const wrapEl = stage.querySelector(':scope > .zoomwrap');
if (wrapEl && getComputedStyle(wrapEl).visibility === 'hidden') planHiddenEver = true;
}
if (performance.now() - t0 < 900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
out.kioskNoVeil = !veilEver;
out.kioskPlanNeverHidden = !planHiddenEver;
c.remove();
return out;
});
check('kioskNoVeil', kiosk.kioskNoVeil);
check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
await page.emulateMedia({ reducedMotion: 'reduce' });
const reduced = await page.evaluate(async () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let anim = 'missed';
await new Promise((done) => {
const tick = () => {
const house = sr().querySelector('.bootveil .boothouse');
if (house) { anim = getComputedStyle(house).animationName; done(); return; }
if (performance.now() - t0 < 500) requestAnimationFrame(tick);
else done();
};
requestAnimationFrame(tick);
});
c.remove();
return { anim };
});
check('reducedMotionStaticHouse', reduced.anim, 'none');
await finish(browser, { ...res, ...kiosk, ...reduced });
+180
View File
@@ -0,0 +1,180 @@
// AUD-1552-01 + AUD-1552-02: adversarial lifecycle regressions for the
// first-open boot veil (HP-1552). Both scenarios FAIL on v1.55.2.
//
// A. disconnect/reconnect while booting (Lovelace rebuilds its DOM, a view
// switch remounts the card). v1.55.2 cleared the boot timer on disconnect
// but kept its truthy id, so the reconnect never restarted the watcher and
// the plan stayed hidden FOREVER. Now the veil lifecycle restarts from
// connectedCallback: the veil must lift no later than the hard cap.
// B. layout shifts before, between and after the old poll ticks (150, 300,
// 450, 590 ms). v1.55.2 revealed the plan after two equal reads at
// 200/400 ms, so a panel landing at 450+ ms jumped on a VISIBLE plan. Now
// the veil holds for the full protective window with trailing quiescence:
// not a single frame may show the plan at a non-final stage height.
// C. a shift AFTER the reveal (slower than the cap) must glide, not snap:
// for a short grace the stage height transitions and the plan refits
// along with it (no transition at all on v1.55.2 — instant jump).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- A: detach before the first tick, reattach, veil must lift ----------
const rec = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.appendChild(c);
document.body.appendChild(wrap);
await sleep(50); // the first boot tick has not fired yet
out.bootingWhenDetached = c._booting === true;
c.remove(); // disconnect: timers die here
await sleep(30);
wrap.appendChild(c); // reconnect — the veil lifecycle must restart
const t0 = performance.now();
const sr = () => c.shadowRoot || c.renderRoot;
while (c._booting && performance.now() - t0 < 2000) await sleep(50);
out.revealMs = Math.round(performance.now() - t0);
out.bootSettledAfterReconnect = c._booting === false;
out.settledWithinCap = performance.now() - t0 < 1500; // hard cap 1200 + slack
await sleep(300); // let the fade finish
const stage = sr().querySelector('.stage');
const wrapEl = stage && stage.querySelector(':scope > .zoomwrap');
out.planVisibleAfterReconnect = !!wrapEl && getComputedStyle(wrapEl).visibility === 'visible';
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
out.veilGone = !sr().querySelector('.bootveil');
// A2: a second detach DURING the fade must not leave a zombie veil either
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = window.__card.hass;
wrap.appendChild(c2);
const t1 = performance.now();
while (c2._booting && performance.now() - t1 < 2000) await sleep(50);
// _bootFading is true for ~220 ms after the reveal — detach right inside it
c2.remove();
await sleep(30);
wrap.appendChild(c2);
await sleep(400);
const sr2 = c2.shadowRoot || c2.renderRoot;
out.fadeVeilGoneAfterReattach = !sr2.querySelector('.bootveil') && c2._bootFading === false;
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(rec)) check(k, v, k === 'revealMs' ? rec.revealMs : true);
// ---------- B: shifts at 150/300/450/590 ms — never a visible stale frame ----------
const shifts = [150, 300, 450, 590];
const outB = {};
for (const shiftMs of shifts) {
const res = await page.evaluate(async (shiftAt) => {
const out = {};
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const frames = [];
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let grown = false;
await new Promise((done) => {
const sample = () => {
const t = performance.now() - t0;
if (!grown && t >= shiftAt) {
grown = true; // the "HA panel" arrives
spacer.style.height = '56px';
window.dispatchEvent(new Event('resize'));
}
const stage = sr().querySelector('.stage');
if (stage) {
const veil = sr().querySelector('.bootveil');
const wrapEl = stage.querySelector(':scope > .zoomwrap');
const planHidden =
!wrapEl ||
getComputedStyle(wrapEl).visibility === 'hidden' ||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
frames.push({ t: Math.round(t), planVisible: !planHidden, stageH: stage.clientHeight });
}
if (t < 1900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames;
const finalH = seen[seen.length - 1].stageH;
// THE assertion: no frame may show the plan at a stale (pre-panel) height
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
out.noStaleFrame = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms`;
const shown = seen.filter((f) => f.planVisible);
out.planShown = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.heightReacted = finalH !== seen[0].stageH;
wrap.remove();
return out;
}, shiftMs);
outB[`shift${shiftMs}_noStaleFrame`] = res.noStaleFrame;
outB[`shift${shiftMs}_planShown`] = res.planShown;
outB[`shift${shiftMs}_heightReacted`] = res.heightReacted;
check(`shift${shiftMs}_noStaleFrame`, res.noStaleFrame);
check(`shift${shiftMs}_planShown`, res.planShown);
check(`shift${shiftMs}_heightReacted`, res.heightReacted);
}
// ---------- C: shift AFTER the reveal glides instead of snapping ----------
const late = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(25);
out.revealed = c._booting === false;
await sleep(120); // clearly after the reveal, still inside the soft grace
const stage = sr().querySelector('.stage');
const h0 = stage.clientHeight;
spacer.style.height = '56px'; // VERY late panel — slower than the cap
window.dispatchEvent(new Event('resize'));
const heights = [];
await new Promise((done) => {
const t1 = performance.now();
const sample = () => {
heights.push(stage.clientHeight);
if (performance.now() - t1 < 700) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const hEnd = heights[heights.length - 1];
out.heightMoved = Math.abs(hEnd - h0) > 30;
// the glide: intermediate frames strictly between the endpoints
const mid = heights.filter((h) => Math.abs(h - h0) > 4 && Math.abs(h - hEnd) > 4);
out.lateShiftGlides = mid.length >= 3
? true
: `only ${mid.length} intermediate frame(s): ${h0} -> ${hEnd} snapped`;
out.settledAtFinal = Math.abs(hEnd - (h0 - 56)) <= 2;
wrap.remove();
return out;
});
check('late_revealed', late.revealed);
check('late_heightMoved', late.heightMoved);
check('late_lateShiftGlides', late.lateShiftGlides);
check('late_settledAtFinal', late.settledAtFinal);
await finish(browser, { ...rec, ...outB, ...late });
+59
View File
@@ -0,0 +1,59 @@
// HP-1454-07: статическая карточка строит модель другой функцией, и room.settings
// в неё не переносились — переопределение заливки на уровне комнаты она
// игнорировала и красила комнату, которую полная карточка оставляет прозрачной.
// Плюс HP-1454-08: layout-события должны доходить до статической карточки без
// перезагрузки страницы.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
// заливка по свету на пространстве, у первой комнаты — переопределение "none"
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const f1 = cfg.spaces.find((s) => s.id === 'f1');
f1.settings = { ...(f1.settings || {}), show_borders: true, show_names: true, fill_mode: 'light' };
f1.rooms[0].settings = { fill_mode: 'none' };
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return { ok: true };
} };
main._serverCfg = cfg;
main._cfgEpoch++;
main.requestUpdate(); await main.updateComplete;
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const overridden = (root) => {
const rooms = [...root.querySelectorAll('.room')];
return rooms.length ? ((rooms[0].getAttribute('style') || '').match(/--room-fill:([^;]+)/) || [])[1] || null : 'missing';
};
out.fullCardRoom0 = overridden(main.shadowRoot || main.renderRoot);
out.staticCardRoom0 = overridden(card.renderRoot);
out.parity = out.fullCardRoom0 === out.staticCardRoom0;
out.overrideRespected = out.staticCardRoom0 === 'transparent';
return out;
});
// зафиксировано прогоном на v1.46.0 и сверено с кодом
checkAll(res, {
fullCardRoom0: 'transparent',
staticCardRoom0: 'transparent',
parity: true,
overrideRespected: true,
});
await finish(browser);
+210
View File
@@ -0,0 +1,210 @@
// The v1.55.0 resize audit (HP-1550-01/-03/-04), numeric regressions:
// 01 — a debounced config write pending from a PREVIOUS edit must never carry
// the live resize preview to the server; Esc leaves server === snapshot;
// a normal pointerup produces exactly one write;
// 03 — pointercancel aborts the drag (no commit, no undo step, no write),
// for wall handles and corner handles alike; lostpointercapture after
// a cancel must not double-fire;
// 04 — a door at the exact midpoint of a wall must not shadow the resize
// handle: the handle wins the hit test, the wall drags WITH the door,
// and no opening-edit drag starts in the resize tool.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => {
const c = window.__card;
c._serverCfg = JSON.parse(s);
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = [];
if ('_rszPreview' in c) c._rszPreview = null;
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
// geometry the SERVER would get (the mutable config _writeConfig reads)
const roomSrv = (id) => page.evaluate((id) => {
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
// geometry AS RENDERED (includes the live preview overlay when a drag is on)
const roomLive = (id) => page.evaluate((id) => {
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
// spy on every config write with a deep copy taken AT CALL TIME — exactly what
// the backend would persist (a later fix of the mutable object must not hide it)
await page.evaluate(() => {
const c = window.__card;
window.__writes = [];
const orig = c.hass.callWS.bind(c.hass);
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/set') window.__writes.push(JSON.parse(JSON.stringify(m.config)));
return orig(m);
} };
return c.updateComplete && true;
});
const writes = () => page.evaluate(() => window.__writes.length);
const writeWallAt = (i, x) => page.evaluate(([i, x]) => {
const w = window.__writes[i];
const r1 = w.spaces.find((s) => s.id === 'f1').rooms.find((r) => r.id === 'r1');
return r1.poly.some((p) => Math.abs(p[0] - x) < 0.011);
}, [i, x]);
// ================= HP-1550-01: pending write vs live preview =================
await enter('resize');
await settle();
// precompute every screen point so drag 2 starts well inside the 500 ms window
const [h550x, h550y] = await screenPt(550, 360);
const [h600x] = await screenPt(600, 360);
const [h650x] = await screenPt(650, 360);
const [h700x] = await screenPt(700, 360);
// drag 1 commits r1's right wall 0.55 → 0.60: a write enters the debounce queue
await page.mouse.move(h550x, h550y);
await page.mouse.down();
await page.mouse.move(h600x, h550y, { steps: 3 });
await page.mouse.up();
// drag 2 starts IMMEDIATELY (pending save still queued): preview 0.60 → 0.70, HOLD
await page.mouse.move(h600x, h550y);
await page.mouse.down();
await page.mouse.move(h700x, h550y, { steps: 3 });
await settle();
check('01_committed_wall', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 0.011);
check('01_preview_live', Math.abs((await roomLive('r1'))[1][0] - 0.70) < 0.011);
// … the queued write fires while the handle is still held (>500 ms)
await page.waitForTimeout(800);
check('01_pending_write_fired', (await writes()) >= 1);
// Esc abandons the drag
await page.keyboard.press('Escape');
await settle();
await page.mouse.up();
await page.waitForTimeout(800); // any stray debounced write would have fired
const nw = await writes();
for (let i = 0; i < nw; i++) {
check(`01_write_${i}_no_preview_coords`, await writeWallAt(i, 0.70), false);
check(`01_write_${i}_has_committed`, await writeWallAt(i, 0.60), true);
}
check('01_server_geom_is_snapshot', Math.abs((await roomSrv('r1'))[1][0] - 0.60) < 1e-6);
check('01_local_geom_is_snapshot', Math.abs((await roomLive('r1'))[1][0] - 0.60) < 1e-6);
// a normal pointerup still produces EXACTLY ONE write
{
const before = await writes();
await page.mouse.move(h600x, h550y);
await page.mouse.down();
await page.mouse.move(h650x, h550y, { steps: 3 });
await settle();
await page.mouse.up();
await page.waitForTimeout(800);
check('01_single_write_per_commit', (await writes()) - before, 1);
check('01_commit_reached_server', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
}
await restore();
// ================= HP-1550-03: pointercancel = abort, not commit =============
await enter('resize');
await settle();
{
const w0 = await writes();
const [ax, ay] = await screenPt(550, 360);
const [bx] = await screenPt(650, 360);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, ay, { steps: 3 });
await settle();
check('03_preview_moved', Math.abs((await roomLive('r1'))[1][0] - 0.65) < 0.011);
// the system interrupts the stream (app switch, palm rejection)
await page.evaluate(() => {
const c = window.__card;
const pid = c._rszDrag.pid;
const el = c.renderRoot.querySelector('.rszhandle');
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
// the lostpointercapture that follows a cancel must not double-fire
el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: pid, bubbles: true }));
});
await settle();
check('03_snapshot_back_live', Math.abs((await roomLive('r1'))[1][0] - 0.55) < 1e-6);
check('03_server_untouched', Math.abs((await roomSrv('r1'))[1][0] - 0.55) < 1e-6);
check('03_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
check('03_drag_cleared', await page.evaluate(() => window.__card._rszDrag === null));
await page.waitForTimeout(800);
check('03_no_pending_write', (await writes()) - w0, 0);
await page.mouse.up();
}
await restore();
// the corner (scale) handle takes the same abort path
await enter('resize');
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
await settle();
{
const w0 = await writes();
const [ax, ay] = await screenPt(550, 860);
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8 about (40,580)
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 3 });
await settle();
check('03_corner_preview', Math.abs((await roomLive('r4'))[2][0] - 0.55) > 0.02);
await page.evaluate(() => {
const c = window.__card;
const pid = c._rszDrag.pid;
const el = c.renderRoot.querySelector('.rszcorner') || c.renderRoot.querySelector('.rszhandle');
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: pid, bubbles: true }));
});
await settle();
check('03_corner_snapshot_back', Math.abs((await roomLive('r4'))[2][0] - 0.55) < 1e-6);
check('03_corner_undo_empty', await page.evaluate(() => window.__card._rszUndo.length), 0);
await page.waitForTimeout(800);
check('03_corner_no_write', (await writes()) - w0, 0);
await page.mouse.up();
}
await restore();
// ============ HP-1550-04: a door mid-wall must not shadow the handle =========
await enter('resize');
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// exactly at the midpoint of r1's right wall (y 0.14..0.58 → 0.36)
sp.openings = [{ id: 'opm', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
{
const [hx, hy] = await screenPt(550, 360);
const [tx] = await screenPt(650, 360);
// the REAL hit test: the topmost interactive element must be the handle
const top = await page.evaluate(([x, y]) => {
const el = window.__card.shadowRoot.elementFromPoint(x, y);
return el ? el.getAttribute('class') || '' : '';
}, [hx, hy]);
check('04_handle_wins_hit_test', String(top).includes('rszhandle'));
await page.mouse.move(hx, hy);
await page.mouse.down();
await page.mouse.move(tx, hy, { steps: 4 });
await settle();
check('04_no_opening_drag', await page.evaluate(() => !window.__card._opDrag));
check('04_wall_drag_started', await page.evaluate(() => !!window.__card._rszDrag));
await page.mouse.up();
await settle();
check('04_wall_moved', Math.abs((await roomSrv('r1'))[1][0] - 0.65) < 0.011);
const op = await page.evaluate(() => window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0]);
check('04_door_travelled_with_wall', Math.abs(op.x - 0.65) < 0.011);
}
await restore();
await finish(browser, { done: true });
+14 -3
View File
@@ -8,9 +8,20 @@ const res = await page.evaluate(async () => {
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
await c.updateComplete;
const rgbDev = sr().querySelector('.dev.rgb');
out.rgbClass = !!rgbDev;
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
// v1.52.0 (правило владельца): RGB больше НЕ красит значок — цвет лампы
// живёт только в glow-пятне и в фолбэке цвета пульсации
out.rgbClassGone = !sr().querySelector('.dev.rgb');
out.litLampIsYellow = [...sr().querySelectorAll('.dev.on')].length > 0;
// фолбэк пульсации сохраняет цвет свечения: маркер icon_ripple на лампе
const lampDev = c._devices.find((x) => x.entities.includes('light.ceiling'));
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.id !== lampDev.id);
c._serverCfg.markers.push({ id: lampDev.id, binding: 'device:' + lampDev.bindingRef, display: 'icon_ripple' });
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const rippleDev = [...sr().querySelectorAll('.dev')].find((e) => (e.getAttribute('style') || '').includes('--ripple-color'));
out.rippleKeepsLightColor = !!rippleDev && rippleDev.getAttribute('style').includes('rgb(255, 0, 128)');
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== lampDev.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// тревога: датчик протечки on
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
+8 -2
View File
@@ -36,8 +36,14 @@ const res = await page.evaluate(async () => {
c._setMode('plan'); await c.updateComplete;
const planLbl = sr().querySelector('.roomlabel');
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
// метрики видны и в редакторе плана (решение владельца, 2026-07-29)
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length > 0;
// кнопка настроек — отдельно от подписи, в центре комнаты, с размером от иконки
out.gearDetached = (() => {
const g = sr().querySelector('.rlgearbtn');
if (!g || g.closest('.roomlabel')) return false;
return getComputedStyle(g).height !== 'auto';
})();
// масштаб: сымитируем resize через прямой вызов
const room = c._spaceModel().rooms.find((r) => r.name);
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
+249
View File
@@ -0,0 +1,249 @@
// Room resize tool (docs/RESIZE.md): wall drag with shared walls (T-junction),
// live badges, stops (neighbour min size, opening anchor), the corner scale
// frame, Esc-cancel and one-step undo. Vertex positions are checked NUMERICALLY.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => {
const c = window.__card;
c._serverCfg = JSON.parse(s);
c._rszSel = null; c._rszDrag = null; c._rszLive = null; c._rszUndo = []; c._rszPreview = null;
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
const roomPolyN = (id) => page.evaluate((id) => {
const r = window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
// mid-drag geometry AS RENDERED: HP-1550-01 moved the live preview out of
// _serverCfg into the _rszPreview overlay (_curSpaceCfg serves it to renders)
const roomPolyLive = (id) => page.evaluate((id) => {
const r = window.__card._curSpaceCfg.rooms.find((x) => x.id === id);
return r?.poly ? r.poly.map((p) => [p[0], p[1]]) : null;
}, id);
const openingOf = (id) => page.evaluate((id) =>
(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings || []).find((o) => o.id === id) || null, id);
// screen position of a render-unit point (the svg viewBox fills the stage exactly)
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
const labels = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel')].map((el) => el.textContent.trim()));
const polyClose = (name, got, want, tol = 1e-4) => {
check(name + '.len', got?.length, want.length);
if (got?.length !== want.length) return;
for (let i = 0; i < want.length; i++) {
check(`${name}[${i}]`, Math.hypot(got[i][0] - want[i][0], got[i][1] - want[i][1]) <= tol, true);
}
};
// ---------- no handles outside the resize tool ----------
await enter('draw');
check('draw_tool_no_handles', await handleCount(), 0);
await enter('opening');
check('opening_tool_no_handles', await handleCount(), 0);
// ---------- handles appear in the resize tool ----------
await enter('resize');
await settle();
check('resize_handles_16', await handleCount(), 16); // 4 rooms × 4 walls
// ---------- wall drag: T-stack r1|r2+r3, opening rides along, live badges ----------
// opening op2 sits ON the shared wall x=0.55 → it must travel with it
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [{ id: 'op2', type: 'door', x: 0.55, y: 0.30, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
});
await settle();
const [hx, hy] = await screenPt(550, 360); // r1 right-wall handle (mid y of 140..580)
const [tx] = await screenPt(650, 360); // +100 render units
await page.mouse.move(hx, hy);
await page.mouse.down();
await page.mouse.move((hx + tx) / 2, hy, { steps: 4 });
await settle();
const mid = await labels();
check('drag_badges_visible', mid.length >= 6); // 3 wall lengths + 3 areas (r1, r2, r3)
check('drag_badges_have_area', mid.filter((t) => t.includes('m²')).length >= 3);
check('drag_badges_have_len', mid.some((t) => /\d\.\d\d m$/.test(t)));
const midR1 = await roomPolyLive('r1');
check('preview_moves_r1', Math.abs(midR1[1][0] - 0.6) < 0.011); // halfway ≈ 0.60 (snapped)
// HP-1550-01: the preview must NOT leak into the config a queued write reads
const midSrv = await roomPolyN('r1');
check('preview_not_in_servercfg', Math.abs(midSrv[1][0] - 0.55) < 1e-9);
await page.mouse.move(tx, hy, { steps: 4 });
await settle();
const end = await labels();
check('drag_badges_change', JSON.stringify(mid) !== JSON.stringify(end));
check('area_updates_live', mid.find((t) => t.includes('m²')) !== end.find((t) => t.includes('m²')));
await page.mouse.up();
await settle();
// committed geometry: r1 grew, r2 translated, r3 became L-shaped with 6 vertices
polyClose('r1_after', await roomPolyN('r1'), [[0.04, 0.14], [0.65, 0.14], [0.65, 0.58], [0.04, 0.58]]);
polyClose('r2_after', await roomPolyN('r2'), [[0.65, 0.14], [0.96, 0.14], [0.96, 0.46], [0.65, 0.46]]);
polyClose('r3_after', await roomPolyN('r3'),
[[0.65, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86], [0.55, 0.58], [0.65, 0.58]]);
const op2 = await openingOf('op2');
check('opening_travels', Math.abs(op2.x - 0.65) < 1e-4 && Math.abs(op2.y - 0.30) < 1e-6);
// ---------- undo: one release = one step back ----------
await page.keyboard.press('Control+z');
await settle();
polyClose('undo_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
polyClose('undo_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
polyClose('undo_r3', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
const op2b = await openingOf('op2');
check('undo_opening_back', Math.abs(op2b.x - 0.55) < 1e-9);
await restore();
// ---------- stop: the shrinking neighbour keeps ~30 cm ----------
await enter('resize');
await settle();
{
const [ax, ay] = await screenPt(550, 360);
const [bx] = await screenPt(1050, 360); // far past r2/r3 (right walls at 0.96)
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, ay, { steps: 6 });
await settle();
await page.mouse.up();
await settle();
const r1 = await roomPolyN('r1');
const r2 = await roomPolyN('r2');
const width2 = (r2[1][0] - r2[0][0]) * 1000;
check('neighbour_min_stop', Math.abs(r1[1][0] - 0.93333) < 0.005); // clamped, NOT 1.05
check('neighbour_min_width', width2 >= 25 - 1e-6 && width2 <= 25 + 1000 / 240 + 1e-6);
}
await restore();
// ---------- stop: an opening anchors the shortening wall ----------
await enter('resize');
await settle();
{
// a door on r1's LEFT wall (x=0.04), spans y 0.16..0.24. Dragging r1's TOP
// wall down shortens that wall from above — it must stop at the door edge.
// (No collinear neighbour wall can adopt this door — a real «упор».)
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [{ id: 'op1', type: 'door', x: 0.04, y: 0.20, angle: 90, length: 0.08 }];
c._cfgEpoch++; c.requestUpdate();
});
await settle();
const [ax, ay] = await screenPt(295, 140); // r1 top-wall handle
const [, by] = await screenPt(295, 300); // try to push far past the door top (y=160)
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(ax, by, { steps: 5 });
await settle();
await page.mouse.up();
await settle();
const r1 = await roomPolyN('r1');
const topY = r1[0][1] * 1000;
check('opening_stop', topY <= 160 + 1e-6); // never below the door top
check('opening_stop_close', topY >= 160 - 1000 / 240 - 1e-6); // and clamped right at it
const op1 = await openingOf('op1');
check('anchored_opening_static', Math.abs(op1.y - 0.2) < 1e-9 && Math.abs(op1.x - 0.04) < 1e-9);
}
await restore();
// ---------- the scale frame: proportional, neighbours stay, stops work ----------
await enter('resize');
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
await settle();
check('frame_corners', await page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszcorner').length), 4);
check('frame_present', await page.evaluate(() => !!window.__card.renderRoot.querySelector('.rszframe')));
{
// shrink about the top-left corner (40,580): drag br (550,860) toward it
const [ax, ay] = await screenPt(550, 860);
const [bx, by] = await screenPt(448, 804); // ≈ k=0.8
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 5 });
await settle();
await page.mouse.up();
await settle();
const r4 = await roomPolyN('r4');
const base = [[0.04, 0.58], [0.55, 0.58], [0.55, 0.86], [0.04, 0.86]];
// ALL vertices scale by the SAME factor about the fixed corner (0.04, 0.58)
const ks = base.map((b, i) => {
const dx = b[0] - 0.04, dy = b[1] - 0.58;
if (Math.abs(dx) > 1e-9) return (r4[i][0] - 0.04) / dx;
if (Math.abs(dy) > 1e-9) return (r4[i][1] - 0.58) / dy;
return null;
}).filter((k) => k != null);
const kAvg = ks.reduce((a, b) => a + b, 0) / ks.length;
check('scale_shrinks', kAvg > 0.7 && kAvg < 0.9);
check('scale_proportional', ks.every((k) => Math.abs(k - kAvg) < 1e-3));
const ky = base.map((b, i) => (Math.abs(b[1] - 0.58) > 1e-9 ? (r4[i][1] - 0.58) / (b[1] - 0.58) : null)).filter((k) => k != null);
check('scale_uniform_xy', ky.every((k) => Math.abs(k - kAvg) < 1e-3));
// neighbours did NOT move (scale never drags them — docs/RESIZE.md exception)
polyClose('scale_r1_static', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
polyClose('scale_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
}
await restore();
await enter('resize');
await page.evaluate(() => { const c = window.__card; c._rszSel = 'r4'; c.requestUpdate(); return c.updateComplete && true; });
await settle();
{
// growing about the top-left corner immediately runs into r3 → the scale stops at ≈1
const [ax, ay] = await screenPt(550, 860);
const [bx, by] = await screenPt(805, 1000); // ≈ k=1.5
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 5 });
await settle();
await page.mouse.up();
await settle();
const r4 = await roomPolyN('r4');
check('scale_neighbour_stop', Math.abs(r4[1][0] - 0.55) < 0.005 && Math.abs(r4[2][1] - 0.86) < 0.005);
polyClose('scale_stop_r3_static', await roomPolyN('r3'), [[0.55, 0.46], [0.96, 0.46], [0.96, 0.86], [0.55, 0.86]]);
}
await restore();
// ---------- Esc cancels the current drag ----------
await enter('resize');
await settle();
{
const [ax, ay] = await screenPt(550, 360);
const [bx] = await screenPt(700, 360);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, ay, { steps: 4 });
await settle();
const midPoly = await roomPolyLive('r1');
check('esc_preview_moved', Math.abs(midPoly[1][0] - 0.70) < 0.011);
await page.keyboard.press('Escape');
await settle();
polyClose('esc_restores_r1', await roomPolyN('r1'), [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]]);
polyClose('esc_restores_r2', await roomPolyN('r2'), [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]]);
check('esc_no_badges', (await labels()).length, 0);
await page.mouse.up();
}
await restore();
// ---------- leaving the tool removes every handle ----------
await enter('merge');
check('merge_tool_no_handles', await handleCount(), 0);
await page.evaluate(() => { window.__card._setMode('devices'); });
await settle();
check('devices_mode_no_handles', await handleCount(), 0);
await finish(browser, { done: true });
+1 -1
View File
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// 1) шестерёнка на карточке комнаты в редакторе плана
const gear = sr().querySelector('.rlgear');
const gear = sr().querySelector('.rlgearbtn');
out.gearShown = !!gear;
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
+93
View File
@@ -0,0 +1,93 @@
// «Уже загруженные»: план, который не удаляется за ненадобностью, обязан быть
// находимым. Иначе обещание «отцепил — файл остался» неполноценно: вернуть его
// из карточки было нельзя, старый URL нигде не хранится (HP-1466-02).
// Заодно это единственный способ удалить план — явным действием.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const base = c.hass.callWS;
let serverPlans = [
{ name: 'f1.aaa.png', url: '/api/houseplan/content/plans/_/f1.aaa.png', size: 121335, modified: 2, used_by: [] },
{ name: 'f2.bbb.png', url: '/api/houseplan/content/plans/_/f2.bbb.png', size: 26931, modified: 1, used_by: ['2 этаж'] },
];
const deleted = [];
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
if (m.type === 'houseplan/plans/delete') {
const p = serverPlans.find((x) => x.name === m.name);
if (p?.used_by.length) { const e = new Error('in_use'); e.code = 'in_use'; throw e; }
deleted.push(m.name);
serverPlans = serverPlans.filter((x) => x.name !== m.name);
return { ok: true, removed: true };
}
if (m.type === 'houseplan/content/sign') {
const urls = {}; for (const p of m.paths) urls[p] = p + '?authSig=X'; return { urls };
}
return base(m);
} };
window.confirm = () => true;
// пространство без плана — как после отцепления
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
await c.updateComplete;
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
// открываем список сохранённых
await c._toggleServerPlans();
await new Promise((r) => setTimeout(r, 60));
await c.updateComplete;
const rows = [...sr().querySelectorAll('.savedplan')];
out.listed = rows.length;
out.showsUsage = (rows[1]?.textContent || '').includes('2 этаж');
out.deleteDisabledForUsed = !!rows[1]?.querySelector('.btn.danger[disabled]');
out.deleteEnabledForFree = !rows[0]?.querySelector('.btn.danger[disabled]');
out.thumbnailSigned = (rows[0]?.querySelector('img')?.getAttribute('src') || '').includes('authSig=');
// выбираем свободный план — он подставляется в диалог
c._useServerPlan(serverPlans[0].url);
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
out.listClosed = !c._spaceDialog.pickSaved;
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
// на несуществующий файл (HP-1470-02)
await c._toggleServerPlans();
await new Promise((r) => setTimeout(r, 60));
await c.updateComplete;
const rows2 = [...sr().querySelectorAll('.savedplan')];
const picked = rows2.find((r) => r.textContent.includes('f1.aaa.png'));
out.deleteDisabledForPicked = !!picked?.querySelector('.btn.danger[disabled]');
c._spaceDialog = { ...c._spaceDialog, planUrl: null };
await c.updateComplete;
await c._deleteServerPlan('f2.bbb.png').catch(() => {});
out.usedNotDeleted = !deleted.includes('f2.bbb.png');
await c._deleteServerPlan('f1.aaa.png');
await c.updateComplete;
out.freeDeleted = deleted.includes('f1.aaa.png');
out.rowGone = !(c._spaceDialog.saved || []).some((p) => p.name === 'f1.aaa.png');
return out;
});
// зафиксировано прогоном на v1.47.0 и сверено с кодом
checkAll(res, {
saveBlockedWithoutPlan: true,
listed: 2,
showsUsage: true,
deleteDisabledForUsed: true,
deleteEnabledForFree: true,
thumbnailSigned: true,
picked: true,
listClosed: true,
saveEnabledAfterPick: true,
deleteDisabledForPicked: true,
usedNotDeleted: true,
freeDeleted: true,
rowGone: true,
});
await finish(browser);
+78
View File
@@ -0,0 +1,78 @@
// Ревью 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._signer.entries).length;
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
batchSizes.length = 0;
round = 2;
c._resign();
await new Promise((r) => setTimeout(r, 120));
out.resignBatches = [...batchSizes];
const vals = Object.values(c._signer.entries).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._signer.entries).length;
out.pruneBatches = [...batchSizes];
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
const one = pdfs[0].url;
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
out.expiredNotServed = c._display(one) === '';
out.expiredDropped = c._signer.entries[one] === undefined;
// а стареющая, но ещё живая — отдаётся, пока едет замена
c._signer.entries[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,
expiredDropped: true,
agingStillServed: true,
});
await finish(browser);
+61
View File
@@ -0,0 +1,61 @@
// HP-1513-01: один и тот же маркер (size 3, angle 37) обязан выглядеть
// одинаково на полной и статичной карточках — статичная игнорировала и
// множитель размера, и поворот.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
await customElements.whenDefined('houseplan-space-card');
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.markers = (cfg.markers || []).filter((m) => m.id !== d.id);
cfg.markers.push({ id: d.id, binding: 'device:' + d.bindingRef, size: 3, angle: 37 });
c._serverCfg = cfg; c._cfgEpoch++; c._regSignature = '';
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 100));
const pick = (root) => [...root.querySelectorAll('.dev')]
.find((e) => (e.getAttribute('style') || '').includes('--dev-scale:3'));
const full = pick(c.shadowRoot || c.renderRoot);
const fW = full?.getBoundingClientRect().width || 0;
const fT = full ? getComputedStyle(full.querySelector('ha-icon')).transform : 'none';
window.__hpInvalidate?.();
const hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 83 };
if (m.type === 'houseplan/layout/get') return { layout: c._layout, rev: 83 };
return { ok: true };
}, connection: { subscribeEvents: async (cb) => { window.__hpInvalidate = cb; return () => {}; } } };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!pick(card.renderRoot || { querySelectorAll: () => [] }) && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
const st = pick(card.renderRoot);
const sW = st?.getBoundingClientRect().width || 0;
const sT = st ? getComputedStyle(st.querySelector('ha-icon')).transform : 'none';
o.staticCarriesScale = sW > 0;
// абсолютные px несравнимы (разные контейнеры/letterbox) — контракт в том,
// что множитель РАБОТАЕТ на обеих карточках: базовый значок той же карточки
// втрое меньше масштабированного
const stBase = [...card.renderRoot.querySelectorAll('.dev')]
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale'));
const sBaseW = stBase?.getBoundingClientRect().width || 0;
o.staticRatioIs3 = sBaseW > 0 && Math.abs(sW / sBaseW - 3) < 0.35;
const fBase = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.dev')]
.find((e) => !(e.getAttribute('style') || '').includes('--dev-scale') && !e.classList.contains('ghost'));
const fBaseW = fBase?.getBoundingClientRect().width || 0;
o.fullRatioIs3 = fBaseW > 0 && Math.abs(fW / fBaseW - 3) < 0.35;
o.bothRotated = fT !== 'none' && sT !== 'none' && fT.startsWith('matrix') && sT.startsWith('matrix');
if (!o.staticRatioIs3) console.log('static', sW, sBaseW);
host.remove();
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== d.id);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
+99
View File
@@ -0,0 +1,99 @@
// Ревью R3-2: houseplan-space-card подписывала URL подложки и выбрасывала
// результат — getCardSize() правил временную модель, а render() строил свою
// заново из конфига, поэтому <image> запрашивал сырой requires_auth-путь и на
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
// подложка на защищённом эндпоинте + управляемый ответ на подпись
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
let signCalls = 0;
let failFirst = true;
const requestedHrefs = [];
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
if (m.type === 'houseplan/content/sign') {
signCalls++;
if (failFirst && signCalls === 1) throw new Error('ws down');
const urls = {};
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
return { urls };
}
return { ok: true };
} };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const stage = async () => {
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
return card.renderRoot.querySelector('.hp-static-stage svg image');
};
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
await stage();
await new Promise((r) => setTimeout(r, 120));
out.hrefAfterFailedSign = await href();
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
// иначе нестабильный сокет получал бы по запросу на каждый рендер
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
await new Promise((r) => setTimeout(r, 150));
out.noRetryStorm = signCalls === 1;
// 3) после выдержки повтор проходит
await new Promise((r) => setTimeout(r, 2100));
card.requestUpdate(); await card.updateComplete;
await new Promise((r) => setTimeout(r, 150));
out.hrefAfterRetry = await href();
out.retried = signCalls === 2;
// 4) повторный рендер не теряет подпись и не просит её заново
const before = signCalls;
card.requestUpdate(); await card.updateComplete;
out.hrefStable = await href();
out.noExtraSignOnRerender = signCalls === before;
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
const ent = card._signer.entries;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenExpired = await href();
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenAging = await href();
// ни один сырой (неподписанный) путь не должен уходить в сеть
for (const im of card.renderRoot.querySelectorAll('image')) requestedHrefs.push(im.getAttribute('href'));
out.noRawHrefEver = !requestedHrefs.includes(raw);
return out;
});
// зафиксировано прогоном на v1.45.1 и сверено с кодом
checkAll(res, {
hrefAfterFailedSign: null,
noRetryStorm: true,
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
retried: true,
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
noExtraSignOnRerender: true,
hrefWhenExpired: null,
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
noRawHrefEver: true,
});
await finish(browser);
+5 -3
View File
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticAspect = attic?.aspect;
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
out.atticSettings = attic?.settings;
out.atticNoPlan = attic ? attic.plan_url === null : null;
return out;
@@ -55,7 +55,9 @@ checkAll(res, {
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"lqiFills": 0,
"atticAspect": 1,
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
"atticSquare": true,
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
// Existing spaces with an absent fill_mode still resolve to 'none'.
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "glow", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+3 -3
View File
@@ -2,13 +2,13 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
return [nx * 1000, ny * 1000]; // square canvas (v1.48.0)
}, [nx, ny]);
const out = {};
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
// living room (r1) has walls at y=0.14 which are NOT grid nodes; click near the wall
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.142)); // near top wall (off grid)
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
+78
View File
@@ -0,0 +1,78 @@
// HP-1454-01: загруженный SVG — пользовательский контент, который Home Assistant
// отдаёт со своего origin. Внутри карточки он подключён через <image>, где
// скрипты не выполняются, но тот же URL, открытый как отдельный документ,
// становится живым документом этого origin: <script> в нём получает доступ к
// localStorage сессии и к API. Проверяем, что заголовок sandbox это снимает,
// и что обычный SVG при этом продолжает отображаться.
import { chromium } from 'playwright';
import { check, finish } from './serve.mjs';
const EVIL = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<rect width="100" height="100" fill="#eee"/>
<script>
document.title = 'HOUSEPLAN_XSS_EXECUTED';
try { localStorage.setItem('hp_xss', 'executed'); } catch (e) {}
</script>
</svg>`;
// ровно тот набор, который отдаёт HouseplanContentView для .svg
const CSP = "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
+ "base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:";
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const ctx = await browser.newContext();
async function serve(page, { csp }) {
await page.route('**/*', (route) => {
const url = route.request().url();
if (url.endsWith('/evil.svg')) {
const headers = { 'Content-Type': 'image/svg+xml', 'X-Content-Type-Options': 'nosniff' };
if (csp) headers['Content-Security-Policy'] = CSP;
return route.fulfill({ status: 200, headers, body: EVIL });
}
return route.fulfill({ status: 200, contentType: 'text/html', body: '<html><body>host</body></html>' });
});
}
// 1) как было до фикса: скрипт исполняется в origin Home Assistant
const before = await ctx.newPage();
await serve(before, { csp: false });
await before.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
await before.waitForTimeout(200);
const noCsp = await before.evaluate(() => ({
title: document.title,
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
}));
// 2) с заголовком: opaque origin, скрипт не выполняется, storage недоступен
const after = await ctx.newPage();
await serve(after, { csp: true });
await after.goto('https://ha.example/api/houseplan/content/plans/_/evil.svg');
await after.waitForTimeout(200);
const withCsp = await after.evaluate(() => ({
title: document.title,
storage: (() => { try { return localStorage.getItem('hp_xss'); } catch (e) { return 'blocked'; } })(),
}));
// 3) тот же файл как <image> внутри страницы — рисуется и без скрипта
const card = await ctx.newPage();
await serve(card, { csp: true });
await card.goto('https://ha.example/');
const drawn = await card.evaluate(async () => {
const img = new Image();
const ok = await new Promise((res) => {
img.onload = () => res(true);
img.onerror = () => res(false);
img.src = '/api/houseplan/content/plans/_/evil.svg';
});
return { loaded: ok, width: img.naturalWidth, title: document.title };
});
check('без CSP скрипт выполняется (иначе тест ничего не доказывает)', noCsp.title, 'HOUSEPLAN_XSS_EXECUTED');
check('без CSP скрипт пишет в storage origin', noCsp.storage, 'executed');
check('с CSP скрипт не выполняется', withCsp.title !== 'HOUSEPLAN_XSS_EXECUTED', true);
check('с CSP storage origin недоступен', withCsp.storage !== 'executed', true);
check('SVG по-прежнему грузится как картинка', drawn.loaded, true);
check('и имеет размеры', drawn.width, 100);
check('картинка ничего не выполнила на странице-хосте', drawn.title, '');
await finish(browser);
+1 -1
View File
@@ -12,7 +12,7 @@ const res = await page.evaluate(async () => {
c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
out.threeOptions = sel && sel.options.length === 3;
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
out.defaultInfo = sel && sel.value === 'info';
c._markerDialog = null; await c.updateComplete;
+84
View File
@@ -0,0 +1,84 @@
// ТЗ 2026-07-29: действие по нажатию «Запустить автоматизацию/скрипт/сцену»
// с пикером и поиском + чекбокс «Спрашивать подтверждение» для toggle/run.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass,
states: { ...c.hass.states,
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
'scene.movie': { state: '', attributes: { friendly_name: 'Кино' } } },
callService: async (dom, svc, data) => { calls.push([dom, svc, data]); return {}; } };
// --- диалог: выбор run + пикер с поиском --------------------------------
const d = c._devices.find((x) => x.space === 'f1' && x.bindingKind === 'device');
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(d); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, tapAction: 'run' }; await c.updateComplete;
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
// пользователя 2026-07-30, поймано только замером высоты)
const listEl = () => sr().querySelector('.dialog .candlist');
o.pickerShown = !!listEl() && listEl().getBoundingClientRect().height > 30;
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
// и найденная строка реально нарисована, а не сплющена
const row = sr().querySelector('.dialog .cand');
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
// сохранение без цели блокируется
await c._saveMarker(); await c.updateComplete;
o.saveBlockedWithoutTarget = !!c._markerDialog;
// выбираем цель + подтверждение, сохраняем
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
await c.updateComplete;
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
await c._saveMarker(); await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
// --- тап: подтверждение → отмена → вызова нет ---------------------------
c._setMode('view'); c._regSignature = ''; c._maybeRebuildDevices();
c.requestUpdate(); await c.updateComplete;
const dev = c._devices.find((x) => x.id === (saved?.id ?? d.id)) || c._devices.find((x) => x.bindingRef === d.bindingRef);
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
o.confirmDialogShown = !!c._tapConfirm && c._tapConfirm.text.includes('Шторы');
c._tapConfirm = null; await c.updateComplete;
o.cancelNoCall = calls.length === 0;
// --- тап: подтвердили → script.turn_on ----------------------------------
c._clickDevice({ stopPropagation() {} }, dev); await c.updateComplete;
const conf = c._tapConfirm; c._tapConfirm = null; conf.exec();
await new Promise((r) => setTimeout(r, 10));
o.runCalled = calls.length === 1 && calls[0][0] === 'script' && calls[0][1] === 'turn_on'
&& calls[0][2].entity_id === 'script.curtains';
// --- без подтверждения: сразу вызов; автоматизация → trigger ------------
c._serverCfg.markers = c._serverCfg.markers.map((m) =>
m.id === (saved?.id ?? d.id) ? { ...m, tap_target: 'automation.evening', tap_confirm: null } : m);
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.bindingRef === d.bindingRef);
calls.length = 0;
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
await new Promise((r) => setTimeout(r, 10));
o.automationTriggered = calls.length === 1 && calls[0][0] === 'automation' && calls[0][1] === 'trigger';
o.noConfirmWhenOff = !c._tapConfirm;
// --- цель удалена → тост, вызова нет ------------------------------------
const states2 = { ...c.hass.states }; delete states2['automation.evening'];
c.hass = { ...c.hass, states: states2 };
calls.length = 0;
c._clickDevice({ stopPropagation() {} }, dev2); await c.updateComplete;
o.missingTargetSafe = calls.length === 0 && !!c._toast;
// очистка
c._serverCfg.markers = c._serverCfg.markers.filter((m) => m.id !== (saved?.id ?? d.id));
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
return o;
});
await finish(browser, checkAll(out));
+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,
+231
View File
@@ -0,0 +1,231 @@
// Live vacuum P1 (docs/VACUUM.md): puck, trail, calibration, hidden rules.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const o = {};
const sr = () => c.shadowRoot || c.renderRoot;
// a vacuum device bound to space f1, dock placed by layout, plus a source
// camera whose coords live in "robot mm": target = 0.02*x+? — we calibrate
// via the 6-number matrix directly (0.5 scale: robot 0..2000 -> canvas 0..1000)
const M = [0.5, 0, 0, 0, 0.5, 0];
const mkAttrs = (x, y, extra) => ({ vacuum_position: { x, y, a: 45 }, map_name: 'm1', ...extra });
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(600, 800) },
} };
const cfg = c._serverCfg;
cfg.markers = cfg.markers || [];
cfg.markers.push({ id: 'e_vacuum_robo', binding: 'entity:vacuum.robo', space: 'f1',
vacuum: { source: 'camera.robo_map', calibration: { m1: M } } });
c._layout['e_vacuum_robo'] = { s: 'f1', x: 0.1, y: 0.1 };
c._regSignature = ''; c._setMode('view'); await c.updateComplete;
o.dockedNoPuck = !sr().querySelector('.vacpuck');
o.baseMarkerThere = !!sr().querySelector('.dev');
// start cleaning -> puck appears at transformed coords, base marker stays
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
await c.updateComplete; await new Promise((r) => setTimeout(r, 50));
let puck = sr().querySelector('.vacpuck');
o.puckAppears = !!puck;
o.puckRound = puck && getComputedStyle(puck).borderRadius === '50%';
// owner 2026-07-31: the base badge, but round and 20% smaller — same plate
// vs a NEUTRAL badge: the robot's own base is yellow while cleaning
const devEl = sr().querySelector('.dev:not(.on)');
const pcs = getComputedStyle(puck), dcs = getComputedStyle(devEl);
o.puckPlateMatchesDev = pcs.backgroundColor === dcs.backgroundColor
&& pcs.borderTopColor === dcs.borderTopColor;
o.puck80pct = Math.abs(puck.getBoundingClientRect().width
- devEl.getBoundingClientRect().width * 0.8) < 2;
o.noWedge = !sr().querySelector('.vacwedge'); // owner 2026-07-31: no heading arrow
// the glyph is DEAD centre (owner report: it floated on the text baseline)
const pr = puck.getBoundingClientRect();
const ir = sr().querySelector('.vacpuck ha-icon').getBoundingClientRect();
o.iconCentred = Math.abs(ir.left + ir.width / 2 - (pr.left + pr.width / 2)) < 0.75
&& Math.abs(ir.top + ir.height / 2 - (pr.top + pr.height / 2)) < 0.75;
o.baseStaysDuringCleaning = !!sr().querySelector('.dev');
// drive: two more points -> trail polyline grows; puck moves
const p1 = puck.getBoundingClientRect();
for (const [x, y] of [[900, 800], [900, 1100]]) {
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: mkAttrs(x, y) } } };
await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
}
const trail = sr().querySelector('.vactrail polyline');
// the trail LAGS one point behind the puck: after 3 telemetry points only
// the first two are drawn — a segment never outruns the icon (owner)
o.trailDrawn = !!trail && trail.getAttribute('points').split(' ').length === 2;
o.trailScaledByMatrix = !!trail && trail.getAttribute('points').startsWith('300.0,400.0');
o.trailBehindPuck = !!trail && !trail.getAttribute('points').includes('450.0,550.0');
// a zoom/view change TELEPORTS the puck instead of gliding across the plan
c._applyView(1.35); c.requestUpdate(); await c.updateComplete;
o.zoomTeleports = sr().querySelector('.vacpuck').classList.contains('jump');
c._applyView(1); c.requestUpdate(); await c.updateComplete;
// casing pair: dark halo + light core with identical geometry — the trail
// must survive any room fill underneath (owner 2026-07-31)
const tcase = sr().querySelector('.vactrail .case');
const tcore = sr().querySelector('.vactrail .core');
o.trailCasing = !!tcase && !!tcore
&& tcase.getAttribute('points') === tcore.getAttribute('points')
&& parseFloat(getComputedStyle(tcase).strokeWidth) > parseFloat(getComputedStyle(tcore).strokeWidth)
&& getComputedStyle(tcase).stroke.includes('0, 0, 0')
&& getComputedStyle(tcore).stroke.includes('255, 255, 255');
// trail off via marker option
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = false;
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.trailToggleOff = !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail = null;
// dock -> puck dissolves, trail lingers (linger window)
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30));
o.puckGoneWhenDocked = !sr().querySelector('.vacpuck');
// default mode 'cleaning': the trail HIDES the moment the run ends (owner)
o.trailHiddenAfterDock = !sr().querySelector('.vactrail polyline');
// hidden marker renders neither puck nor trail
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } } } };
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = true;
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.hiddenNoPuck = !sr().querySelector('.vacpuck') && !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').hidden = false;
// no calibration for the active map -> no puck (graceful degradation)
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: mkAttrs(900, 1100, { map_name: 'm2' }) } } };
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.unknownMapNoPuck = !sr().querySelector('.vacpuck');
// ---- server-side runs: current + one previous (owner 2026-07-31) ----
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'always';
c._vacSrvTrails = { e_vacuum_robo: {
current: { map_id: 'm1', started: 1, ended: null,
points: [[700, 500], [900, 500], [1100, 640], [950, 1150]] },
previous: { map_id: 'm1', started: 0, ended: 1,
points: [[600, 1300], [800, 1300], [800, 1500]] },
} };
c.hass = { ...c.hass, states: { ...c.hass.states,
'vacuum.robo': { state: 'cleaning', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 950, y: 1150, a: 0 }, map_name: 'm1' } } } };
await c.updateComplete;
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
const prevG = sr().querySelector('.vactrail g.prev');
o.srvPrevRunShown = !!prevG && prevG.querySelectorAll('polyline').length === 2
&& prevG.querySelector('.case').getAttribute('points').split(' ').length === 3;
const curLines = [...sr().querySelectorAll('.vactrail > polyline.case, .vactrail g:not(.prev) polyline.case')];
const cur = curLines.find((x) => !x.closest('g.prev'));
// 4 server points, the live tail trimmed while moving -> 3 rendered
o.srvCurTrimmed = !!cur && cur.getAttribute('points').split(' ').length === 3;
o.srvPrevFaded = !!prevG && getComputedStyle(prevG).opacity === '0.4';
// the growing tip is glued to the puck by the rAF sampler
const tip = sr().querySelector('line.tip');
o.tipExists = !!tip;
await new Promise((r) => setTimeout(r, 250));
const puckNow = sr().querySelector('.vacpuck').getBoundingClientRect();
const svgT = sr().querySelector('.vactrail');
const vbT = svgT.viewBox.baseVal; const sbT = svgT.getBoundingClientRect();
const tx = sbT.left + ((parseFloat(tip.getAttribute('x2')) - vbT.x) / vbT.width) * sbT.width;
const ty = sbT.top + ((parseFloat(tip.getAttribute('y2')) - vbT.y) / vbT.height) * sbT.height;
o.tipGluedToPuck = Math.hypot(tx - (puckNow.left + puckNow.width / 2), ty - (puckNow.top + puckNow.height / 2)) < 12;
// 'never' kills every line including the previous run
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = 'never';
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
o.neverHidesAll = !sr().querySelector('.vactrail');
cfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.trail_mode = null;
c._vacSrvTrails = {};
// ---- the fit panel (drag + corner-stretch) end to end ----
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const dev = c._devices.find((x) => x.id === 'e_vacuum_robo');
o.fitDevFound = !!dev;
// map m2 with rooms (bboxes) and a parked robot
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 500, y: 500, a: 0 }, map_name: 'm2',
rooms: { 1: { name: 'Кухня', x0: 0, y0: 0, x1: 1000, y1: 800 },
2: { name: 'Зал', x0: 0, y0: 800, x1: 1000, y1: 2000 } } } } } };
await c.updateComplete;
c._vacStartFit(dev); await c.updateComplete;
o.fitOverlay = !!sr().querySelector('.vacfit');
o.fitGhostRooms = sr().querySelectorAll('.vacfit polygon').length === 2;
o.fitLabels = [...sr().querySelectorAll('.vacfit text')].map((t) => t.textContent.trim()).join('|') === 'Кухня|Зал';
o.fitHandles = sr().querySelectorAll('.vacfithandle').length === 4;
// REAL hit-tests: what would an actual click land on? Synthetic dispatch
// bypasses pointer-events, and that let the untouchable overlay pass once.
const hitAt = (el) => {
const r = el.getBoundingClientRect();
let n = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
while (n && n.shadowRoot) {
const deeper = n.shadowRoot.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (!deeper || deeper === n) break;
n = deeper;
}
return n;
};
o.fitHandleHittable = hitAt(sr().querySelector('.vacfithandle'))?.classList?.contains('vacfithandle') === true;
o.fitGhostHittable = !!hitAt(sr().querySelector('.vacfit polygon'))?.closest?.('.vacfit');
o.fitBar = !!sr().querySelector('.vaccalbar');
o.fitMirrorDefault = c._vacFit.p.mir === true;
// drag: the ghost centre must follow; rotate: the centre must NOT move
const before = { ...c._vacFit.p };
const svgEl = sr().querySelector('.vacfit');
const sb2 = svgEl.getBoundingClientRect();
const fire = (type, x, y, target) => (target || svgEl).dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, pointerId: 7, clientX: x, clientY: y }));
fire('pointerdown', sb2.left + sb2.width * 0.5, sb2.top + sb2.height * 0.5);
fire('pointermove', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
fire('pointerup', sb2.left + sb2.width * 0.6, sb2.top + sb2.height * 0.55);
await c.updateComplete;
o.fitDragMoves = c._vacFit.p.ox !== before.ox && c._vacFit.p.oy !== before.oy;
const centreBefore = (() => { const t = c._vacFit.p; const m = [t.s * (t.mir ? -1 : 1), 0, t.ox, 0, t.s, t.oy];
return [m[0] * 500 + m[2], m[4] * 1000 + m[5]]; })();
c._vacFitTurn({ rot: 90 }); await c.updateComplete;
o.fitRotateKeepsCentre = c._vacFit.p.rot === 90;
// corner-stretch scales
const s0 = c._vacFit.p.s;
const handle = sr().querySelector('.vacfithandle');
const hb = handle.getBoundingClientRect();
fire('pointerdown', hb.left + hb.width / 2, hb.top + hb.height / 2, handle);
fire('pointermove', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
fire('pointerup', hb.left + hb.width / 2 + 60, hb.top + hb.height / 2 + 60);
await c.updateComplete;
o.fitCornerScales = Math.abs(c._vacFit.p.s - s0) > 1e-6;
// save -> matrix stored for m2, panel closed, puck appears while cleaning
c._vacFitSave(); await c.updateComplete;
const savedM = c._serverCfg.markers.find((m) => m.id === 'e_vacuum_robo').vacuum.calibration.m2;
o.fitSavedMatrix = Array.isArray(savedM) && savedM.length === 6 && savedM.every(Number.isFinite);
o.fitClosed = !c._vacFit && !sr().querySelector('.vacfit');
o.oldWizardGone = typeof c._vacCalClick === 'undefined' && typeof c._vacStartWizard === 'undefined';
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.robo_map': { state: 'idle', attributes: { vacuum_position: { x: 501, y: 501, a: 0 }, map_name: 'm2',
rooms: {} } } } };
await c.updateComplete;
c.hass = { ...c.hass, states: { ...c.hass.states } }; await c.updateComplete;
o.puckAfterFit = !!sr().querySelector('.vacpuck');
return o;
});
checkAll(out, {
dockedNoPuck: true, baseMarkerThere: true, puckAppears: true, puckRound: true,
puckPlateMatchesDev: true, puck80pct: true,
noWedge: true, iconCentred: true, baseStaysDuringCleaning: true, trailDrawn: true,
trailScaledByMatrix: true, trailBehindPuck: true, zoomTeleports: true,
trailCasing: true, trailToggleOff: true,
puckGoneWhenDocked: true, trailHiddenAfterDock: true, hiddenNoPuck: true,
unknownMapNoPuck: true,
srvPrevRunShown: true, srvCurTrimmed: true, srvPrevFaded: true,
tipExists: true, tipGluedToPuck: true, neverHidesAll: true,
fitDevFound: true, fitOverlay: true, fitGhostRooms: true, fitLabels: true,
fitHandles: true, fitHandleHittable: true, fitGhostHittable: true, fitBar: true, fitMirrorDefault: true, fitDragMoves: true,
fitRotateKeepsCentre: true, fitCornerScales: true, fitSavedMatrix: true,
fitClosed: true, oldWizardGone: true, puckAfterFit: true,
});
await finish(browser);
+108
View File
@@ -0,0 +1,108 @@
// v1.54.0 audit regressions (HP-1540-01/-04/-06): the FIRST-USE path.
// An auto-discovered vacuum has NO marker in the config — cfg.markers stays
// empty here, unlike smoke_vacuum.mjs which always pushes one by hand (the
// audit called that gap out explicitly). Every vacuum action must materialise
// the marker and actually persist; plan rooms are legacy RECTANGLES.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const o = {};
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
// legacy rectangle rooms (x/y/w/h, HP-1540-04) in the garden space where
// the auto-discovered vacuum.mower already lives
const g = cfg.spaces.find((s) => s.id === 'garden');
g.rooms = [
{ id: 'ga', name: 'Кухня', area: 'garden', x: 0.05, y: 0.10, w: 0.25, h: 0.30 },
{ id: 'gb', name: 'Зал', x: 0.40, y: 0.10, w: 0.25, h: 0.30 },
{ id: 'gc', name: 'Спальня', x: 0.05, y: 0.50, w: 0.25, h: 0.30 },
];
// robot rooms: same centres in "robot mm" (scale 4), map_index is a
// NUMERIC ZERO — the cross-runtime zero-map contract (HP-1540-02)
const robotRooms = {
1: { name: 'Кухня', x0: 300, y0: 600, x1: 1100, y1: 1400 },
2: { name: 'Зал', x0: 1700, y0: 600, x1: 2500, y1: 1400 },
3: { name: 'Спальня', x0: 300, y0: 2200, x1: 1100, y1: 3000 },
};
const camAttrs = { vacuum_position: { x: 700, y: 1000, a: 0 }, map_index: 0, rooms: robotRooms };
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'camera.mower_map': { entity_id: 'camera.mower_map', device_id: 'd_mower', platform: 'demo' } },
states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: camAttrs } } };
c._regSignature = '';
c.requestUpdate(); await c.updateComplete;
// spy on config writes: a success toast is only honest after one of these
const writes = [];
const realWS = c.hass.callWS;
c.hass.callWS = async (m) => { if (m.type === 'houseplan/config/set') writes.push(m); return realWS(m); };
const dev = c._devices.find((x) => x.id === 'd_mower');
o.devFound = !!dev;
o.freshNoMarker = (cfg.markers || []).length === 0 && !dev.marker;
// ---- auto-calibration from scratch (HP-1540-01 + HP-1540-04) ----
c._vacAutoCalibrate(dev);
await c.updateComplete;
const m1 = (cfg.markers || []).find((x) => x.id === 'd_mower');
o.markerMaterialised = !!m1 && m1.binding === 'device:d_mower';
const cal0 = m1?.vacuum?.calibration?.['0'];
o.rectRoomsCalibrated = Array.isArray(cal0) && cal0.length === 6 && cal0.every(Number.isFinite);
o.sourceStored = m1?.vacuum?.source === 'camera.mower_map';
// the matrix really maps robot mm → canvas: centre of «Кухня» → (175, 250)
const ap = (mm, x, y) => [mm[0] * x + mm[1] * y + mm[2], mm[3] * x + mm[4] * y + mm[5]];
const hit = cal0 ? ap(cal0, 700, 1000) : [0, 0];
o.matrixMapsRooms = Math.hypot(hit[0] - 175, hit[1] - 250) < 2;
o.successToastShown = (c._toast || '').startsWith('Done: bound via 3 rooms');
await new Promise((r) => setTimeout(r, 700)); // debounced _saveConfig
o.configWritePersisted = writes.length >= 1
&& !!writes[writes.length - 1].config.markers.find((x) => x.id === 'd_mower')?.vacuum?.calibration?.['0'];
// ---- manual fit from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.id === 'd_mower');
o.fitFreshNoMarker = !dev2.marker;
c._vacStartFit(dev2); await c.updateComplete;
c._vacFitSave(); await c.updateComplete;
const m2 = (cfg.markers || []).find((x) => x.id === 'd_mower');
const calFit = m2?.vacuum?.calibration?.['0'];
o.fitMaterialises = !!m2 && Array.isArray(calFit) && calFit.length === 6;
o.fitToastAfterSave = c._toast === c._t('vac.cal_done');
// ---- live checkbox + trail select from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
const liveBox = sr().querySelector('.vacbox input[type=checkbox]');
o.vacSectionShown = !!liveBox;
if (liveBox) { liveBox.click(); await c.updateComplete; }
const m3 = (cfg.markers || []).find((x) => x.id === 'd_mower');
o.liveToggleMaterialises = !!m3 && m3.vacuum?.live === false;
const sel = sr().querySelector('.vacbox select');
if (sel) { sel.value = 'always'; sel.dispatchEvent(new Event('change')); await c.updateComplete; }
o.trailModePersists = m3?.vacuum?.trail_mode === 'always';
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
// ---- error copy never points at the removed point calibration (HP-1540-06) ----
const toasts = ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']
.map((k) => c._t(k)).join(' | ');
o.noPointCalibrationCopy = !/point|точк/i.test(toasts);
// and the real no-rooms path shows the reworded toast
c.hass = { ...c.hass, states: { ...c.hass.states,
'camera.mower_map': { state: 'idle', attributes: { vacuum_position: { x: 1, y: 2, a: 0 }, map_index: 0 } } } };
await c.updateComplete;
c._vacAutoCalibrate(c._devices.find((x) => x.id === 'd_mower'));
o.noRoomsToastReworded = c._toast === c._t('vac.autocal_no_rooms') && !/point|точк/i.test(c._toast);
c.hass.callWS = realWS;
return o;
});
checkAll(out);
await finish(browser, out);
+60
View File
@@ -0,0 +1,60 @@
// Единый принцип жёлтого (2026-07-29): жёлтый = устройство прямо сейчас
// выполняет основную функцию. Термоголовка желтеет от реального нагрева
// (hvac_action), а не от служебного свитча защиты от накипи; горящая лампа
// желтит значок в любом режиме заливки тем же условием, что зажигает пятно.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(() => {
const o = {};
const c = window.__card;
const cls = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const r = c._stateClass(d);
c.hass = saved;
return r;
};
const trv = { id: 't', primary: 'climate.trv', entities: ['climate.trv', 'switch.trv_anti_scaling'], marker: null };
// реально греет → жёлтая
o.heatingIsYellow = cls(trv, {
'climate.trv': { state: 'heat', attributes: { hvac_action: 'heating' } },
'switch.trv_anti_scaling': { state: 'on' },
}) === 'on';
// включена, но не греет (idle) → чёрная, даже со включённой защитой от накипи
o.idleIsDark = cls(trv, {
'climate.trv': { state: 'heat', attributes: { hvac_action: 'idle' } },
'switch.trv_anti_scaling': { state: 'on' },
}) === '';
// выключена → чёрная
o.offIsDark = cls(trv, {
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
}) === '';
// без hvac_action фолбэк на state
o.stateFallback = cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === ''
? false : cls(trv, { 'climate.trv': { state: 'heat', attributes: {} } }) === 'on';
// горящая лампа устройства желтит значок, даже если primary — не она
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
o.litLightWins = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'on' },
}) === 'on';
o.darkLampIdle = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'off' },
}) === '';
// «источник света»: умный выключатель с глупыми светильниками — жёлтый от
// того же условия, что и пятно glow
const wall = { id: 'w', primary: 'sensor.w', entities: ['sensor.w'],
marker: { is_light: true, controls: ['switch.fixtures'] } };
o.forcedSourceYellow = cls(wall, {
'sensor.w': { state: '1' }, 'switch.fixtures': { state: 'on' },
}) === 'on';
return o;
});
await finish(browser, checkAll(out));
+110
View File
@@ -0,0 +1,110 @@
// HP-1551: opening the card with a saved per-space zoom must NOT paint even a
// single frame at the default fit ("the plan flashes and re-scales"). Repro of
// the field report: LS holds the config cache (instant render) and a saved
// zoom of 1.8, the server config arrives ~350 ms later like a real HA
// websocket round-trip. Every rAF frame from the first possible moment is
// sampled: a frame where the stage is visible AND the viewBox is at the
// default scale is the flash.
import { launch, check, finish } from './serve.mjs';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
const ZOOM = 1.8;
// ---- phase 1: prime the config cache exactly the way production does ------
const { page, browser } = await launch();
await page.waitForFunction(() => !!localStorage.getItem('houseplan_card_cfg_v1'), { timeout: 9000 });
const cfgCache = await page.evaluate(() => localStorage.getItem('houseplan_card_cfg_v1'));
// default fit width at zoom 1 (clean LS): the yardstick for "default scale"
const defaultFitW = await page.evaluate(() => window.__card._view.w);
await page.close();
// ---- phase 2: cold open with a saved zoom and a slow server ----------------
const ctx = await browser.newContext({ viewport: { width: 820, height: 760 } });
await ctx.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
if (p === '/') p = '/demo.html';
const f = ROOT + p;
existsSync(f)
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
: r.fulfill({ status: 404, body: 'nf' });
});
await ctx.addInitScript(([cache, zoom]) => {
localStorage.setItem('houseplan_card_cfg_v1', cache);
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: zoom }));
localStorage.removeItem('houseplan_card_nav_v1');
window.__frames = [];
const origCreate = Document.prototype.createElement;
Document.prototype.createElement = function (tag, ...a) {
const el = origCreate.call(this, tag, ...a);
if (String(tag).toLowerCase() !== 'houseplan-card' || window.__hooked) return el;
window.__hooked = true;
// model the HA websocket latency: the config cache renders instantly,
// the live config lands 350 ms later
let proto = Object.getPrototypeOf(el), desc = null;
while (proto && !desc) { desc = Object.getOwnPropertyDescriptor(proto, 'hass'); proto = Object.getPrototypeOf(proto); }
if (desc?.set) {
Object.defineProperty(el, 'hass', {
configurable: true,
get() { return desc.get.call(this); },
set(v) {
if (v && typeof v.callWS === 'function' && !v.__slow) {
const orig = v.callWS.bind(v);
v.__slow = true;
v.callWS = (m) => (m && m.type === 'houseplan/config/get')
? new Promise((res) => setTimeout(() => res(orig(m)), 350))
: orig(m);
}
desc.set.call(this, v);
},
});
}
// rAF sampling from the first possible moment (before the first paint)
const sample = () => {
const sr = el.shadowRoot || el.renderRoot;
const stage = sr && sr.querySelector('.stage');
const svgEl = stage && stage.querySelector(':scope > .zoomwrap > svg');
if (stage && svgEl) {
const cs = getComputedStyle(stage);
const cw = getComputedStyle(svgEl.parentElement);
const cv = getComputedStyle(svgEl);
const r = stage.getBoundingClientRect();
const vb = (svgEl.getAttribute('viewBox') || '').split(/\s+/).map(Number);
const hidden = [cs, cw, cv].some((c) => c.opacity === '0' || c.visibility === 'hidden' || c.display === 'none');
window.__frames.push({ visible: !hidden && r.width > 1 && r.height > 1, vbW: vb[2] || 0, zoom: el._zoom });
} else {
window.__frames.push(null);
}
if (window.__frames.length < 75) requestAnimationFrame(sample);
else window.__framesDone = true;
};
requestAnimationFrame(sample);
return el;
};
}, [cfgCache, ZOOM]);
const p2 = await ctx.newPage();
p2.on('pageerror', (e) => console.log('EXC2', e.message));
await p2.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await p2.waitForFunction(() => window.__framesDone === true, { timeout: 15000 });
const frames = await p2.evaluate(() => window.__frames);
const targetW = defaultFitW / ZOOM;
const seen = frames.filter(Boolean);
// the flash: stage visible while the viewBox is still at the default scale
// (clearly wider than the saved-zoom view; the default fit is 1.8x wider)
const flashFrames = seen.filter((f) => f.visible && f.vbW > targetW * 1.35);
const last = seen[seen.length - 1];
const goodFrames = seen.filter((f) => f.visible && Math.abs(f.vbW - targetW) < targetW * 0.02);
check('sampledFrames', seen.length > 30);
check('noDefaultScaleFlash', flashFrames.length === 0 ? true
: `flash: ${flashFrames.length} visible frame(s) at default scale, e.g. vbW=${flashFrames[0].vbW.toFixed(3)} vs target ${targetW.toFixed(3)}`);
check('settledAtSavedZoom', !!last && last.visible && Math.abs(last.zoom - ZOOM) < 0.01
&& Math.abs(last.vbW - targetW) < targetW * 0.02);
check('planActuallyShown', goodFrames.length >= 5); // the fix must not just hide the plan forever
await finish(browser);
+224
View File
@@ -0,0 +1,224 @@
// v1.49.x: zoom goes below the base fit, the editor does not shift the plan.
// - the stage height follows the MEASURED header, not a hard-coded 118px, so
// entering an editor keeps the plan inside the viewport;
// - zoom < 1 centres the content instead of pinning it to a corner;
// - the content frame (default zoom) includes devices standing outside rooms.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
// -- editor entry keeps the stage inside the viewport --------------------
const stageBox = () => page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
const b = sr.querySelector('.stage').getBoundingClientRect();
return { top: Math.round(b.top), bottom: Math.round(b.bottom) };
});
const vh = await page.evaluate(() => window.innerHeight);
const inView = await stageBox();
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(400);
const inPlan = await stageBox();
out.viewFitsViewport = inView.bottom <= vh + 2;
out.editorFitsViewport = inPlan.bottom <= vh + 2; // used to overflow by ~90px
out.editorStageShrinks = inPlan.top > inView.top && inPlan.bottom <= inView.bottom + 2;
await page.evaluate(() => window.__card._setMode('view'));
await page.waitForTimeout(300);
// -- zoom out below the base fit -----------------------------------------
out.zoomOut = await page.evaluate(() => {
const c = window.__card;
c._resetZoom();
const fit = { ...c._viewOr(c._baseVb()) };
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
c._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 0.5);
const v = { ...c._view };
const base = c._baseVb();
const cx = v.x + v.w / 2, cy = v.y + v.h / 2;
return {
zoom: c._zoom,
wider: v.w > fit.w * 1.9, // actually zoomed out
centredX: Math.abs(cx - (base[0] + base[2] / 2)) < 1, // not pinned to a corner
centredY: Math.abs(cy - (base[1] + base[3] / 2)) < 1,
};
});
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
&& out.zoomOut.centredX && out.zoomOut.centredY;
delete out.zoomOut;
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
await page.evaluate(() => window.__card._resetZoom());
// -- editor zoom is a working tool, not the viewing intent ----------------
// view 1.6 → devices editor 2.5 → back to view: the pre-editor viewport
// (zoom AND center) comes back; the editor keeps its own zoom while open.
out.editorZoomNotSaved = await page.evaluate(async () => {
const c = window.__card;
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const center = () => { const v = c._view; return [v.x + v.w / 2, v.y + v.h / 2]; };
c._setMode('view');
c._resetZoom();
c._zoomAt(10, 10, 1.6); c._saveZoom(); // off-center on purpose
const want = { zoom: c._zoom, c: center() };
c._setMode('devices'); await raf2();
c._zoomAt(10, 10, 2.5); c._saveZoom(); // off-center too: the center must not leak either
const editorZoomFree = Math.abs(c._zoom - 2.5) < 0.01; // zooming inside stays
c._setMode('view'); await raf2();
const got = { zoom: c._zoom, c: center() };
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
return {
editorZoomFree,
zoomRestored: Math.abs(got.zoom - want.zoom) < 0.01,
centerRestored: Math.hypot(got.c[0] - want.c[0], got.c[1] - want.c[1]) < 0.02,
lsRestored: Math.abs((ls[c._space] || 1) - want.zoom) < 0.01,
};
});
out.editorZoomFree = out.editorZoomNotSaved.editorZoomFree;
out.viewZoomRestored = out.editorZoomNotSaved.zoomRestored;
out.viewCenterRestored = out.editorZoomNotSaved.centerRestored;
out.viewZoomBackInLs = out.editorZoomNotSaved.lsRestored;
delete out.editorZoomNotSaved;
// -- editor entered and left without touching zoom: no jump ---------------
out.untouchedEditorNoJump = await page.evaluate(async () => {
const c = window.__card;
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
c._setMode('view');
c._resetZoom();
c._zoomAt(30, 30, 1.6); c._saveZoom();
const before = { ...c._view };
c._setMode('plan'); await raf2();
c._setMode('view'); await raf2();
const v = c._view;
return Math.abs(c._zoom - 1.6) < 0.01
&& Math.abs(v.x - before.x) < 0.005 && Math.abs(v.y - before.y) < 0.005
&& Math.abs(v.w - before.w) < 0.005 && Math.abs(v.h - before.h) < 0.005;
});
// -- the view zoom still survives a space switch, per space ---------------
out.viewZoomSurvivesSpaceSwitch = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
c._setMode('view');
c._resetZoom(); c._applyView(1.7); c._saveZoom();
c._slideTo('garden', 'left'); await wait(350);
const gardenGotOwnZoom = Math.abs(c._zoom - (JSON.parse(
localStorage.getItem('houseplan_card_zoom_v1') || '{}').garden || 1)) < 0.01;
c._slideTo('f1', 'right'); await wait(350);
return gardenGotOwnZoom && Math.abs(c._zoom - 1.7) < 0.01;
});
await page.evaluate(() => window.__card._resetZoom());
// -- owner's dacha regression: editor 500% must never reach the per-space
// view store. View zoom set on BOTH floors, editor cranked to 5.0, back to
// view — and the floor tab is clicked in the SAME tick (on the tablet the
// exit re-render janks, so the click runs before the restore rAF and the
// old fix-up save was skipped). Two switches later the stale 5.0 came back.
out.editorZoomNeverInSpaceStore = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
c._setMode('view');
c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // floor 1 view zoom
c._slideTo('garden', 'left'); await wait(350);
c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // floor 2 view zoom
c._setMode('devices'); await raf2();
c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — a working tool
const lsInEditor = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
c._setMode('view');
c._slideTo('f1', 'right'); await wait(350); // same tick as the exit — beats the rAF
const firstSwitch = Math.abs(c._zoom - 1.6) < 0.01;
c._slideTo('garden', 'left'); await wait(350);
const secondSwitch = Math.abs(c._zoom - 1.4) < 0.01; // used to come back as 5.0
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // leave the stage as the next tests expect
return {
editorNotPersisted: Math.abs((lsInEditor.garden || 1) - 1.4) < 0.01,
firstSwitch,
secondSwitch,
lsClean: Math.abs((ls.garden || 1) - 1.4) < 0.01 && Math.abs((ls.f1 || 1) - 1.6) < 0.01,
};
});
out.editorZoomStaysOutOfLs = out.editorZoomNeverInSpaceStore.editorNotPersisted;
out.ownerFirstSwitchOk = out.editorZoomNeverInSpaceStore.firstSwitch;
out.ownerSecondSwitchOk = out.editorZoomNeverInSpaceStore.secondSwitch;
out.ownerLsClean = out.editorZoomNeverInSpaceStore.lsClean;
delete out.editorZoomNeverInSpaceStore;
// -- HP-1543-01: смена этажа ВНУТРИ редактора не тащит editor zoom в view --
// view-зум сохранён на обоих этажах → редактор на f1 → переключение ВКЛАДКОЙ
// на garden → editor zoom 5.0 → выход в view (остаёмся на garden): вид обязан
// вернуться к сохранённому view-зуму garden (1.4) с штатным центром
// _restoreZoom(), а не остаться на редакторских 500% (снапшот-guard по space
// отбрасывал восстановление, а текущий _zoom так и оставался редакторским).
out.crossFloorEditorExit = await page.evaluate(async () => {
const c = window.__card;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const sr = c.shadowRoot || c.renderRoot;
c._setMode('view');
c._resetZoom(); c._zoomAt(10, 10, 1.6); c._saveZoom(); // f1 view zoom
c._slideTo('garden', 'left'); await wait(350);
c._resetZoom(); c._zoomAt(10, 10, 1.4); c._saveZoom(); // garden view zoom
c._slideTo('f1', 'right'); await wait(350);
c._setMode('devices'); await raf2();
// этаж меняется НАСТОЯЩЕЙ вкладкой — именно этот путь под подозрением
const idx = c._model.findIndex((s) => s.id === 'garden');
sr.querySelectorAll('.tabs .tab')[idx].click();
await c.updateComplete; await raf2();
c._zoomAt(10, 10, 5.0); c._saveZoom(); // 500% — рабочий инструмент
c._setMode('view'); await raf2(); await wait(60);
const stillGarden = c._space === 'garden';
const gotZoom = c._zoom;
const v = c._view; const vb = c._baseVb();
const centred = !!v && Math.abs(v.x + v.w / 2 - (vb[0] + vb[2] / 2)) < 1
&& Math.abs(v.y + v.h / 2 - (vb[1] + vb[3] / 2)) < 1;
const ls = JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}');
const store = { ...c._zoomBySpace };
c._slideTo('f1', 'right'); await wait(350); c._resetZoom(); // вернуть сцену следующим тестам
return {
stillGarden,
viewZoomRestoredCrossFloor: Math.abs(gotZoom - 1.4) < 0.01, // было 5.0 (HP-1543-01)
centerRestoredCrossFloor: centred, // центр как у _restoreZoom()
storeIntactCrossFloor: Math.abs((store.garden || 1) - 1.4) < 0.01, // _zoomBySpace не тронут
lsIntactCrossFloor: Math.abs((ls.garden || 1) - 1.4) < 0.01
&& Math.abs((ls.f1 || 1) - 1.6) < 0.01, // LS_ZOOM не тронут
};
});
out.crossFloorStillGarden = out.crossFloorEditorExit.stillGarden;
out.crossFloorViewZoomRestored = out.crossFloorEditorExit.viewZoomRestoredCrossFloor;
out.crossFloorCenterRestored = out.crossFloorEditorExit.centerRestoredCrossFloor;
out.crossFloorStoreIntact = out.crossFloorEditorExit.storeIntactCrossFloor;
out.crossFloorLsIntact = out.crossFloorEditorExit.lsIntactCrossFloor;
delete out.crossFloorEditorExit;
// -- devices outside rooms stretch the default frame ---------------------
out.devicesStretchFrame = await page.evaluate(() => {
const c = window.__card;
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
c._serverCfg = cfg; c._model = null;
const before = c._baseVb();
// walk one lamp far outside every room
c._layout = { ...c._layout, d_lamp: { s: 'f1', x: 0.99, y: 0.5 } };
const after = c._baseVb();
return after[0] + after[2] > before[0] + before[2] + 20; // right edge follows the lamp
});
// -- a card BELOW other dashboard content still gets a stage (HP-1500-02) --
out.stageSurvivesContentAbove = await page.evaluate(async () => {
const spacer = document.createElement('div');
spacer.style.height = '900px';
document.body.insertBefore(spacer, document.body.firstChild);
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const h = sr.querySelector('.stage').getBoundingClientRect().height;
spacer.remove();
window.dispatchEvent(new Event('resize'));
await new Promise((r) => setTimeout(r, 120));
// the old code billed the 900px spacer as "header" and left a 0px stage
return h > 300;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200"><rect width="800" height="200" fill="#eee"/></svg>

After

Width:  |  Height:  |  Size: 137 B

+7 -7
View File
@@ -51,17 +51,17 @@ customElements.define('ha-card',HaCard);
<script type="module">
const CFG = {
spaces: [
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', plan_aspect:1.25,
view_box:[0,0,1,1],
rooms:[
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]]},
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.14],[0.96,0.14],[0.96,0.46],[0.55,0.46]]},
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.46],[0.96,0.46],[0.96,0.86],[0.55,0.86]]},
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.58],[0.55,0.58],[0.55,0.86],[0.04,0.86]]}
], segments:[] },
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', plan_aspect:1.4286,
view_box:[0,0,1,1],
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.178],[0.97,0.178],[0.97,0.822],[0.03,0.822]]} ], segments:[] }
],
markers: [],
settings: {}
+29
View File
@@ -0,0 +1,29 @@
# Demo-stand extras
Things that live on the public stand (demo.houseplan.tech) but are not part
of the shipped integration.
- `demo_robot/` — the scripted robot vacuum (docs/VACUUM.md, "demo stand gets
a scripted synthetic robot"). Deployed by copying the folder to
`custom_components/demo_robot` in both stand seeds and adding `demo_robot:`
to configuration.yaml; the dev stand picks it up automatically from this
path (`/opt/hp/bin/hp-update-dev.sh`). The rest of the stand-only config
(template LQI sensors, alarm helpers, the smoke automation) lives in the
seeds on the stand host — see `docs/TESTING-DEMO.md` and the memory note
`houseplan-demo-stand`.
- `demo_guard/` — stand-only guard (2026-07-31). Visitors log in as an
administrator (the card editor is gated on `is_admin`) and kept restarting
HA from the UI, which looked like the stand crashing between hourly resets.
The component re-registers `homeassistant.restart`/`homeassistant.stop` as
no-ops after startup. Deployed to `custom_components/demo_guard` in
seed-demo only (the dev stand sits behind basic auth) + `demo_guard:` in
configuration.yaml.
- `www/stand-reset-timer.js` — console-only countdown to the next hourly
reset (`hp-reset.timer`, every hour at :00). Served as
`/local/stand-reset-timer.js` from `seed-demo/www/`, wired through
`frontend: extra_module_url`. Logs a styled `console.info` every minute,
switching to `console.warn` for the last 5 minutes. `www/stand-dev-info.js`
is the dev-stand counterpart: a single `console.info` saying the dev stand
only resets on deploy.
+46
View File
@@ -0,0 +1,46 @@
"""Stand-only guard: keep visitor-admins from killing the public demo.
Visitors log in as `demo`, and that user HAS to be an administrator — the
House Plan card gates its editor on `user.is_admin`, and demonstrating the
editor is the whole point of the stand. But an administrator can also call
`homeassistant.restart` / `homeassistant.stop`, and visitors do exactly that
(seen live 2026-07-31: HA exited with the restart code 100 minutes after a
scheduled reset, right as a visitor session from an external IP was active —
that is what looked like «стенд перезагружается чаще, чем раз в час»).
Once startup finishes this component re-registers both services as no-ops:
the UI buttons stay, pressing them only leaves a WARNING in the log. The
hourly stand reset is NOT affected — hp-reset.timer restarts the docker
container from the outside.
"""
from __future__ import annotations
import logging
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "demo_guard"
BLOCKED = ("restart", "stop")
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _blocked(call: ServiceCall) -> None:
_LOGGER.warning(
"демо-стенд: homeassistant.%s отключён для посетителей "
"(стенд и так сбрасывается сам, ежечасно в :00)",
call.service,
)
@callback
def _neuter(_event) -> None:
# After EVENT_HOMEASSISTANT_STARTED every core service is in place;
# registering the same domain/name again replaces the handler.
for service in BLOCKED:
hass.services.async_register("homeassistant", service, _blocked)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _neuter)
return True
+10
View File
@@ -0,0 +1,10 @@
{
"domain": "demo_guard",
"name": "Demo Stand Guard",
"codeowners": [],
"dependencies": [],
"documentation": "https://github.com/Matysh/houseplan-card/tree/dev/demo/stand/demo_guard",
"iot_class": "calculated",
"requirements": [],
"version": "1.0.0"
}
+5
View File
@@ -0,0 +1,5 @@
# demo_guard re-registers EXISTING homeassistant.restart / homeassistant.stop
# as no-ops on the public demo stand (visitors are admins by necessity — the
# card's editor requires is_admin — and kept restarting HA). It defines no
# services of its own; this file exists because hassfest requires it for any
# integration that calls async_register.
+181
View File
@@ -0,0 +1,181 @@
"""Scripted robot vacuum for the public demo stand.
The real feature (docs/VACUUM.md) targets Tier-A map integrations — Xiaomi
Cloud Map Extractor, Tasshack dreame-vacuum, Valetudo. None of them can run
on the stand (no hardware), so the stand ships this simulator instead: a
`vacuum` entity with start/pause/return_to_base and a `sensor` whose
attributes follow the Tasshack shape (`vacuum_position` as a dict, `rooms`
with names matching the plan rooms, `map_index`). Room names match the demo
house on purpose — that is what makes «Настроить автоматически» demonstrable.
Coordinates are millimetres in 0..8000 with the Y axis flipped versus the
screen (like every real robot map seen so far) so the mirror toggle in the
fit panel has meaning on the stand too.
"""
from __future__ import annotations
import math
from datetime import timedelta
from typing import Any, Callable
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import discovery
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
DOMAIN = "demo_robot"
TICK_S = 2 # telemetry cadence
SPEED_MM = 600 # per tick ≈ 0.3 m/s, a realistic robot pace
DOCK = (3900.0, 2600.0)
# Robot-map rooms, mm, Y up (flipped vs screen). Bboxes mirror the plan's
# room polygons; the names MUST equal the plan room names — auto-calibration
# matches by name.
ROOMS: dict[str, tuple[str, float, float, float, float]] = {
"1": ("Гостиная", 480, 4320, 4160, 7360),
"2": ("Кухня", 4160, 5120, 7520, 7360),
"3": ("Спальня", 4160, 2400, 7520, 5120),
"4": ("Кабинет", 480, 2400, 2720, 4320),
"5": ("Прихожая", 2720, 2400, 4160, 4320),
}
# cleaning order: start at the dock in the hallway, end next to it
CLEAN_ORDER = ("5", "4", "1", "2", "3")
INSET = 350.0 # keep lanes away from walls
LANE_MM = 650.0 # serpentine lane spacing
def _serpentine(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
"""Boustrophedon lanes across a room bbox, like a real vacuum drives."""
x0, y0, x1, y1 = x0 + INSET, y0 + INSET, x1 - INSET, y1 - INSET
pts: list[tuple[float, float]] = []
y = y0
left_to_right = True
while y <= y1 + 1:
xs = (x0, x1) if left_to_right else (x1, x0)
pts.append((xs[0], y))
pts.append((xs[1], y))
left_to_right = not left_to_right
y += LANE_MM
return pts
def build_route() -> list[tuple[float, float]]:
route: list[tuple[float, float]] = [DOCK]
for rid in CLEAN_ORDER:
_, x0, y0, x1, y1 = ROOMS[rid]
route.extend(_serpentine(x0, y0, x1, y1))
route.append(DOCK)
return route
class RobotSim:
"""One shared simulation both entities read from."""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.route = build_route()
self.state = "docked" # docked|cleaning|paused|returning
self.pos: tuple[float, float] = DOCK
self.heading = 0.0
self._seg = 0 # index of the segment being driven
self._seg_done = 0.0 # mm driven inside the current segment
self._back: list[tuple[float, float]] | None = None # manual return path
self._listeners: list[Callable[[], None]] = []
# ---- entity plumbing ----
def add_listener(self, cb: Callable[[], None]) -> Callable[[], None]:
self._listeners.append(cb)
def _remove() -> None:
if cb in self._listeners:
self._listeners.remove(cb)
return _remove
def _notify(self) -> None:
for cb in list(self._listeners):
cb()
# ---- commands (called by the vacuum entity) ----
def start(self) -> None:
if self.state == "paused":
self.state = "returning" if self._back else "cleaning"
else:
self.state = "cleaning"
self._seg = 0
self._seg_done = 0.0
self._back = None
self.pos = DOCK
self._notify()
def pause(self) -> None:
if self.state in ("cleaning", "returning"):
self.state = "paused"
self._notify()
def stop(self) -> None:
self.pause()
def return_to_base(self) -> None:
if self.state == "docked":
return
# retrace the visited waypoints backwards — a straight line to the
# dock would cut through walls
visited = self.route[: self._seg + 1]
self._back = [self.pos] + list(reversed(visited))
self._seg = 0
self._seg_done = 0.0
self.state = "returning"
self._notify()
# ---- physics ----
def _polyline(self) -> list[tuple[float, float]]:
return self._back if self._back is not None else self.route
def tick(self, _now: Any = None) -> None:
if self.state not in ("cleaning", "returning"):
return
line = self._polyline()
# manual return drives twice as fast, like the real thing hurrying home
budget = SPEED_MM * (2 if self._back is not None else 1)
while budget > 0 and self._seg < len(line) - 1:
ax, ay = line[self._seg]
bx, by = line[self._seg + 1]
seg_len = math.hypot(bx - ax, by - ay)
left = seg_len - self._seg_done
if left <= budget:
budget -= left
self._seg += 1
self._seg_done = 0.0
continue
self._seg_done += budget
t = self._seg_done / seg_len
self.pos = (ax + (bx - ax) * t, ay + (by - ay) * t)
self.heading = math.degrees(math.atan2(by - ay, bx - ax)) % 360
budget = 0
if self._seg >= len(line) - 1:
# arrived: either the route is finished or the manual return is
self.pos = line[-1]
self.state = "docked"
self._back = None
elif self._back is None and self._seg >= len(line) - 2:
# the last route leg is the drive home
self.state = "returning"
self._notify()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sim = RobotSim(hass)
hass.data[DOMAIN] = sim
@callback
def _tick(now: Any) -> None:
sim.tick(now)
async_track_time_interval(hass, _tick, timedelta(seconds=TICK_S))
hass.async_create_task(
discovery.async_load_platform(hass, "vacuum", DOMAIN, {}, config)
)
hass.async_create_task(
discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config)
)
return True
+10
View File
@@ -0,0 +1,10 @@
{
"domain": "demo_robot",
"name": "Demo Robot Vacuum",
"codeowners": [],
"dependencies": [],
"documentation": "https://github.com/Matysh/houseplan-card/tree/dev/demo/stand/demo_robot",
"iot_class": "calculated",
"requirements": [],
"version": "1.0.0"
}
+56
View File
@@ -0,0 +1,56 @@
"""The map sensor of the stand simulator — Tasshack-shaped attributes.
`vacuum_position` is a plain dict on purpose: the real Tasshack integration
keeps a Point OBJECT in memory (houseplan's trail recorder handles both), but
a dict is what any template/attribute tooling on the stand can also read.
"""
from __future__ import annotations
from typing import Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from . import DOMAIN, ROOMS, RobotSim
async def async_setup_platform(hass: HomeAssistant, config, add_entities, discovery_info=None) -> None:
add_entities([DemoRobotMap(hass.data[DOMAIN])])
class DemoRobotMap(SensorEntity):
_attr_should_poll = False
_attr_name = "Карта робота-пылесоса"
_attr_unique_id = "demo_robot_map"
_attr_icon = "mdi:map"
def __init__(self, sim: RobotSim) -> None:
self._sim = sim
self.entity_id = "sensor.demo_robot_map"
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._sim.add_listener(self.async_write_ha_state))
@property
def native_value(self) -> str:
return self._sim.state
@property
def extra_state_attributes(self) -> dict[str, Any]:
x, y = self._sim.pos
return {
"vacuum_position": {
"x": round(x, 1),
"y": round(y, 1),
"a": round(self._sim.heading, 1),
},
"rooms": {
rid: {
"name": name,
"x0": x0, "y0": y0, "x1": x1, "y1": y1,
"x": (x0 + x1) / 2, "y": (y0 + y1) / 2,
}
for rid, (name, x0, y0, x1, y1) in ROOMS.items()
},
"map_index": 1,
}
+65
View File
@@ -0,0 +1,65 @@
"""The vacuum entity of the stand simulator."""
from __future__ import annotations
from typing import Any
from homeassistant.components.vacuum import (
StateVacuumEntity,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
from . import DOMAIN, RobotSim
ACTIVITY = {
"docked": VacuumActivity.DOCKED,
"cleaning": VacuumActivity.CLEANING,
"paused": VacuumActivity.PAUSED,
"returning": VacuumActivity.RETURNING,
}
async def async_setup_platform(hass: HomeAssistant, config, add_entities, discovery_info=None) -> None:
add_entities([DemoRobotVacuum(hass.data[DOMAIN])])
class DemoRobotVacuum(StateVacuumEntity):
_attr_should_poll = False
_attr_name = "Робот-пылесос"
_attr_unique_id = "demo_robot_vacuum"
_attr_supported_features = (
VacuumEntityFeature.START
| VacuumEntityFeature.STOP
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.STATE
)
def __init__(self, sim: RobotSim) -> None:
self._sim = sim
self.entity_id = "vacuum.demo_robot"
async def async_added_to_hass(self) -> None:
self.async_on_remove(self._sim.add_listener(self.async_write_ha_state))
@property
def activity(self) -> VacuumActivity:
return ACTIVITY.get(self._sim.state, VacuumActivity.IDLE)
@property
def extra_state_attributes(self) -> dict[str, Any]:
# the card resolves the active map name through the vacuum entity
return {"selected_map": "1"}
async def async_start(self) -> None:
self._sim.start()
async def async_pause(self) -> None:
self._sim.pause()
async def async_stop(self, **kwargs: Any) -> None:
self._sim.stop()
async def async_return_to_base(self, **kwargs: Any) -> None:
self._sim.return_to_base()
+7
View File
@@ -0,0 +1,7 @@
// Dev-стенд House Plan: ежечасного сброса НЕТ. Контейнер перезапускается
// только при новом коммите в ветку dev (hp-update-dev.timer, проверка раз
// в 10 минут). Одно информационное сообщение при загрузке, без таймера.
console.info(
"%c[dev-стенд] сброс только при деплое: перезапуск при новом коммите в dev (проверка раз в 10 мин)",
"background:#5d4037;color:#fff;padding:2px 6px;border-radius:3px"
);
+31
View File
@@ -0,0 +1,31 @@
// Демо-стенд House Plan: таймер до ближайшего сброса — пишет ТОЛЬКО в консоль
// DevTools, никакого UI. Стенд сбрасывается systemd-таймером hp-reset.timer
// ежечасно в :00 (RandomizedDelaySec=0; сам перезапуск занимает ~15 секунд,
// отсюда «~» в сообщении).
(() => {
"use strict";
const INFO = "background:#03589c;color:#fff;padding:2px 6px;border-radius:3px";
const WARN = "background:#b71c1c;color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold";
const tick = () => {
const now = new Date();
const next = new Date(now);
next.setMinutes(60, 0, 0); // ближайший :00
const left = Math.max(1, Math.ceil((next - now) / 60000));
if (left <= 5) {
console.warn(
`%c[демо-стенд] до сброса ~${left} мин — несохранённые эксперименты пропадут (сброс ежечасно в :00)`,
WARN
);
} else {
console.info(`%c[демо-стенд] до сброса ~${left} мин (сброс ежечасно в :00)`, INFO);
}
};
tick();
// выравниваемся по границе минуты, дальше — раз в минуту
setTimeout(() => {
tick();
setInterval(tick, 60000);
}, (60 - new Date().getSeconds()) * 1000);
})();
+780 -183
View File
File diff suppressed because one or more lines are too long
+175 -19
View File
@@ -10,13 +10,14 @@ houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), curation, groups, domain priority
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, domain priority
│ └─ data/
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
│ ├─ trails.py # server-side vacuum trail recorder (state-change driven)
│ ├─ websocket_api.py # houseplan/layout/get|set|update
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
@@ -100,23 +101,36 @@ name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-d
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
references and duplicates by name|area. Manual files: `houseplan/file/set` → `/config/houseplan/files/<id>/`,
served from `/houseplan_files/files/`.
references and duplicates by name|area. Manual files: transactional HTTP upload into `<config>/houseplan/files/<id>/`
(staging `up_*` folders promoted on save), served via signed
`/api/houseplan/content/files/…` urls.
## Server-side configuration (v1.3.0+)
## Server-side configuration (current shape, v1.51+)
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","aspect","view_box":[4],"rooms":[{"id","name","area","x","y","w","h"}] }],
"device_overrides": {"<device_id>": {"hidden","icon","name"}},
"virtual_devices": [{"id","space","name","icon","x","y","note?","entity_id?"}],
"settings": {"exclude_integrations":[],"group_lights":true} }
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
"openings":[…], "decor":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
"name","icon","display","controls","is_light","tap_action",
"room_id","pdfs",… }],
"settings": { "exclude_integrations":[], "group_lights":true,
"filter_seeded":true, "fill_colors":{…}, "icon_rules":[…],
"known_devices":[…], "new_device_ids":[…] } }
```
All coordinates are **normalized (0..1 of the space plan)**; the render space is
1000 × 1000/aspect. Layout v2: `{device_id: {"s": space, "x", "y"}}` (normalized).
Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/plans/…`.
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
it centred on the square. The schema bounds geometry to ±4 with strictly
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
gone — markers carry everything, `marker.hidden` is the explicit
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
Layout v2: `{device_id | rl_<roomId>: {"s": space, "x", "y"}}` (normalized,
bounded ±4). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
(copy-on-write, never overwritten), served via signed
`/api/houseplan/content/plans/_/<name>` urls; growth is bounded by store
quotas, nothing is ever deleted for being old (docs/SCOPE.md).
## Room geometry rules (v1.19–v1.21)
@@ -172,13 +186,155 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| Command | Parameters | Response |
|---|---|---|
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}}` |
| `houseplan/layout/set` | `layout` | `{ok}` (admin_only optional) |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
| `houseplan/layout/get` | — | `{layout: {device_id: {x,y}}, rev}` |
| `houseplan/layout/set` | `layout`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_layout_updated` |
| `houseplan/layout/update` | `device_id`, `pos` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/trail/get` | — | `{trails: {marker: {current, previous}}}` — vacuum runs, raw robot coords |
| `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/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/layout/delete` | `device_id` | `{ok, rev}`; event `houseplan_layout_updated` |
| `houseplan/geometry/repair` | `space_id`, `aspect`, `dry_run?`, `undo?` | preview / `{ok, rev, moved}` / `{restored}`; errs `nothing_to_repair`, `no_backup` |
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
the old `houseplan/file/set` was removed in v1.10.0.
**If the v1.48 migration crashed halfway** (HP-1500-01): the config write
landed, the layout write did not, and both triggers are gone — markers of that
space sit in the old coordinates and nothing in the data can prove it. The
`geom_pending` intent (v1.50.0) prevents this for any future migration, but
cannot help an install that was already stranded. There is no safe automatic
answer — re-transforming a layout that is actually correct would corrupt it —
so the fix is explicit: `houseplan/geometry/repair {space_id, aspect}`
re-applies the transform to that one space's positions. `dry_run: true`
previews, the previous positions ride the same store write as a one-deep
backup, and `undo: true` restores them. Admin-gated like every other write.
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
`aspect`, and coordinates were normalised against it — x by the width, y by the
height. That made every geometric question depend on a per-space number for no
benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted
inside it by its own ratio (`fitInSquare`, shared by both renderers), which is
stored as `plan_aspect` so the layout does not jump before the file loads.
Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out
to a square and re-expresses every coordinate against it — a uniform scale plus
an offset in render units, so angles and proportions are exact — and scales
`cell_cm` for tall plans, since the grid pitch is a fraction of the width.
**User content is served inert** (HP-1454-01). An uploaded SVG is the only
thing here that a browser will happily treat as a *document* rather than an
image, and it would be a document of Home Assistant's own origin. Inside the
card that never matters — `<image>` does not run scripts — but the url is
reachable directly, and uploading needs only write access, which by default
every user has. `HouseplanContentView` therefore sends a `sandbox` CSP with SVG
and only with SVG: a CSP on a PDF response can break the browser's built-in
viewer, and a raster image has no execution model to disable.
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
An upload takes a free name and never overwrites, because the bytes under an
existing name may be referenced by the stored configuration and an upload is
not part of that transaction. `reserve_filename` *claims* the name as it picks
it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two
uploads agree on one name and quietly overwrite each other. It also budgets the
length so the result survives the sanitiser the content view applies to the
request, since a name the view rewrites is a file written and never served.
Streaming temporaries live in the files root under `.upload-`, are removed on
every exit path of the request (including cancellation, which is a
BaseException and slips past `except Exception`), and are swept at startup and
daily. That scheduled pass also runs the two collectors with the stored
configuration as *both* sides — nothing superseded, so every referenced file is
kept and only aged unreferenced ones go. Without it, collection would only ever
happen when somebody saves, and a file uploaded into a dialog that was then
cancelled would wait for a write that may never come. A new icon has no id yet, so its
files go to a per-dialog staging folder and move to the real id once the config
write is accepted — the same copy → save → cleanup order as a rebind.
`config/set` collects what its commit superseded — that much a commit knows for
certain. *Unreferenced* is a far weaker signal, and the policy follows from one
asymmetry: **a few unnecessary megabytes can always be removed by hand; a file
we should not have removed cannot be brought back.** When the evidence is weak,
keep the file. Owner's decision, 2026-07-28, after the one-hour rule applied to
every unreferenced file destroyed two detached plans.
The classification is by **owner**, not by "is it referenced". A file leaving
the configuration looks identical whether the plan was replaced, detached, or
its space deleted — and only the first is a deletion the user asked for. Reading
`old_refs - new_refs` and calling it "superseded" deleted a plan the moment it
was detached, under documentation promising the opposite (HP-1465-01).
| Case | What it means | Rule |
|---|---|---|
| Space in both, plan A → plan B | the user picked another image | removed immediately |
| Space in both, plan → none | detached; one click undoes it | **kept** |
| Space gone | deliberate, but the image was imported and may be nowhere else | **kept** |
| Space has a plan, plus another file of its own | an upload whose save was rejected | **kept** — ageing these out raced the retry that referenced them |
| Marker in both, attachment dropped from its list | a trash button, promising nothing | removed immediately |
| Marker gone | same call as a deleted space's plan | **kept** |
| Attachment in `up_*` | a dialog that was never saved; no device owns it | `PLAN_ORPHAN_TTL_S` (1 h) |
| Marker there, file it never listed | a rejected upload | **kept**, same reason |
Nothing is deleted for being old, with one exception: a per-dialog staging
folder (`up_*`), which by construction can only hold an upload from a dialog
that was never saved. The disk therefore stays bounded by the user, not by a
timer — `houseplan/plans/list` shows every stored plan with its size and which
space uses it, and `houseplan/plans/delete` removes one on request, refusing
while a space still references it. That listing is what makes "we never delete"
livable: a detached plan is not lost, it is one click away in the space dialog.
**Config writes are serialized** (HP-1454-03). `_writeConfig()` chains onto a
single promise: one `config/set` in flight, each carrying the revision the
previous one returned. The debounce still spaces out *when* a write starts;
what it cannot do — and used to be relied on for — is keep two writes from
overlapping, which produced a self-inflicted conflict and lost the newer edit.
**Plan uploads are copy-on-write, and collection belongs to the commit**
(reviews R2-1, R3-1). The file system is not part of the config's
optimistic-locking transaction, so nothing referenced may be overwritten or
deleted before the CAS succeeds: the upload writes a new versioned name and
removes nothing. Deciding what may then go is *not* a client's call — a cleanup
request cannot be ordered against another client's commit, and a delayed one
deletes a plan that was just saved. So `config/set` collects itself, inside its
write lock, from the pair of configurations that bracket the commit
(`plans.collect_plans`): a file the commit REPLACED goes immediately, and
nothing else goes at all — see the table above; only a per-dialog staging folder
ages out. Growth is bounded at the door instead, by `plans.check_quota` on every
upload (store size, file count, free disk), because a limit that deletes is how
plans were lost twice. 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.
**An internal plan url must exist when it is stored** (HP-1470-02). The picker
can attach a plan and then delete it, and two clients can do the same in either
order — the write lock orders the requests but says nothing about whether the
file survived. `config/set` therefore checks every `/api/houseplan/content/plans/`
url against the disk before saving, and refuses with `missing_plan`. External and
legacy urls are the user's own and are never second-guessed.
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
in `src/signing.ts` is the single implementation, used by both cards; the
duplicate inside houseplan-space-card signed correctly and never handed the
result to its renderer, which is the failure mode a second copy invites. `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.
Queued and in-flight are distinct states: a render happening while a request is
out must not queue the same url again, a failure backs off rather than retrying
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
promise that never settles cannot block retries forever.
**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/`.
@@ -198,7 +354,7 @@ Shared, framework-light modules keep the two views from diverging:
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
mirrors the full card's private geometry.
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
borders/names + device markers (via `buildDevices`, same filtering) with NO handlers,
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
(the space-card imports `cardStyles`) for visual parity.
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
+1093
View File
File diff suppressed because it is too large Load Diff
+1235
View File
File diff suppressed because it is too large Load Diff
+19 -4
View File
@@ -49,11 +49,26 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
## Deployment to the dacha (ha.jbstudio.pro)
- 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/`
- SSH: port **22222**, root, key `ha_jb` (lives in the user folder `houseplan/.secrets/ha_jb`,
outside git; copy into the sandbox with chmod 600 — only ask the user if it is gone).
- **The HA config root is `/mnt/data/supervisor/homeassistant`** — in this SSH
environment `/config` does not exist; a deploy aimed at `/config/...` fails
with "No such file or directory".
- JS: `scp -P 22222 -i <key> dist/houseplan-card.js root@ha.jbstudio.pro:/mnt/data/supervisor/homeassistant/custom_components/houseplan/frontend/`
- Cache busting: `sed` the `?v=` version in `.storage/lovelace_resources`, then restart HA.
- **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).
+61
View File
@@ -0,0 +1,61 @@
# Filtering: the explicit "hide from plan" flag
Agreed with the owner 2026-07-29. This document is the source of truth for the
mechanism; the code follows it.
## Principle
Whether a device is on the plan is an EXPLICIT, per-device fact: the
"Hide from plan" checkbox, stored as `marker.hidden`. The old on-the-fly
filtering algorithm survives only as the SEEDER of those flags — it decides
the initial value once, and the user owns the flag from then on.
## Data model
- `marker.hidden: true` — hidden from the plan. For an auto device without a
marker, hiding creates a stub marker (this mechanism predates this spec).
- `marker.hidden: false` (marker present) — explicitly VISIBLE: the seeder
never touches a device that has any marker, so unhiding must KEEP the stub
marker. That is the re-seed protection.
- No marker — never evaluated by the seeder yet, or a plain physical device.
- `settings.filter_seeded: true` — this config has been materialised.
- `settings.show_all` — removed (deleted during materialisation). The old
toggle was shared config state; the new one is a local editor tool.
## Seeding
"Non-physical" = the old filter rules: excluded integration domains, model
"Group", scene-like models, bridges, myheat children, and individual lamp
devices in an area covered by a light group (when group folding is on).
The seeder runs on the editing client (write permission required) whenever
devices rebuild, and creates `hidden: true` stub markers for non-physical
devices in BOUND areas that have NO marker. It is idempotent: marked devices
are never revisited. It fires on:
1. first load of a config without `filter_seeded` (materialises the current
behaviour; nothing changes visually, the flags become real and editable);
2. an area newly bound to the plan;
3. a new device appearing in a bound area — non-physical ones are hidden
silently (no red dot); physical ones keep the red-dot flow.
Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
LEGACY runtime filter, so a read-only client on an old config sees exactly
the old behaviour until an editing client materialises it.
## Behaviour
- Hidden devices ARE built (flagged `hidden`), but not rendered in any mode,
except the device editor with "Show hidden devices" on — there they render
ghosted (translucent, dashed) and clicking opens the dialog to untick.
- "Show hidden devices" (rename of "Show all") is LOCAL, ephemeral state of
the current tab.
- Room LQI counts hidden devices (owner's decision).
- Light fill and glow do NOT count hidden devices — an invisible device casts
no visible light (owner's decision). Room climate is registry-wide and
unaffected, as before.
- The checkbox appears in the dialog of EVERY device kind, virtual included.
- "Remove from plan" disappears for auto/entity devices (the checkbox is the
one way to hide); a virtual device's "Delete" remains a real deletion.
- Duplicate names are still numbered, light groups still fold — those are
aggregation, not hiding.
+1 -1
View File
@@ -9,7 +9,7 @@ An interactive floor plan for Home Assistant delivered as one HACS package:
a storage **integration** (server-side config, WS API, file uploads, auth) + a
**Lovelace card** (rendering, room markup editor, drag layout, zoom, live states,
temperature, Zigbee LQI, device metadata with PDF manuals, virtual markers,
en/ru localization). GUI-first: no YAML, no hand-made SVG.
live robot vacuums, en/ru localization). GUI-first: no YAML, no hand-made SVG.
## Competitive landscape (stars verified 2026-07-06)
+168
View File
@@ -0,0 +1,168 @@
# Room resize — the spec (source of truth)
Status: approved by the owner 2026-08-01. Dev-branch feature, **no
release**. Scope decisions final: a dedicated tool mode, wall-drag with
shared walls always moving together, a corner-scale frame for the
selected room, live numbers (wall lengths + room areas), grid snap,
Esc-cancel, one drag = one undo step.
## Principle
Until now room geometry could only be changed by split/merge or by
redrawing the outline — there was no vertex or wall dragging at all
(the `.rlhandle` corners belong to the room LABEL card, not to the
room). Resize adds exactly two mechanisms, both living ONLY inside a
dedicated Plan-editor tool «Изменение размера комнат» (`_tool ===
'resize'`). In every other tool the plan looks and behaves exactly as
before — no handles, no new hit areas.
## Mechanism A — wall drag
- Every visible room shows a small handle at the midpoint of every
wall (handles for all rooms at once — owner's UX pick). The visible
glyph is a compact icon — a wall segment with two arrows pointing
perpendicular to it (the directions the wall drags), rotated to the
wall's orientation; accent ink over a `--hp-bg` halo. It is half the
size of the old circle, but the HIT area is an invisible circle of
the original finger-sized radius (derived from `view.w` like
`.vacfithandle`), so touch targets did not shrink. Cursor: `grab`,
`grabbing` while dragging.
- Dragging a handle moves the wall along its outward normal; **both
ends of the edge translate together** (the wall stays parallel to
itself; adjacent walls stretch/shrink). Works for any polygon
(L-shaped included) and for legacy `x/y/w/h` rectangles — those are
converted through `roomPoly` and are **saved back as `poly`**.
- The moved wall position snaps to the drawing grid (`snapToGrid`,
same pitch as the draw tool).
- **Handles own the hit test** (HP-1550-04): inside the resize tool
openings are not editable — their transparent hit area is inert
(`pointer-events: none`) and the resize layer renders above the
openings, so a door sitting exactly at the midpoint of a wall can
never shadow that wall's handle. A drag of such a wall carries the
door along through the normal anchor pipeline; clicking over a door
falls through to room picking. Every other Plan tool keeps the
openings interactive exactly as before.
## Shared walls — ALWAYS together
If a stretch of the dragged wall coincides with a neighbour's boundary
(collinear overlap with an epsilon, the `sharedBoundary` notion), the
coinciding stretches of the neighbour move synchronously: your room
grows — the neighbour shrinks. Gaps and overlaps cannot appear by
construction.
Partial contact (T-junctions): only the coinciding stretch of the
neighbour moves. Where the stretch ends inside a neighbour wall, new
vertices are inserted into the neighbour outline, which may legally
become L-shaped. All of this is shown as a live preview during the
drag. On commit collinear leftovers are simplified away
(`simplifyPoly`), so geometry stays clean.
## Stops (the wall stops dead)
1. **Minimum size** — neither the own room nor a shrinking neighbour
may get thinner than ~30 cm (`MIN_ROOM_CM`, expressed in canvas
units through `cell_cm`). The measure is orientation-independent
(HP-1550-02): for a wall drag it is the smallest perpendicular
distance from the moved stretch to any part of the room boundary
inside the band the stretch sweeps along its normal — vertices and
crossing walls count whether parallel or not (a triangle's apex, a
slanted obstacle), while collinear remainders and the step corners
a T-junction inserts at the very ends of the stretch do not. For
the scale frame it is the TRUE minimum width of the outline
(rotating calipers over the convex hull) scaled by `k` — the
axis-aligned bbox is never consulted, so rotation cannot hide the
short side. Rooms that are ALREADY thinner keep their clearance
(the drag may improve it, never worsen it).
2. **Self-intersection** — a wall never passes through the opposite
side; the outline must stay a simple polygon with its orientation
and a positive area.
3. **Foreign rooms** — a growing wall stops when it would overlap a
room that is not a shared-wall neighbour (`roomsOverlap`; touching
walls are legal, crossing is not).
4. **Island rooms** — islands inside the room (`islandsOf`) must stay
fully inside; a wall shrinking onto an island stops.
5. **Openings are anchors** — a door/window ON the moving stretch
travels with the wall (its `openings[].x/y` centre is shifted, the
angle is unchanged). A wall that carries openings cannot get too
short for them: every opening previously sitting on a wall of an
affected room must still fit fully on some wall afterwards — for
the own room AND for the neighbour.
## Mechanism B — the scale frame
- In the resize tool a click inside a room SELECTS it: a dashed
bounding frame with 4 corner handles appears.
- Dragging a corner scales ALL vertices proportionally (uniform
similarity) about the opposite bbox corner — the same maths family
as the vacuum fit panel (`reanchorFit`), only without rotation.
- The same stops apply (minimum size, foreign overlap, islands,
openings; self-intersection is impossible under a similarity).
- **The one exception to «shared walls always together»:** a scale
breaks collinear coincidence (walls move apart at an angle-preserving
ratio, not along a normal), so neighbours are NOT dragged along.
Growing into a neighbour simply stops the scale (the neighbour is a
wall to hit); shrinking away from a neighbour legally opens a gap.
- Openings exclusive to the scaled room follow the transform
(position scales, physical length does not); openings on a wall
shared with an unchanged neighbour stay with the neighbour's wall.
## Live numbers
While a handle is being dragged:
- length badges (`.measurelabel` style, `segmentCm`/`formatLength`,
metric or imperial per the HA unit system) on the dragged wall and
its two adjacent walls;
- the room area in m² (`polygonArea` × scale²) at the room centre,
live; when a shared wall is dragged — the areas of BOTH rooms
(owner picked «стены + площадь»);
- Esc cancels the current drag and puts the original geometry back;
- releasing the handle (pointerup) commits: one write through the
standard debounced `_saveConfig` path;
- `pointercancel` / `lostpointercapture` (the system interrupted the
stream: app switch, palm rejection) takes the CANCEL path, never the
commit path — snapshot geometry back, no undo step, no write
(HP-1550-03).
## Preview vs commit (HP-1550-01)
The live drag preview never touches the shared `_serverCfg`: it lives
in a separate overlay (`_rszPreview`) that `_curSpaceCfg`/`_renderCfg`
substitute into every render. Config writes are serialized and read
`_serverCfg` at the moment they run (HP-1454-03), so a debounced write
still queued from a previous edit can fire mid-drag — with the overlay
it carries only committed geometry. The overlay moves into the real
config exactly once, on pointerup; a cancel (Esc, pointercancel) just
drops the overlay, leaving nothing to restore and nothing to write.
## Undo
One operation (handle release that changed something) = one undo step.
The plan editor has no committed-operation undo stack (Ctrl+Z/Esc only
walk back draw/split points), so the resize tool keeps its own stack
of pre-drag snapshots (rooms + openings of the space, capped at 30)
and Ctrl+Z/⌘Z pops it while the tool is active.
## Out of scope / invariants
- Device positions are not touched; the room settings button (pole of
inaccessibility) recomputes itself from the new outline.
- Saving goes through the standard config path (`houseplan/config/set`
with `expected_rev`); backend validation already covers polygons
(`_GEOM` ±4, `MAX_POLY_POINTS` 500) — inserted neighbour vertices are
just polygon points, openings keep their schema, nothing new to
validate server-side.
- Touch: handles are finger-sized, use pointer capture and swallow
`pointerdown`, so the stage pan/pinch never fights a handle drag.
- The label-card corners (`.rlhandle`, `_rlResizeDown`) are untouched.
## Geometry home
All pure geometry lives in `src/resize.ts` (edge normals, edge move,
shared-span search and vertex insertion, all stops — including the
orientation-independent `minSpanClearance` band measure and the
`minPolyWidth` calipers width — the scale clamp, area formatting)
under node:test units in `test/resize.test.mjs`;
`src/houseplan-card.ts` only wires pointers, the preview overlay,
badges and undo.
+20 -12
View File
@@ -35,12 +35,12 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, filtering defaults
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
LQI thresholds, group_lights default.
- [ ] `repair-issues`: broken plan file references, orphaned layout entries, storage
migration failures → Repairs UI instead of silent logs.
- [ ] `system_health.py`: config rev, spaces/markers count, storage sizes.
- [x] `repair-issues`: broken plan references in Repairs (repairs.py, re-checked on
every config save); geometry/repair WS command for stranded migrations (v1.50.1).
- [x] `system_health.py` (v1.12.0).
- [ ] `exception-translations` + `icon-translations` where applicable.
- [ ] Frontend resource registration: adopt the community-consensus embedded-card pattern
end-to-end (we already do StaticPathConfig + storage-mode resource + `?v=` busting;
@@ -48,26 +48,34 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
## Phase 9 — Universality & flexibility (product depth)
- [ ] **Areas/floors registry integration**: import HA floors as spaces, suggest area
- [x] **Areas/floors registry integration**: floors-import wizard (v1.13.0); further: suggest area
bindings from the registry, sync names (HA is moving this way — native Areas/Home
dashboard; riding the registry is our moat).
- [ ] **Curation without hardcode**: icon rules (`iconFor`) become data — user-editable
- [x] **Filtering without hardcode**: icon rules became data (v1.13.0); the runtime
filter itself became explicit per-device hide flags in v1.51.0 (docs/FILTERING.md);
icon rules are user-editable
mapping (regex/domain/device_class → mdi icon) stored in config, shipping EN+RU
defaults; drop dacha-specific patterns from code.
- [ ] **Click actions** per device/domain: toggle / more-info / navigate / custom service
call (configurable, like standard card `tap_action`).
- [ ] **Theming**: respect light themes (currently dark-leaning), use HA theme variables
- [x] **Click actions** per device (v1.13.0, simplified v1.38.1): card / more-info /
toggle, with the lock/alarm safety model; running an automation/script/scene
with an optional confirmation landed in v1.53.0.
- [x] **Live robot vacuums** (docs/VACUUM.md): a puck driving the plan over a
solved affine transform, one-click calibration by room names or a
drag-and-stretch fit panel, server-recorded trails (current + previous run)
with never/cleaning/always display modes. Adapters: Xiaomi Cloud Map
Extractor, Tasshack dreame-vacuum, Valetudo. Display only — no commands.
- [x] **Theming**: light-theme pass done in v1.13.0; HA theme variables
everywhere, optional per-space background color.
- [ ] Multi-instance question: keep single-instance (one house) but support **multiple
cards** with different default spaces (already works) — document as a decision.
- [ ] Plan formats: keep SVG/PNG/JPG/WebP; add max dimensions guidance; optional
auto-downscale on upload.
- [ ] More locales: extract i18n dictionaries to JSON so contributors can add languages
without touching TS.
- [x] More locales: i18n dictionaries are JSON since v1.13.0 (src/i18n/*.json).
## Phase 10 — Community & distribution
- [ ] hacs/default PR #8995 through moderation (expect drafting for fixes).
- [ ] hacs/default PR **#9004** through moderation (#8995 was bot-closed for a
non-template body; #9004 is queued with the label since 2026-07-22).
- [ ] Demo GIF/video for README (the single biggest driver of adoption for dashboard cards).
- [ ] Forum post in the Floorplan category + Reddit r/homeassistant showcase once
the demo assets exist.
+28 -2
View File
@@ -33,8 +33,8 @@ Editors are admin-only tools and must never leak interactions into View
|---|---|---|
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action (explicit button only, never a plan tap) | **Closed** |
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action | **Closed** |
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, filtered auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
@@ -52,9 +52,32 @@ Editors are admin-only tools and must never leak interactions into View
## Known gaps that fit the mission (build only on owner's request)
- Person/presence shown in rooms (classic floorplan ask; pure J1).
### The lock invariant, stated precisely (review CR-1)
No lock or alarm panel is ever actuated **by a tap on the plan**: icons, lock
badges, `marker.controls[]` and the device card all refuse (`resolveTapAction`
+ `TOGGLE_FORBIDDEN_DOMAINS`, `isControllable`, `_cardToggle`). There is exactly
**one** sanctioned actuation surface: the labeled Unlock/Lock button inside an
opened door card, which additionally confirms before unlocking. That is a
product decision (2026-07-22), not an oversight — but it means the invariant is
"never by accident", not "never at all". Any new actuation path must either
refuse locks or be added to this paragraph.
- Plan-level "security glance": one badge for "all locked / N open" (J2).
- Threshold colouring for room-card metrics (J5).
## Standing rule: never delete a user's file on an inference
Fixed with the owner on 2026-07-28, after automatic collection removed two
detached floor plans. The component may delete a file only when the user's
action says so — replacing a plan, removing an attachment, deleting a device.
"Nothing points at this any more" is not such an action: detaching a plan is one
click and reversible, and the editor tells the user the file stays.
The asymmetry is the whole argument. Wasted disk is visible, cheap and
reversible; a deleted file is none of those. Where the evidence is weak, keep
the file — and if a future version wants to reclaim that space, it asks.
## Out of scope — never build, point users to the right tool
- Automations, scenes, scripts, notifications → HA core.
@@ -91,6 +114,9 @@ Editors are admin-only tools and must never leak interactions into View
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
health · zero-to-plan GUI onboarding · keeping the plan true over years.
**Where users are:** Telegram chat https://t.me/ha_houseplan (support, feature
signals, screenshots) — treat it as the primary source of field feedback.
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
dashboards that hide *where* things happen · silent device sprawl · accidental
toggles of security devices · per-device dashboards that non-technical family
+38 -12
View File
@@ -5,22 +5,27 @@
> 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-08-02)
| Item | State |
|---|---|
| Version | **v1.41.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.55.3** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Closes the v1.55.2 audit findings AUD-1552-01/02 (boot-veil lifecycle: reconnect restart + full protective window with trailing quiescence + post-reveal soft grace; regressions in `demo/smoke_preloader_lifecycle.mjs`) |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). 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) |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.3** 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 | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
| Demo stand | **https://demo.houseplan.tech** — public, login `demo`/`demo`, resets to a pristine synthetic home every hour. **https://dev.houseplan.tech** — closed (basic auth), auto-deploys the `dev` branch every 10 min. Host: `ssh -i ~/.ssh/hp_stand hp@135.106.166.146`; layout, seeds and gotchas in the memory note `houseplan-demo-stand`. Since 2026-07-31 the stand covers most of the manual checklist: a scripted robot vacuum (`demo/stand/demo_robot` — Tasshack-shaped map sensor, serpentine run, pre-solved calibration, seeded server trail), Zigbee-style LQI template sensors, hand/auto-triggered leak+smoke alarms, an hvac_action climate marker and working script/scene/automation targets for tap-run. The stand-specific how-to-check guide is **docs/TESTING-DEMO.md** |
| 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 |
## Current feature surface (since the 2026-07-17 snapshot)
@@ -45,6 +50,24 @@
- **Dialog UX**: binding radios + entities checkbox + search dropdown
(v1.38.0); tap actions simplified to Device card / more-info / Toggle,
right-click → more-info (v1.38.1); Esc closes every dialog (v1.30.4).
- **Room settings, tier 3** (v1.42.0): per-room fill/temp-source/label sizes;
the settings button sits at the room's VISUAL centre (inscribed circle +
centroid pull), icon-derived size, zooms with the plan (v1.51.0).
- **Files & plans** (v1.44–v1.50): signed content urls with sandbox CSP,
copy-on-write plan files, "already uploaded" picker + explicit delete
(v1.47.0), store quotas instead of any age-based deletion (v1.49.0),
nothing is ever deleted on an inference (docs/SCOPE.md rule).
- **Square canvas** (v1.48.0) with a crash-safe two-store migration
(geom_pending, v1.50.0) and an explicit geometry/repair command (v1.50.1);
content-fit default zoom with devices as content, zoom out to 0.4×,
measured stage height (v1.49–v1.50.2).
- **Explicit hide flags** (v1.51.0, docs/FILTERING.md): per-device
"Hide from plan" checkbox seeded once from the old filter; local
"Show hidden" ghosts; hidden counts toward room LQI on both cards, casts
no light (v1.51.1).
- **Yellow = working right now** (v1.51.0): climate by hvac_action, service
switches can no longer become primary, glow pool and icon share one
condition. Editor gestures on touch (pinch/pan) landed the same release.
## Recent milestones (details in CHANGELOG.md)
@@ -126,8 +149,9 @@
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
repository' label). Minor issues ⇒ the bot drafts the PR (fix and re-ready).
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
2. GitHub auth: fine-grained PAT (Contents R/W, issued 2026-07-23) in the sandbox
`~/.git-credentials`; pushes go over SSH with the `ha_jb` key. The old classic PAT
expired and is gone.
3. Privacy: legacy real-house plan sources (`assets/`) removed from the tree in
v1.13.3, but they persist in git history and old release archives; 8 README
screenshots in docs/images are still from the real house (replacement with
@@ -147,12 +171,14 @@
2. Restore the repo: `git clone <user-folder>/houseplan-card.git.bundle hpcN` in `/tmp`
(files from *previous* sandbox sessions in `/tmp` belong to `nobody` and are unreadable —
always clone into a fresh directory; `npm ci` again).
3. Deployment needs the `ha_jb` SSH key — ask the user to upload it (uploads are readable
only in the session they were uploaded in).
3. Deployment needs the `ha_jb` SSH key — it lives in the user folder at
`houseplan/.secrets/ha_jb` (outside git) and often survives in the sandbox home
`~/.ssh/ha_jb`; copy with chmod 600. Only ask the user if both are gone.
4. Build only in `/tmp` (never on the mount), `npm run build` (starts with `tsc --noEmit`),
md5-verify after every deploy, restart HA via
`nohup ha core restart >/dev/null 2>&1 </dev/null &` (otherwise the SSH session hangs).
5. GitHub pushes need a PAT (create via the user's Chrome: settings/tokens, repo+workflow scope).
5. GitHub pushes: SSH remote with the `ha_jb` key; API releases with the fine-grained
PAT from `~/.git-credentials` (see the watchlist).
## Product scope
+366
View File
@@ -0,0 +1,366 @@
# Чек-лист ручного тестирования на публичном демо-стенде
> **Стенд:** https://demo.houseplan.tech — вход **demo / demo** (админ).
> Dev-версия: https://dev.houseplan.tech (basic auth `hp` / `houseplan-dev-2026`, дальше тот же demo/demo).
>
> **Стенд сбрасывается каждый час** к эталонному состоянию — это фича: ломайте
> смело, удаляйте комнаты, заливайте планы, через час всё вернётся. Из этого же
> следует: долгоживущие проверки («файл исчез через сутки», «пережило рестарт»)
> на стенде не живут.
>
> Источник истины — [TESTING.md](TESTING.md): формулировки пунктов и их
> `[auto:…]`-маркеры смотреть там. Здесь каждый пункт сокращён до сути и дополнен
> строкой «Стенд: …» — где и как ткнуть именно на демо-стенде.
>
> **Демо-дом:** дашборд «House plan» (виды: План / Киоск / Схема), пространства
> «1 этаж» (Гостиная, Кухня, Спальня, Кабинет, Прихожая) и «Двор». Ключевые
> демо-устройства: робот-пылесос (Прихожая, симулятор `demo_robot`), датчик
> протечки (Кухня, включается помощником `input_boolean.demo_leak`), датчик дыма
> (Спальня, сам срабатывает каждые 10 минут на 30 с), «Тёплый пол» (Кабинет,
> climate.heatpump, hvac_action=heating), 4 «Zigbee»-термометра + дверной датчик
> с атрибутом `linkquality` (отличный/средний/слабый сигнал по комнатам),
> маркер «Открыть шторы» (tap → запуск скрипта с подтверждением).
## Вводные пункты (v1.43.x–v1.44.x)
- Smoke-harness сам по себе — Стенд: не проверяется (это CI), только локально.
- Шестерёнка «⚙ Room» на каждой карточке комнаты в редакторе плана — Стенд: открыть «Редактор плана», убедиться что у всех 5 комнат есть кнопка ⚙ фиксированного размера, открывает настройки комнаты.
- Метрики 0.75 от имени комнаты — Стенд: включить метрики в настройках пространства (⚙ у «1 этаж») и посмотреть подпись под именем комнаты.
- Тултип не появляется после touch — Стенд: открыть с планшета/телефона, потыкать комнаты и иконки — hover-тултипов быть не должно.
- Флаг «источник света» у выключателя — Стенд: у «Decorative Lights» (Гостиная) в диалоге устройства включить «Это источник света» — при включении розетка светится в glow-заливке.
- Карточка устройства: управляемые сущности сверху, ≥30 px, замки не переключаются — Стенд: тапнуть «Котёл» или медиаплеер; для замка — открыть карточку двери (бейдж замка на двери в Прихожей).
- Инвариант замков на всех путях — Стенд: дверь в Прихожей: Unlock просит подтверждение, Lock — нет; иконка замка никогда не тогглится тапом.
- Транзакционная миграция вложений — Стенд: прикрепить PDF к устройству, перепривязать маркер на другую сущность — файл открывается после перепривязки.
- Планы и PDF в реальном браузере — Стенд: залить план в настройках пространства (любой PNG/SVG), DevTools → Network: `/api/houseplan/content/...` = 200 с `authSig`, тот же URL без authSig = 401.
- Единая политика прав (may_write) — Стенд: не проверяется напрямую (нужен не-админ; на стенде один пользователь demo-админ) — покрыто tests_backend.
- NaN/Infinity и лимиты openings — Стенд: не проверяется руками (нужен ручной WS-вызов) — tests_backend.
- Захват указателя при drag, декор не утаскивается за границы — Стенд: «Редактор фона», нарисовать фигуру и попытаться утащить её далеко за план.
- Скрытые термометры питают карточку комнаты — Стенд: скрыть «Термометр в гостиной» (галка «скрыть с плана») — температура в карточке Гостиной остаётся.
- Тултип комнаты без «открыть area» — Стенд: навести на комнату с десктопа: имя + температура/сигнал, никакого «открыть».
## Матрица окружений ★
- Chrome/Edge desktop — Стенд: да, просто открыть.
- Firefox desktop — Стенд: да, со своей машины.
- Safari (macOS) — Стенд: да, при наличии Mac.
- HA Companion Android (холодный старт!) — Стенд: да: добавить `https://demo.houseplan.tech` как сервер в приложении (demo/demo), убить приложение, открыть заново на дашборде House plan.
- HA Companion iOS — Стенд: аналогично Android.
- Планшет-киоск — Стенд: частично: открыть вид «Киоск» браузером планшета во весь экран; реальный настенный планшет — только на железе.
- Телефон портрет ≤400 px — Стенд: да, или DevTools device mode.
- Тёмная/светлая тема — Стенд: профиль demo → тема; проверить бейджи/диалоги/контраст.
- RU/EN локали — Стенд: сменить язык в профиле пользователя demo; опцию `language:` карточки можно проверить, отредактировав вид дашборда.
## Установка / обновление / удаление
- Свежая установка через HACS — Стенд: не проверяется (интеграция ставится файлами, HACS нет) — локально.
- `single_config_entry` — Стенд: Настройки → Устройства и службы → Добавить интеграцию → «House Plan» недоступна второй раз.
- Обновление через HACS (`?v=` после рестарта) — Стенд: не проверяется (нет HACS; версию ресурса видно в DevTools: `houseplan-card.js?v=1.54.0`).
- YAML-mode Lovelace / extra_module_url — Стенд: частично: стенд как раз грузит карточку через `extra_module_url` (см. configuration.yaml) — если дашборд открылся, механизм жив.
- Удаление entry → ресурс исчезает, `.storage/houseplan.*` живёт — Стенд: можно (Настройки → Интеграции → House Plan → удалить), стенд сам восстановится при сбросе; переустановку файлами проверить нельзя.
- Diagnostics download + REDACTED — Стенд: Настройки → Интеграции → House Plan → «Загрузить диагностику», убедиться что name/link/description/pdfs = `**REDACTED**`.
## Режимы ★
- Всегда открывается во View — Стенд: войти в редактор, перезагрузить страницу — карточка снова во View.
- View: только pan/zoom/tap/long-press, иконки не таскаются — Стенд: попробовать утащить любую иконку в режиме просмотра — должен идти pan.
- Шапка View: табы пространств, счётчик, zoom, табы редакторов, ⚙ — Стенд: глазами в шапке «1 этаж | Двор».
- Plan: тулбар разметки, иконки скрыты, оранжевая рамка — Стенд: вкладка «Редактор плана».
- Devices: drag иконок, клик открывает редактор маркера — Стенд: вкладка «Редактор устройств».
- Табы скрыты для не-админа — Стенд: не проверяется (второго, не-админского, пользователя на стенде нет).
- Открывания во View инертны (дверь/окно — чистый рисунок) — Стенд: во View потыкать дверь в Прихожей мимо бейджа замка — ничего не происходит.
- Бейдж замка — единственное исключение — Стенд: клик по замочку на двери открывает карточку двери; в редакторе плана он инертен.
- Курсор pointer во View, grab в Devices — Стенд: сравнить курсор над иконкой в двух режимах.
- В Plan открывание интерактивно (drag по стенам, 3 px порог) — Стенд: редактор плана → потаскать дверь вдоль стены, одиночный тап открывает свойства.
## Онбординг ★
Все пункты онбординга требуют ПУСТОЙ конфиг. На стенде: Настройки → Устройства и службы → House Plan можно удалить и добавить интеграцию заново, либо стереть все пространства руками — и пройти онбординг; сброс через час всё вернёт.
- Мастер импорта этажей — Стенд: у демо-дома этажи HA не заведены, мастер предложит «Start from scratch»; полный сценарий с этажами — локально (или создать этажи в Настройки → Зоны и этажи, затем удалить пространства карточки).
- Остальные пункты мастера (снять галки → Create disabled, очередь диалогов, Skip/Cancel, авто-разметка с тостом, пустое состояние) — Стенд: в рамках того же прогона после удаления пространств.
## Пространства ★
- Создание с картинкой (SVG/PNG/JPG/WebP), чёткость на зуме — Стенд: «+» в шапке → загрузить свой файл (демо-планы нарисованы без подложки — тем интереснее добавить).
- >8 MB → читаемый тост — Стенд: залить большой файл.
- «Без изображения — нарисую сам», ориентация — Стенд: «+» → режим рисования.
- Белый холст в draw-режиме — Стенд: оба демо-пространства такие: борта/имена читаемы на белом.
- Rename / замена картинки / image→draw отвязывает план — Стенд: ⚙ пространства.
- Удаление пространства — Стенд: удалить «Двор», убедиться что «1 этаж» не тронут; сброс вернёт.
- Настройки отображения: борта, имена, цвет+прозрачность, заливки — Стенд: ⚙ «1 этаж» — там всё живое, предпросмотр после сохранения.
- Заливка «zigbee»: красный→зелёный по среднему LQI — Стенд: ⚙ «1 этаж» → заливка «Сигнал Zigbee»: Гостиная зелёная (LQI ~206), Кабинет красный (~24), Двор не залит (нет zigbee).
- Заливка «lights» — Стенд: включить, зажечь свет в комнате с плана — комната жёлтая, потушить — серая.
- Заливка «temperature» + границы комфорта — Стенд: включить: Спальня синяя (~18.7°), Кухня жёлтая (~26°), Гостиная зелёная (~22.8°); границы 20–25 редактируются тут же.
- Радиогруппа заливок без легенды — Стенд: глазами в ⚙ пространства.
- Hover затемняет текущую заливку — Стенд: навести на комнату при любой заливке.
- Тултип комнаты со средней температурой — Стенд: навести на Кухню — «≈26°».
- Средняя температура только от термометров — Стенд: рядом с «Тёплым полом» (Кабинет) убедиться, что его current_temperature не ломает среднее (в Кабинете свой термометр).
- Ширина диалога 500 px, компактные поля — Стенд: глазами в ⚙ пространства.
- Общие настройки (⚙ в шапке): цвета по режимам + Reset — Стенд: покрутить цвета glow/temp/lqi, Reset.
- Пользовательские цвета и на статичной карточке — Стенд: сменить цвет заливки, открыть вид «Схема».
- Градиент LQI между настроенными цветами — Стенд: сменить цвета weak/strong в ⚙ и посмотреть заливку zigbee.
- Per-space «Показывать LQI» — Стенд: выключить в ⚙ «1 этаж» — бейджи цифр у термометров и строка сигнала в тултипах пропадают только там.
- Центровка бейджа и глифа — Стенд: глазами на любой иконке при максимальном зуме.
- Hover-подсветка при кастомных бортах — Стенд: покрасить борта и навести.
- Настройки живут на сервере — Стенд: сменить цвет, открыть стенд в приватном окне/другом браузере — цвет тот же.
## Редактор разметки комнат ★
Всё проверяется в «Редакторе плана» на «1 этаже» (или создайте своё пространство — сброс простит всё).
- Сетка, снап, общие стены — Стенд: дорисовать комнату впритык к существующей.
- Линейка сегмента (метры; cm/cell из настроек) — Стенд: начать рисовать контур, смотреть на бегущую длину.
- Незакрытый контур не сохраняется — Стенд: начать контур, уйти из разметки, вернуться — линий нет.
- Удаление комнаты бережёт общие стены — Стенд: удалить Прихожую (⚙ комнаты → удалить), стены Кабинета/Спальни на месте; сброс вернёт.
- Нет инструмента «Erase» в тулбаре — Стенд: глазами.
- Пересечения запрещены (клик внутри чужой комнаты → тост) — Стенд: попытаться начать контур внутри Гостиной.
- Контур вокруг существующей комнаты отклоняется — Стенд: обвести Кабинет снаружи.
- Merge двух смежных / отказ для несмежных — Стенд: слить Кабинет+Прихожую, тост при попытке слить Кабинет+Кухню; сброс вернёт.
- Split: две точки на стенах, бОльшая часть сохраняет имя — Стенд: разрезать Гостиную; Cancel в диалоге — комната цела.
- Split с точкой вне стены / вдоль стены → тост — Стенд: там же.
- Split на не-грид комнатах (снап к стене) — Стенд: комнаты демо на гриде; полноценно — локально, но снап виден: клик чуть мимо стены цепляется.
- Промах далеко от стены → тост — Стенд: кликнуть в центр комнаты.
- Esc/Ctrl+Z снимает точку, Reset чистит — Стенд: во время рисования.
- Замыкание (≥4 точек) → диалог комнаты — Стенд: нарисовать комнату во «Дворе».
- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: все 6 зон заняты — список пуст: создать комнату «без зоны».
- «Без зоны» требует имя, `area: null` — Стенд: там же.
- Cancel диалога возвращает контур — Стенд: там же.
- Сохранение с зоной раскладывает устройства зоны — Стенд: не проверяется как есть (у demo-сущностей нет device registry — автораскладка по зонам не работает, маркеры на стенде явные); полноценно — локально/на даче.
- Erase удаляет линию, удаление комнаты — полигон — Стенд: инструментом «ластик линий» в тулбаре (клик по своей линии).
- Иконки скрыты в разметке — Стенд: глазами.
## Устройства на плане ★
- Авто-устройства только в комнатах своей зоны — Стенд: не проверяется (см. выше: без device registry автонаполнение молчит; все маркеры стенда — явные entity-маркеры).
- Фильтрация мостов/групп/сцен, 👁 «показать все» — Стенд: «Редактор устройств» → 👁: появляются скрытые сущности (сцены/скрипты в списке добавления отфильтрованы).
- Нумерация дубликатов имён — Стенд: добавить второй маркер той же сущности не даст (занята) — проверяется локально.
- Группы света сворачивают лампы — Стенд: не проверяется (в демо нет light-групп с device registry).
- Drag иконок, снап к сетке, позиция per-space — Стенд: редактор устройств: перетащить иконку, F5 — позиция жива; у «Двора» своя раскладка.
- ↺ сброс автолейаута — Стенд: не проверяется: кнопка Reset убрана в v1.33.2 (пункт в TESTING.md исторический).
- Бейдж температуры, LQI с цветом — Стенд: под «Zigbee»-термометрами цифра LQI (цвет от красного к зелёному: Кабинет красный, Гостиная зелёный), температура на бейдже.
- Живые состояния: свет жёлтый, открытая штора оранжевая, unavailable блёклый — Стенд: пощёлкать свет; открыть штору («Открыть шторы» или карточка); unavailable в демо нет — эмулировать нечем, но у робота после паузы всё живое.
- Морфинг иконок по состоянию — Стенд: дверь/замок: открыть lock.front_door из карточки двери — иконка меняется; гаражные ворота во «Дворе».
- «Значение вместо иконки» — Стенд: у термометра в диалоге выбрать display «значение» — на плане цифра.
- RGB только в glow/ripple — Стенд: включить RGBWW-лампу в Гостиной цветом: бейдж жёлтый стандартный, цвет только в пятне glow.
- Красная пульсация тревоги — Стенд: включить `input_boolean.demo_leak` (Настройки → Устройства и службы → Помощники) — датчик на Кухне пульсирует красным; дым в Спальне сам вспыхивает каждые 10 минут на 30 с.
- Стоимость рендера (geometry once) — Стенд: не проверяется руками — smoke_render_perf.
- Тап vs drag открывания — Стенд: редактор плана, тап по двери = свойства, drag на место = ничего не записано.
- Вогнутый остров — Стенд: нарисовать U-комнату во «Дворе» и остров внутри.
- Backend hardening (B2–B5) — Стенд: не проверяется руками — unit/backend.
- Гонка сохранений (markup vs Save) — Стенд: два окна: в одном правка разметки, в другом Save диалога — правка выживает; остальное — тесты.
- Niche split — Стенд: разрез, начинающийся и кончающийся на одной стене — вырезается ниша.
- Контент только через подписанные URL — Стенд: залить план, скопировать URL картинки из DevTools, убрать authSig → 401; старые пути `/houseplan_files/plans` → 404.
- Каждая опция редактора сохраняема — Стенд: перебрать display/tap/fill в диалогах — ошибок валидации быть не должно.
- Кнопка настроек комнаты в геометрическом центре — Стенд: редактор плана, глазами.
- Один индикатор всегда (лампа жёлтая в plan-редакторе glow-пространства) — Стенд: включить свет, открыть редактор плана — бейдж жёлтый (glow-слоя там нет).
- Size/angle parity — Стенд: задать маркеру size 3 / angle 37 в диалоге → и на «Схеме» тоже ×3 и повёрнут.
- Tap запускает автоматизацию — Стенд: тап по «Открыть шторы» (Гостиная) → диалог подтверждения → штора едет; в диалоге маркера видно «Run» + поиск по automation/script/scene (есть скрипт «Открыть шторы», автоматизация «Вечерний сценарий», сцены «Кино», «Вечер в гостиной»); Esc = не выполнять.
- Бейджи light-source в glow — Стенд: заливка glow (по умолчанию): у горящей лампы бейдж стандартный (индикатор — пятно), у розетки-«источника света» жёлтый.
- Множитель размера иконки растит глиф — Стенд: size 3 у любого маркера.
- Auto-grid parity, ripple ghost, hidden LQI parity, ghost без цифр — Стенд: скрыть «Zigbee»-термометр: LQI комнаты не меняется, «Схема» совпадает с планом; «Show hidden» в редакторе устройств показывает синий пунктирный призрак без значений.
- Флаг «скрыть с плана» — Стенд: галка в любом диалоге устройства; скрытый исчезает из всех режимов и счётчика, но LQI комнаты держит.
- «Жёлтый = работает» — Стенд: «Тёплый пол» в Кабинете: hvac_action=heating → жёлтая подложка. (Выключить нагрев можно, поставив target ниже 20° в карточке — бейдж гаснет.)
- Жесты редактора на touch — Стенд: с телефона в редакторе плана: pinch = zoom, палец = pan, отпускание не ставит точку.
- Legacy-геометрия, положительные размеры, границы 1e100, no-op repair — Стенд: не проверяется (нужен подложенный битый store, у тестировщика нет доступа к .storage) — unit/backend.
- Карточка под другим контентом дашборда — Стенд: вид «Схема» — карточка стоит после других карточек и не схлопывается.
- Stranded migration repair / geometry repair — Стенд: не проверяется руками (WS-команда) — backend.
- Редакторы видят весь холст — Стенд: «Двор» (одна комната): в редакторе плана виден весь белый квадрат, во View — контент-фит.
- Save ждёт пропорции выбранного плана — Стенд: прикрепить «уже загруженный» план и мгновенно Save.
- Зум ниже фита (пол 0.4x) — Стенд: минусовать зум ниже 100%.
- Migration crash / parallel upload quota — Стенд: не проверяется (kill HA между записями невозможен) — backend.
- Зум открывается на контенте — Стенд: «Двор» открывается комнатой на весь экран.
- Удаление используемого плана запрещено — Стенд: залить план, в списке «уже загруженные» его delete неактивен.
- Квота загрузок — Стенд: можно доливать планы до квоты — тост; сброс почистит.
- Миграция на квадратный холст — Стенд: не проверяется (миграция уже прошла; нужен старый store) — unit.
- Картинка плана по центру с полями — Стенд: залить широкую картинку — поля сверху/снизу, без растяжения.
- Re-attach отвязанного плана — Стенд: ⚙ пространства → «рисование» → Save → reload → «Уже загруженные» → прикрепить обратно.
- Detach хранит файл — Стенд: там же: после отвязки файл в списке; замена плана убирает старый сразу.
- Перепривязка не ест мануалы — Стенд: маркер с 2 PDF перепривязать на другую сущность — оба открываются.
- Ничего не копится на idle — Стенд: не проверяется полноценно (ежечасный сброс раньше суточного sweep) — backend.
- Drag выигрывает у удалённого move — Стенд: два окна, тащить разные иконки одновременно.
- Одноимённые загрузки из двух вкладок — Стенд: можно, два вложения обязаны выжить; тонкости — unit.
- Временные файлы не выживают — Стенд: не проверяется (нужен доступ к ФС) — backend.
- Две полные карточки согласны в позициях — Стенд: два окна: drag в одном виден во втором без reload.
- Загруженный SVG инертен (script не исполняется) — Стенд: залить SVG со `<script>alert(1)</script>`, открыть его подписанный URL в новой вкладке — alert нет, план рендерится.
- Вложение не перезаписывает другое — Стенд: приложить manual.pdf к двум маркерам — два независимых файла.
- Два быстрых эдита выживают — Стенд: быстро сделать две правки подряд (throttle сети в DevTools) — обе на месте.
- Открытые границы следуют геометрии — Стенд: открыть границу (инструмент «Открыть границу») между Гостиной и Кухней, потом утащить вершину стены — разрыв едет вместе.
- Внутренние лимиты (max+1) — Стенд: не проверяется руками — unit/backend.
- Большие файлы стримятся (50 MB) — Стенд: можно залить 50 MB мануал и скачать дважды; память HA снаружи не видна — полноценно только локально.
- Паритет статичной карточки (fill none) — Стенд: у комнаты поставить fill «нет» и сравнить со «Схемой».
- Layout доходит до статичной карточки — Стенд: drag иконки на «Плане» → на «Схеме» (другая вкладка того же дашборда) двигается.
- Repair-issue смертны — Стенд: удалить пространство с битым планом… проще: удалить пространство — связанные предупреждения из Настройки → Ремонт исчезают.
- Не-подписанный путь не зацикливает запросы — Стенд: не проверяется руками — unit.
- Подписи не усиливаются на плохой сети — Стенд: DevTools throttling + Network: один sign-запрос на URL.
- Битая папка планов не валит save — Стенд: не проверяется (нужна ФС) — backend.
- Два редактора, один план — Стенд: две вкладки, залить подложку в каждой по очереди — служится последняя, файлы целы.
- Фон статичной карточки — Стенд: залить план «1 этажу», открыть «Схему»: картинка есть, в Network нет неподписанных запросов.
- Отклонённый save не трогает план — Стенд: две вкладки: обе меняют план, вторая получает конфликт — старый план жив.
- Кэш подписей на 200+ URL — Стенд: не воспроизводимо (нет 200 файлов) — smoke_sign_cap.
- Климат не дорожает с комнатами — Стенд: не проверяется руками — smoke_climate_once.
- Upload при конкурентной ревизии — Стенд: две вкладки: приложить фон при открытом втором редакторе.
- Подписанный фон плана — Стенд: см. выше: authSig в Network, нет failed login в журнале HA (Настройки → Система → Журналы).
- Зомби-диалоги — Стенд: Esc во время сохранения при throttling — диалог закрыт, тост об ошибке живёт.
- Нет hover-тултипов на touch — Стенд: с телефона.
- Слайдеры шрифтов (50–300%) — Стенд: ⚙ пространства + ⚙ комнаты: живой образец в диалоге следует слайдеру.
- Room settings tier 3 (имя, зона, fill override, источник T°/влажности) — Стенд: ⚙ любой комнаты; источник температуры прописан у комнат демо через зонные термометры — переопределить и посмотреть карточку.
- PDF переживает перепривязку — Стенд: см. «перепривязка не ест мануалы».
- Киоск-режим — Стенд: вид «Киоск» дашборда: шапки нет, свайп листает пространства с точками-индикатором, дабл-тап сброс зума, лонг-пресс 3 с на пустом месте — поповер размеров; авто-листание cycle — задано в конфиге вида; реальный планшет — руками на своём.
- Иконка-ссылка зоны у имени комнаты — Стенд: во View у имён комнат есть ↗ (зоны привязаны) — клик ведёт в зону HA; в редакторах иконки нет.
- Смарт-гайды — Стенд: тащить иконку в редакторе устройств — пунктирные направляющие от соседей, бейдж длина·угол.
- Свет тогглится по умолчанию — Стенд: тап по любой лампе во View = вкл/выкл без настройки.
- Производные стены тоже режутся (.seg) — Стенд: открыть границу и смотреть в редакторе плана: сплошной линии под пунктиром нет.
- Пунктир открытых границ в разметке — Стенд: там же.
- Nav-персистентность (пространство+режим) — Стенд: уйти в «Двор», закрыть вкладку, открыть — снова «Двор»; `#space=floor1` в URL сильнее.
- Чистка tap-действий + правый клик — Стенд: ПКМ по иконке во View = more-info HA.
- Редизайн секции биндинга — Стенд: «+» в редакторе устройств: радио Виртуальное/Из списка HA, «показать сущности», поиск в дропдауне.
- Настоящий пунктир открытой границы — Стенд: см. открытые границы.
- Hover открытия стены — Стенд: инструмент «Открыть границу»: у общей стены курсор pointer + янтарный пунктир-превью; на уже открытой — красное превью.
- Открытые границы: свет течёт по зоне — Стенд: открыть границу Гостиная-Кухня при glow: свет горящей лампы заливает обе.
- Секторы дверей не темнят комнату — Стенд: glow: открыть дверь Прихожей, смотреть сектор света.
- Per-source радиус glow — Стенд: поле «Радиус свечения» в диалоге лампы.
- Скрытая light-primary — Стенд: не проверяется (нет скрытых-в-реестре ламп в демо) — локально.
- Marker controls (тогл связки ламп) — Стенд: у «Котла»/виртуального маркера прописать «Управляет источниками света» + Toggle и тапнуть: обе лампы Гостиной разом.
- Glow fill в целом — Стенд: заливка по умолчанию на «1 этаже»: зажечь RGBWW-лампу цветом — цветное пятно, радиус в ⚙.
- Островные комнаты — Стенд: нарисовать остров во «Дворе».
- Иконка не прыгает при перепривязке — Стенд: перепривязать маркер: позиция та же; смена комнаты в том же пространстве тоже не двигает.
- Плейсхолдер автоиконки — Стенд: диалог без явной иконки: «Auto: mdi:…» с превью.
- Нет кнопки Reset в тулбаре Devices — Стенд: глазами: add / 👁 / ⬡.
- Сетка во всех редакторах + fade декора — Стенд: редактор фона: комнаты/иконки блёкнут до 35%, декор яркий.
- Редактор фона (фигуры/текст/ластик) — Стенд: порисовать во «Дворе»: прямоугольник-«газон», текст.
- Превью открывания (призрак 90 см) — Стенд: инструмент «Открывание», водить у стены.
- Split-polyline, курсоры, Esc-цепочка — Стенд: split Гостиной ломаной через середину.
- Подсветка выбранного в merge/split — Стенд: янтарная обводка первой комнаты.
- Карточка vs инструмент — Стенд: в редакторе плана тащить карточку комнаты при активном Draw — точек не ставится.
- Карточки комнат (метрики, drag, resize) — Стенд: включить метрики в ⚙ пространства; в редакторе плана карточку можно тащить и растягивать за углы.
- Esc закрывает диалоги по одному — Стенд: наоткрывать вложенных диалогов.
- Общая ⚙ видна во всех режимах — Стенд: глазами.
- Табы редакторов (2 шт + X) — Стенд: глазами.
- ⚙ пространства в каждом режиме — Стенд: глазами.
- Lock action (Unlock красный + подтверждение) — Стенд: карточка двери в Прихожей.
- Флаг нового устройства (красная точка) — Стенд: включить незанятую сущность… проще: не проверяется напрямую (набор сущностей демо фиксирован; точка была бы у новых) — локально.
- Ноль устройств в HA — Стенд: не проверяется (в демо 100+ сущностей) — smoke_new_device.
## Диалог устройства (маркеры) ★
- Все поля сохраняются — Стенд: любой маркер: имя/иконка/модель/ссылка/описание, F5.
- Перепривязка с поиском, занятые исключены — Стенд: дропдаун биндинга: уже размещённые сущности в списке отсутствуют.
- Виртуальное устройство: имя+комната, пунктир — Стенд: «Котёл» — образец; создать своё.
- Комнаты без зоны в списке комнат — Стенд: нарисовать комнату «без зоны» и поместить туда маркер.
- Room override центрирует — Стенд: сменить комнату маркера.
- Tap-action override — Стенд: перебрать все варианты у одного маркера.
- PDF: ok / >50 MB / .exe — Стенд: приложить файлы к маркеру; большие и .exe должны дать читаемую ошибку.
- `javascript:` в ссылке не кликабелен — Стенд: вписать `javascript:alert(1)` в поле ссылки — в карточке не ссылка.
- Remove: авто → скрытый маркер, виртуальное → насовсем — Стенд: удалить «Котёл» (вернётся сбросом) и любой entity-маркер.
## Правила иконок ★
- ⬡ открывает редактор с текущими правилами — Стенд: редактор устройств → ⬡.
- Тест-поле, добавление/удаление/порядок — Стенд: там же, ввести имя «Термометр…».
- Битый regex краснеет и пропускается — Стенд: вписать `([`.
- Reset к дефолтам — Стенд: там же.
- Правила переиконивают существующие; per-device иконка сильнее; замки всегда mdi:lock — Стенд: правило `.*свет.* → mdi:star` и смотреть план.
- Живут после reload и во втором браузере — Стенд: приватное окно.
## Tap-действия и жесты ★
- Тап → карточка, `toggle` тогглит только свет/розетки/вентиляторы — Стенд: пощёлкать лампы (toggle по умолчанию), медиаплеер (карточка).
- Замки не тогглятся никогда; шторы только с явным override — Стенд: замок двери; штора Гостиной: выставить «Toggle» в диалоге — начнёт тогглиться, гаражные ворота — нет.
- Long-press всегда карточка — Стенд: зажать лампу на 600 мс — карточка вместо toggle.
- Drag >3 px отменяет tap/long-press — Стенд: потащить палец с иконки.
- pointercancel без фантомов — Стенд: с телефона: начать тап и свернуть браузер.
## Zoom / pan / подписи
- Wheel у курсора, +/−, fit, % — Стенд: глазами.
- Pinch + двухпальцевый pan — Стенд: с телефона.
- Зум живёт per-space в localStorage — Стенд: зазумить «1 этаж», перейти в «Двор» и назад, F5.
- Resize окна перефитит — Стенд: свернуть боковую панель HA.
- Подписи комнат таскаются (rl_*) — Стенд: в редакторе плана утащить имя комнаты, F5.
- Читаемость подписей на мин/макс зуме — Стенд: глазами на белом плане.
## Мультиклиент ★
- Две вкладки: drag виден без reload — Стенд: два окна рядом.
- Конфликт конфига → тост, авторесинк, retry — Стенд: два окна: одновременно править настройки пространства.
- Точечные layout-апдейты не трутся — Стенд: тащить разные иконки в двух окнах.
- `admin_only` для не-админа — Стенд: не проверяется (нет второго пользователя) — backend.
## Крайние случаи
- HA без устройств/зон — Стенд: не проверяется (демо полон сущностей) — локально.
- Пространство без комнат — Стенд: создать пустое, видна подсказка разметки.
- Комната без зоны + борта — Стенд: нарисовать, кликнуть — ничего.
- Нет zigbee вовсе — Стенд: «Двор»: заливка lqi оставляет двор незалитым, бейджей нет.
- 100+ устройств в одном пространстве — Стенд: не проверяется (в «1 этаже» ~30 маркеров) — локальная синтетика.
- Длинные имена — Стенд: переименовать маркер в очень длинное имя.
- HTML/эмодзи в именах — Стенд: назвать маркер `<b>xss</b>🚿` — текст, не разметка.
- План удалён с диска → Repairs — Стенд: не проверяется (нет доступа к ФС) — backend.
- Битый houseplan.config — Стенд: не проверяется (нет доступа к .storage) — backend.
- Рестарт HA при открытом диалоге — Стенд: Настройки → Система → Перезапустить (админ может!), диалог держать открытым; после рестарта save даст чистую ошибку. Помнить: рестарт контейнера = сброс, но сам HA-рестарт изнутри сброс не триггерит.
- Legacy layout v1 — Стенд: не проверяется — unit.
- Киоск холодный старт в приложении — Стенд: Companion app + вид «Киоск», убить и открыть.
## Релизные регрессии
- Ноль ошибок консоли — Стенд: DevTools console на дашборде, в разметке, диалогах, зуме.
- Ноль houseplan-ошибок в логе HA — Стенд: Настройки → Система → Журналы, фильтр «houseplan».
- npm test / pytest / CI — Стенд: не про стенд — CI.
- Скриншоты README — Стенд: не про стенд.
## houseplan-space-card (вид «Схема»)
- Рендер идентичен плану, без шапки — Стенд: вид «Схема» дашборда.
- Полностью неинтерактивна — Стенд: покликать по «Схеме» — ничего, даже тултипов.
- Кнопка-футер открывает полную карточку на этом пространстве — Стенд: клик по кнопке под «Схемой».
- Несколько с разными space — Стенд: «Схема» содержит обе (1 этаж и Двор) — один общий WS-запрос (Network).
- Неизвестный space → аккуратная ошибка — Стенд: отредактировать вид, вписать space: nope.
- show_button: false — Стенд: правкой YAML вида.
- #space= диплинк — Стенд: открыть `…/plan#space=yard`.
## Presence ripples / per-device иконка
- «Только пульсация»: бейдж исчезает, кольца при on — Стенд: у датчика движения «Movement Backyard» (Кухня) поставить display «пульсация» — он периодически on/off сам.
- «Иконка + пульсация» — Стенд: там же.
- Цвет и размер ×2..×8 — Стенд: там же.
- unavailable останавливает пульс — Стенд: не проверяется (нет unavailable-сущностей в демо) — smoke.
- Размер ×0.5..×3 и поворот; бейджи масштабируются — Стенд: любой маркер.
- Риплы при выключенных live-состояниях — Стенд: выключить «живые состояния» в конфиге карточки (правка вида) — риплы живут.
- reduce motion — Стенд: включить в ОС тестера.
## Двери и окна
Дверь с замком (Прихожая, lock.front_door) и окно с датчиком (Кухня, contact = «Basement Floor Wet») уже стоят.
- Клик мимо стены → тост; у стены → диалог — Стенд: инструмент «Открывание» в разметке.
- Дверь: косяки+полотно+дуга, длина в см — Стенд: глазами у входной двери.
- Контакт: open → створка + дуга акцентом — Стенд: датчик окна Кухни переключается сам (это «мокрый пол» из демо — он мигает); либо повесить contact на `binary_sensor.demo_leak` и дёргать помощник.
- Unavailable → статичный дефолт — Стенд: не проверяется (нет unavailable) — локально.
- Замок: зелёный/оранжевый/серый бейдж — Стенд: замок двери, открыть/закрыть из карточки.
- Карточка с двумя состояниями, замок не тогглится с плана — Стенд: клик по двери/замку во View.
- Flip зеркалит петли/сторону — Стенд: диалог открывания (двойной клик с инструментом).
- Клик с инструментом → редактирование; Delete — Стенд: там же.
- Hover: акцент + grab во View — Стенд: навести на дверь.
- Drag вдоль стен с переснапом за углы — Стенд: тащить дверь по периметру Прихожей.
- Один клик = карточка, дабл = свойства, drag = ничего — Стенд: там же.
## Живые пылесосы (docs/VACUUM.md)
Симулятор: «Робот-пылесос» в Прихожей (база = маркер), карта `sensor.demo_robot_map`
в стиле Tasshack (позиция-словарь, комнаты с именами как на плане, mm, ось Y
перевёрнута), калибровка уже прописана. Полный цикл уборки ~3.5 минуты.
- Docked: только маркер базы — Стенд: глазами (робот в Прихожей).
- Уборка: круглая пульсирующая шайба едет, база на месте — Стенд: карточка робота → Start (или Настройки → Устройства и службы → Сущности → vacuum.demo_robot); шайба змейкой объезжает все 5 комнат.
- Глайд ~1.2 с, телепорт при zoom/pan/switch — Стенд: во время уборки позумить.
- Режимы следа never/cleaning/always — Стенд: диалог маркера робота → «Показывать путь робота»; в always виден след прошлой уборки (40%) — он уже записан в эталоне.
- Серверный след (две уборки, переживает reload) — Стенд: запустить уборку, F5 посреди — линия продолжается с места; вторая вкладка видит ту же линию.
- Стиль: тёмный ореол + светлое ядро — Стенд: глазами поверх glow-заливки.
- Скрытый маркер: ни шайбы, ни следа — Стенд: скрыть маркер робота на время уборки.
- Калибровка: «Настроить автоматически» — Стенд: диалог робота → «Живая позиция»: имена комнат карты совпадают с планом (все 5) — автокалибровка сходится; панель подгонки (призрак, углы, поворот, зеркало — зеркало осмысленно: ось Y в карте перевёрнута) тоже можно открыть и покрутить, Esc/Cancel не портит.
- Мультиэтаж (матрица на карту) — Стенд: частично: карта одна (map 1), матрица хранится по её id; мультикарта — только на реальном Dreame.
- Pause/Return — Стенд: кнопки в карточке робота: пауза замирает, «домой» возвращает по своим следам с двойной скоростью.
## Чего на стенде нет принципиально
- Реальное Zigbee/Z-Wave железо, реальные роботы (Dreame/Xiaomi/Valetudo), реальный настенный планшет.
- HACS-путь установки/обновления, YAML-mode Lovelace, переустановка интеграции с сохранением конфига.
- Всё, что требует доступа к файловой системе стенда: битые store, kill -9 между записями, недоступные папки, наблюдение за памятью процесса.
- Долгоживущие сценарии (суточный sweep, «часом позже») — сброс стенда наступает раньше.
- Не-админский пользователь (admin_only, скрытие табов) — на стенде один админ demo.
+377 -5
View File
@@ -20,6 +20,59 @@
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
- [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor
every room card carries a pill button "⚙ Room" of a FIXED readable size
(independent of the card font) — including rooms without a name; it opens
Room settings [auto: smoke_feedback_v2]
- [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name
(was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2]
- [ ] Touch tooltips, take two (v1.43.3): a hover tooltip never appears after
ANY touch/pen pointer event, even if the browser claims `hover: hover`
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
fixtures glows in the "Light sources" fill once "This device is a light
source" is ticked (its own entity or the lights bound under "Controls");
unticked devices without a light entity never glow [auto: smoke_glow]
- [ ] Device card controls (v1.44.0): the device card opens with its
controllable entities FIRST — toggles right there (≥30 px tap targets),
cover/lock/climate open HA more-info; model, links and manuals moved
below; config/diagnostic entities are not listed; locks never toggle from
the card [auto: smoke_card_controls]
- [ ] Lock invariant, all paths (v1.44.2, review CR-1): icon tap, controls[],
device card and _cardToggle refuse locks/alarm panels entirely; the door
card's Unlock asks for confirmation, Lock does not [auto: smoke_lock_invariant]
- [ ] Attachment migration is transactional (v1.44.2, review CR-2/CR-3):
rebinding COPIES files, saves the config, and only then deletes the old
folder; a rejected save leaves the old files and urls intact; a name
collision in the destination gets a unique name (the pre-existing file is
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 filtering 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:
@@ -142,7 +195,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Devices on the plan ★
- [ ] Auto devices appear only in rooms bound to their area [manual]
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
- [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
@@ -153,8 +206,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [manual]
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
as its body, small badges hidden; non-numeric fallback keeps the icon [manual]
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
(explicit ripple color still wins); off/white lights unchanged [manual]
- [ ] RGB lights (v1.27.0, contract changed in v1.52.0): a lamp's colour lives
in its glow spot and the ripple fallback ONLY — the icon/badge/border get
no RGB tint; explicit ripple color still wins; off lights unchanged
[auto: smoke_light_badges + smoke_rgb_alarm]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [manual]; reduced-motion static
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
@@ -181,6 +236,263 @@ 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]
- [ ] Every editor option is storable (v1.45.3, issue #3): set a sensor to
"value instead of an icon" and save — no validation error, the value shows
on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Room settings button (dev): detached from the (movable) name label —
always at the room's geometric centre, one button-height below it; sized
at 70% of a device icon and zooming WITH the plan; the small metric rows
under the room name now show in the plan editor too
[auto: smoke_room_cards gearDetached/plainInPlan]
- [ ] One indicator always (v1.52.1, HP-1520-01): in a glow space the plan
editor (no glow layer) shows the lit lamp yellow again; the devices
editor draws the layer and keeps the badge standard
[auto: smoke_light_badges]
- [ ] Size/angle parity (v1.52.1, HP-1513-01): a marker with size 3 / angle 37
scales x3 and rotates on BOTH cards [auto: smoke_size_angle_parity]
- [ ] Tap runs an automation (dev, owner's spec 2026-07-29): the tap-action
list has "Run automation/script/scene" with a searchable picker; saving
without a target is refused; the confirm checkbox guards toggle AND run
(our dialog, Esc/cancel = no call); automation.trigger / script.turn_on /
scene.turn_on per domain; a deleted target toasts and calls nothing;
covers/valves joined the card-wide toggle EXCEPT garage/door/gate
device classes (explicit per-device toggle still works for them)
[auto: smoke_tap_run + unit resolveTapAction/runServiceFor + backend
test_run_target_is_bounded_to_runnable_domains]
- [ ] Light-source badges (v1.52.0): in glow fill a lit lamp's badge stays
standard (the spot is the indicator) and a lit socket stays yellow; in
other fills a lit lamp is plain yellow with no RGB tint; morphing and
the ripple colour fallback survive [auto: smoke_light_badges +
smoke_rgb_alarm]
- [ ] Icon size multiplier scales the glyph (dev): set a marker's size to 3 —
the icon inside grows with the badge instead of staying default
[auto: smoke_icon_scale]
- [ ] Auto-grid parity (v1.51.2, HP-1511-01): with an empty layout, a visible
device among hidden ones sits at the same spot on both cards
[auto: smoke_hidden_flag autoGridParity]
- [ ] Ripple ghost (v1.51.2, HP-1511-02): a hidden ripple-display marker shows
its base icon, no pulse [auto: smoke_hidden_flag rippleGhost*]
- [ ] Hidden LQI parity (v1.51.1, HP-1510-01): a room whose only Zigbee
devices are hidden paints the same lqi fill on the full and the static
card [auto: smoke_hidden_flag lqiParity]
- [ ] Ghost shows no numbers (v1.51.1, HP-1510-02): a hidden value-display
device renders as a plain ghost — no value/temp/hum/LQI, no icon morph
[auto: smoke_hidden_flag ghostHidesValue]
- [ ] Hide-from-plan flag (dev, docs/FILTERING.md): every device dialog has
the checkbox, incl. virtual; hidden devices vanish from every mode and
the count, still count toward room LQI, cast no glow/light fill; the
device editor's "Show hidden" (local, per tab) shows them as BLUE
dashed ghosts — distinct from a grey unavailable icon — with NO live
state paint (no yellow, no alarm, no ripple);
unticking keeps a hidden:false marker (re-seed protection); an old
config materialises on first load by an editing client and legacy
clients keep the old behaviour until then
[auto: smoke_hidden_flag + unit seedHiddenBindings/seeded/legacy]
- [ ] Yellow means working (dev): a TRV whose hvac_action is heating glows
yellow; one that is merely enabled (idle) or has a service switch on
(anti-scaling, child lock) stays dark; a lit light turns its state on by
the same condition that lights the glow pool — and shows the yellow
badge only where the glow spot is NOT drawn: with the glow layer
visible the spot is the one indicator (v1.52.0)
[auto: smoke_yellow_principle + smoke_light_badges]
- [ ] Editor gestures on touch (dev): in the plan editor on a phone, pinch
zooms and a moving finger pans; releasing after a gesture does not draw
a point, a clean tap still does [auto: smoke_editor_gestures]
- [ ] Legacy geometry parity (v1.50.4, HP-1503-01): a store with a zero
viewport and a negative rect renders identically sane in BOTH cards —
full canvas fallback, normalised rectangle [auto: smoke_legacy_geometry]
- [ ] Sizes are positive (v1.50.3, HP-1502-01): view_box or room w/h of zero
or below is refused; a store that already holds one opens on the full
canvas, not a blank screen [auto: test_sizes_are_not_coordinates + unit
safeViewBox fallback]
- [ ] Room card layout (v1.50.3): the settings button is the bottom row of the
card and the room name sits in the same spot in view and plan modes
[manual; verified by vb-coordinate measurement]
- [ ] Geometry bounds (v1.50.2, HP-1501-01): a config with a 1e100 room
vertex is refused by the server; one already stored still renders with a
sane frame [auto: test_geometry_magnitudes_are_bounded + unit
contentBounds legacy case]
- [ ] No-op repair (v1.50.2, HP-1501-02): geometry/repair with a typo'd space
id errors, moves no revision and keeps the previous backup undoable
[auto: test_a_noop_repair_does_not_eat_the_backup]
- [ ] Card below other dashboard content (v1.50.1, HP-1500-02): place the card
after a tall card in a normal dashboard — the plan still gets most of the
viewport instead of a zero-height stage [auto: smoke_zoom_out]
- [ ] Frame never degenerate (v1.50.1, HP-1500-03): a space with one lone
marker opens with canvas around it, not an empty scene; an absurd stored
coordinate neither hides the plan nor is accepted by the server
[auto: unit contentBounds + backend test_layout_coordinates_are_bounded]
- [ ] Stranded migration repair (v1.50.1, HP-1500-01): geometry/repair with
dry_run previews, applies with a backup, undo restores; wrong space is
recoverable [auto: test_geometry_repair_is_explicit_previewable_and_undoable]
- [ ] Editors see the whole canvas (v1.50.0, HP-1490-03): a hand-drawn space
with one small room opens content-fit in View; switching to the plan
editor shows the full square with room to draw a second room far away;
back to View restores the content fit [auto: smoke_audit_1490]
- [ ] Save waits for a picked plan's proportions (v1.50.0, HP-1490-04): pick a
saved plan and hit Save before the thumbnail loads — the stored aspect is
the real one, never the previous file's [auto: smoke_audit_1490]
- [ ] Zoom goes below the fit (v1.50.0): minus past 100% floats the plan
centred, floor at 0.4x; entering an editor keeps the stage inside the
viewport [auto: smoke_zoom_out]
- [ ] Migration crash recovery (v1.50.0, HP-1490-01): kill HA between the two
store writes of the square migration — the next start finishes the layout
half from the saved intent
[auto: test_square_migration_finishes_after_a_crash_between_the_writes]
- [ ] Parallel upload quota (v1.50.0, HP-1490-02): two simultaneous uploads
with one slot left — exactly one succeeds
[auto: test_parallel_uploads_cannot_slip_past_the_quota_together]
- [ ] Zoom opens on the content (v1.49.0): a space with no background and one
small room opens with that room filling the screen, with a small margin.
With a background it still fits the whole image
[auto: unit: contentBounds]
- [ ] Deleting a picked plan is refused (v1.49.0, HP-1470-02): pick a saved
plan, reopen the list — its delete button is disabled. Ask the server to
store a plan url whose file is gone: `missing_plan`, and the revision does
not move [auto: smoke_saved_plans + backend
test_config_set_refuses_a_plan_that_no_longer_exists]
- [ ] Uploads are bounded (v1.49.0, HP-1470-01): past the store quota an upload
is refused with a clear error and the disk does not grow; the plan list
returns the newest 60 with a total
[auto: unit: test_check_quota_counts_the_whole_store_not_one_request,
backend test_uploads_are_bounded_by_a_store_quota]
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
looks exactly as before, just with margins where the canvas was extended.
Measure a wall in the plan editor — the length in cm is unchanged. Marker
positions, doors, decor and the saved zoom are all where they were
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
test_migration_preserves_real_lengths_and_shapes]
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
empty bands above and below, a tall one with bands at the sides, and it is
never stretched [auto: unit: fitInSquare + smoke_space_settings]
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
PAGE, open space settings → "Already uploaded" → the image is listed with
its size and no "in use" note → attach it → it renders. The one a space
uses shows that space and cannot be deleted; a free one can, with a
confirm, and disappears from the list
[auto: smoke_saved_plans + backend test_stored_plans_can_be_listed_and_deleted_on_request]
- [ ] Detaching a plan keeps the file (v1.46.6): switch a space to "draw" and
SAVE — the image is still in `config/houseplan/plans/` right afterwards,
and after a restart, and can be re-attached. Deleting the space keeps it
too. Replacing a plan still removes the one it replaced, immediately.
Check straight after the save: the earlier bug deleted the file at that
moment, while every scheduled-pass test passed
[auto: unit: test_plan_collection_matrix, test_attachment_collection_matrix,
backend test_detaching_a_plan_keeps_the_file]
- [ ] Rebinding a device does not eat its manuals (v1.46.5): attach two files to
a device, rebind it to another HA device — both are readable afterwards.
If a copy failed, the file it failed on is still there rather than deleted
with the folder [auto: backend test_files_cleanup_keeps_referenced_files]
- [ ] Nothing accumulates on an idle instance (v1.46.2/v1.46.3, HP-1461-01,
HP-1462-01): attach a file, cancel the dialog, and do not save anything
else — the file is gone after a restart AND after the daily pass, while
every file the configuration still references is untouched. Seed the
strays AFTER the last save, or `config/set` collects them and the check
proves nothing
[auto: backend test_startup_sweep_collects_what_no_commit_will,
test_daily_sweep_callback_collects_too, test_sweep_and_a_config_write_do_not_race]
- [ ] A drag wins over a concurrent remote move (v1.46.2, HP-1461-02): drag an
icon and, while the save is still in flight, have another window move a
different icon — your icon stays where you put it and the other one
updates [auto: smoke_layout_sync]
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
file from two browser tabs at once — two attachments, two sets of bytes,
neither lost. A file whose name is at the length limit still downloads
[auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours]
- [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload
mid-transfer, send two files in one request, make promotion fail — in each
case the files folder holds no `.upload-*`. An old one is swept at startup
[auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps]
- [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in
two windows, drag an icon in one — it moves in the other without a reload;
a drag in progress in the second window is not thrown away
[auto: smoke_layout_sync]
- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's
signed url directly in a tab — a `<script>` inside it must not run and must
not reach the HA session's localStorage; the same plan still renders in the
card. PDFs still open in the browser viewer
[auto: smoke_svg_sandbox + backend test_uploaded_svg_is_sandboxed_and_a_pdf_is_not]
- [ ] An attachment never overwrites another (v1.46.0, HP-1454-02): attach a file,
cancel the dialog — the previously stored file is byte-identical. Attach
`manual.pdf` to two NEW icons — two independent files. A cancelled upload is
gone an hour later
[auto: backend test_upload_never_overwrites_an_existing_attachment + unit: collect_attachments]
- [ ] Two quick edits both survive (v1.46.0, HP-1454-03): with a slow connection,
make an edit and another one before the first save answers — both are in the
stored config, only one write is ever in flight, and no conflict toast fires
[auto: smoke_config_writer]
- [ ] Open boundaries follow geometry (v1.46.0, HP-1454-04): change a space's
aspect or drag a room vertex — the open boundary and the light through it
move with the walls, without a reload [auto: smoke via model-identity key]
- [ ] Inner limits (v1.46.0, HP-1454-05): max and max+1 for polygon points,
open_to, controls, pdfs, text and url lengths; an oversized config as a
whole is refused with `too_large`
[auto: unit: test_inner_collection_limits + backend test_config_write_is_capped_by_total_size]
- [ ] Big files stream (v1.46.0, HP-1454-06): upload a ~50 MB manual and download
it twice in parallel — HA's memory does not grow by a file per transfer
[manual]
- [ ] Static card parity (v1.46.0, HP-1454-07): a room whose fill is set to "none"
under a space filled by light is transparent on BOTH cards
[auto: smoke_render_parity]
- [ ] Layout reaches the static card (v1.46.0, HP-1454-08): drag an icon on the
full card — a static card on the same dashboard moves it too, with no
config write and no reload
[auto: backend test_layout_keeps_its_revision_and_announces_changes + manual]
- [ ] Repair issues are not immortal (v1.46.0, HP-1454-09): create a missing-plan
warning, then delete the space — the warning disappears [manual]
- [ ] A path the backend cannot sign does not become a request loop (v1.45.4,
review R5-1): when `content/sign` answers successfully but omits a path,
the card backs that path off individually and keeps the urls it did get;
a re-render asks only for what is still missing, and only after the wait
[auto: unit: signing.test + backend test_signing_one_path_may_fail_without_failing_the_request]
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
the WebSocket slow or refusing, the card issues ONE sign request per url
and backs off after a failure instead of asking again on every render; a
request that never answers stops blocking retries after 15 s
[auto: unit: signing.test + smoke_space_card_bg]
- [ ] A broken plans directory does not fail a save (v1.45.2, review R4-1): make
the plans folder unreadable and save the configuration — the save
succeeds, the revision is usable, and the next save does not conflict
[auto: backend test_a_failing_collector_does_not_undo_an_accepted_save]
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
two tabs, attach a background in each in turn — the plan last saved is the
one served, and neither commit deletes the other's file. A rejected upload
disappears on a later save, not immediately
[auto: backend test_late_commit_of_one_client_never_deletes_another_client_s_plan,
test_commit_does_not_collect_another_client_s_uncommitted_upload,
test_abandoned_uploads_are_collected_once_old]
- [ ] Static card background (v1.45.1, review R3-2): a houseplan-space-card on a
dashboard shows the plan image, not an empty stage; the browser never
requests the unsigned path and Home Assistant logs no failed login. A
failed signing request is retried on the next render
[auto: smoke_space_card_bg]
- [ ] 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]
@@ -272,8 +584,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
same domain still win over hidden ones [manual: click hallway lamps]
- [ ] Marker controls (v1.36.0): a marker with "Controls light sources" and
tap action Toggle flips all bound lights/switches at once (any on → all
off, all off → all on, one service call); the icon and its RGB tint
mirror the targets, not the marker's own entity; without explicit Toggle
off, all off → all on, one service call); the icon state (yellow badge)
mirrors the targets, not the marker's own entity — the RGB tint is gone
since v1.52.0, target colours reach only the glow/ripple; without explicit Toggle
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto: smoke_controls]
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
@@ -494,3 +807,62 @@ require hands on real hardware — they remain for the human pass.
crossing wall-segment boundaries
- [ ] Single click still opens the status card; double click opens the properties dialog;
a drag does NOT open either
## Live vacuums (docs/VACUUM.md)
- Docked robot: only the base marker, at the user-placed spot (the dock).
- Cleaning + calibrated map: a round pulsing puck — the base badge but
circular and 20% smaller, same plate colors, glyph dead-centre — drives
the plan; the base marker never moves. No heading arrow.
- Puck motion: glides ~1.2 s between telemetry points; TELEPORTS (no glide)
on zoom, pan, space switch, browser-tab return, and after a >10 s data
gap. Stale coords (>60 s while cleaning) freeze and dim it.
- «Показывать путь робота» has three modes: never / while cleaning (default
— the line hides the instant the run ends) / always (the only mode that
also shows the previous run at 40% opacity).
- The trail is server-recorded (trails.py watches the source entity; two
runs kept per marker, survives reloads, shared by every screen) and never
outruns the icon: drawn segments lag one point, and the last segment is a
rAF-driven tip glued to the puck centre every frame.
- Trail style: cartography casing (dark halo 2.25 + light core 0.9),
readable over any room fill.
- Hidden marker: neither puck nor trail. Uncalibrated active map: no puck.
- Calibration: «Настроить автоматически» (≥3 rooms matched by name) or the
fit panel — drag the dashed room ghost, stretch by 4 corner handles,
rotate 90°/mirror buttons (mirror ON by default), Save/Cancel/Esc. Real
clicks must land on the overlay (elementFromPoint smoke guards it).
- Multi-floor: one matrix per robot map (Dreame `selected_map` on the
vacuum entity names the active one).
## Room resize (docs/RESIZE.md)
- [ ] The «Размер» tool appears in the Plan editor toolbar; in EVERY other
tool (and in Devices/Decor/View) there is not a single `.rszhandle`
- [ ] Handles sit at the midpoint of every wall of every room; they are
finger-sized, capture the pointer and never start a stage pan
- [ ] Dragging a handle moves the wall along its normal, both edge ends
together; the wall line snaps to the drawing grid
- [ ] A wall fully shared with a neighbour drags the neighbour's wall with
it — the neighbour shrinks/grows, no gap and no overlap can appear
- [ ] A partially shared wall (T-junction) moves only the coinciding
stretch: the neighbour becomes L-shaped (new vertices), shown live
- [ ] Legacy x/y/w/h rectangle rooms resize the same way and are saved back
as polygons
- [ ] Live badges while dragging: lengths of the dragged wall + both
adjacent walls, and the m² area at the room centre; dragging a shared
wall shows BOTH areas; all numbers update continuously
- [ ] Stops: ~30 cm minimum for the own room AND the shrinking neighbour;
a foreign room in the path (touch is ok, overlap never); islands
inside; a door/window on a shortening wall (the wall corner can not
pass the opening edge)
- [ ] A door/window ON the moving wall travels with it (openings x/y
recompute; angle unchanged)
- [ ] Esc mid-drag cancels: the original geometry is back instantly
- [ ] Click a room in the resize tool → dashed bbox frame with 4 corner
handles; dragging a corner scales all vertices proportionally about
the opposite corner; neighbours are NOT dragged along (the one
exception to «shared walls together»), growing into one stops
- [ ] Ctrl+Z / ⌘Z after releasing a handle restores the previous geometry —
one release = one undo step (rooms AND openings)
- [ ] Device markers do not move; the room settings gear re-centres itself
- [ ] Smoke: `node demo/smoke_room_resize.mjs`

Some files were not shown because too many files have changed in this diff Show More