Compare commits

...
85 Commits
Author SHA1 Message Date
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
73 changed files with 9842 additions and 659 deletions
+20 -6
View File
@@ -5,6 +5,7 @@
[![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
@@ -16,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)
> ### 🚀 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).
@@ -33,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.
@@ -78,8 +91,9 @@ Key advantages in short:
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). RGB bulbs color their icon with the real
light color. A translucent icon = unavailable. Dark = idle.
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.
---
@@ -161,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)
@@ -204,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.
@@ -286,7 +300,7 @@ Services → House Plan**.
**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 filtering (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.
+19 -5
View File
@@ -3,6 +3,7 @@
[![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 в живую интерактивную карту дома.** Загрузите или
@@ -14,12 +15,19 @@
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
> ### 🚀 Попробовать вживую — без установки
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
> возвращается в исходное состояние.
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
**Главное**
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
@@ -31,6 +39,11 @@
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
@@ -77,7 +90,8 @@ House Plan показывает ваш умный дом так, как он в
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). RGB-лампы окрашивают значок реальным цветом света.
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
@@ -162,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)
@@ -205,7 +219,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -288,7 +302,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+12
View File
@@ -50,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))
@@ -194,6 +203,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
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 —
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.51.0"
VERSION = "1.55.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.51.0"
"version": "1.55.2"
}
+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)
+29 -1
View File
@@ -173,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),
@@ -269,7 +271,31 @@ MARKER_SCHEMA = vol.Schema(
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", None),
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),
@@ -295,6 +321,8 @@ 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)),
# 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(
@@ -16,6 +16,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import (
DOMAIN,
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
@@ -40,6 +41,7 @@ _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)
@@ -692,6 +694,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
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:
@@ -767,3 +770,18 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
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');
+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);
+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
@@ -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,
+161
View File
@@ -73,4 +73,165 @@ Object.assign(out, await page.evaluate(async () => {
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));
+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));
+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);
+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 });
+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' } } } };
+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 });
+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));
+3 -1
View File
@@ -56,6 +56,8 @@ checkAll(res, {
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"lqiFills": 0,
"atticSquare": true,
"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},
// 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);
+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));
+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);
+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);
+143
View File
@@ -48,6 +48,149 @@ out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const
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;
File diff suppressed because one or more lines are too long
+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);
})();
+573 -165
View File
File diff suppressed because one or more lines are too long
+36 -14
View File
@@ -17,6 +17,7 @@ houseplan-card/
├─ 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)
@@ -176,11 +190,19 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `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}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}]}` |
| `houseplan/plans/list` | — | `{plans: [{name, url, size, modified, used_by}], total}` (newest 60) |
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
| `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
+342
View File
@@ -1,5 +1,347 @@
# Changelog
## v1.55.2 — 2026-08-02
Patch release: a calmer first open — no zoom flash and no layout jumps
while Home Assistant is still settling — plus a new background color
setting and two bits of editor polish.
- **Opening a plan no longer flashes the default view before your saved
zoom** (HP-1551). The saved zoom used to be applied a beat after the
first render, so the card painted the default fit for a frame and then
snapped to your position. It is now applied synchronously, before the
first paint — the plan appears exactly where you left it.
- **A first-open veil hides the plan until the layout settles**
(HP-1552). While Home Assistant is still loading its side panels, the
stage height can change several times, and the plan visibly jumped
along with it. The card now waits under a dark veil with a pulsing
house outline until the height stops moving, then reveals the plan
already in place. Kiosk mode skips the veil entirely.
- **New setting: background color around the plan** (HP-1554). The area
around the plan can now be any color — set it in general settings for
the whole card, or override it per space. The backend validates the
value as a #rrggbb hex color.
- **Round caps and joins on background-editor lines** (HP-1553). Strokes
used to end and meet in square cuts; line ends and corners are rounded
now, so traced walls and outlines look clean.
- **The Resize tool's wall handles now look the part.** They are half
the previous size, show a wall-with-arrows glyph that rotates to
follow the wall, and use the grab cursor — while the hit area stays
exactly as large as before.
## v1.55.1 — 2026-08-01
Patch release: the four findings of the v1.55.0 audit, all in the new
Room resize tool (HP-1550-01..04).
- **A pending save can no longer leak a live drag to the server**
(HP-1550-01). The resize preview used to be written straight into the
shared config object, and a debounced save still queued from a
previous edit could snapshot it mid-drag — so a resize you then
cancelled with Esc quietly survived on the server and came back after
a reload. The live geometry now stays in a separate overlay that only
the renderer sees; it reaches the config exactly once, when the
handle is released.
- **An interrupted drag is cancelled, not committed** (HP-1550-03).
When the system cuts a drag short — switching apps, palm rejection on
a tablet — the tool used to treat it as a release and save the
half-finished geometry. Such interruptions now take the same path as
Esc: the original geometry comes back, no undo step, no save.
- **A door in the middle of a wall no longer blocks resizing it**
(HP-1550-04). The door's invisible hit area used to sit on top of the
wall handle, making such a wall ungrabbable for both rooms. In the
resize tool the handles now own the hit test (openings are not
editable there anyway — they simply travel with the wall), while all
other editor tools keep openings clickable exactly as before.
- **The ~30 cm minimum size now holds for any room shape**
(HP-1550-02). The old check only measured parallel opposite walls, so
a triangular room could be squeezed into a sliver, and a rotated
room could scale its real short side below the floor unnoticed. Both
measures are orientation-independent now: wall drags respect every
obstacle in the wall's path (a triangle's apex, a slanted wall), and
the scale frame uses the room's true minimum width. Rooms already
thinner than the floor may still be improved, never made worse.
## v1.55.0 — 2026-08-01
Minor release: rooms are no longer set in stone — a dedicated Resize
tool in the plan editor changes their size by dragging walls; plus the
two findings of the v1.54.3 audit (HP-1543-01/02).
- **Room resize — a new «Размер» tool in the plan editor** (spec:
docs/RESIZE.md). Every wall gets a handle at its midpoint; drag it
and the whole wall moves, staying parallel to itself, with grid
snap. Works for polygons (L-shapes included) and for legacy
rectangles alike.
- **Shared walls always move together.** If the dragged wall coincides
with a neighbour's boundary, the neighbour follows: your room grows,
the neighbour shrinks — gaps and overlaps cannot appear by
construction. At T-junctions only the coinciding stretch follows,
inserting new corners into the neighbour's outline where needed.
- **Live numbers while you drag.** The dragged wall and its two
adjacent walls show their lengths in real meters/feet, and the room
area in m² updates live at the room centre — for a shared wall, the
areas of both rooms.
- **The wall stops where it must.** Rooms cannot get thinner than
~30 cm (neither yours nor the neighbour's), an outline never crosses
itself, a growing wall stops at foreign rooms and island rooms, and
doors and windows are anchors: an opening travels with its wall, and
a wall carrying openings can never get too short for them.
- **A scale frame for the whole room.** Click a room in the resize
tool to select it: dragging a corner of the dashed frame scales the
outline proportionally, with the same stops applied.
- **Esc and Ctrl+Z.** Esc cancels the current drag and puts the
original geometry back; one released drag is one undo step, and
Ctrl+Z walks back up to 30 of them while the tool is active.
- **Fix: room overlap detection missed equal-height rectangles slid
over each other.** Found while building the resize stops; the
slide-over case now counts as an overlap everywhere the check is
consulted, including the draw tool.
- **Fix (HP-1543-01): editor zoom stayed on screen after a floor
switch made inside the editor.** Exiting an editor on a different
floor than the one you started on skipped the viewport restore and
left the editor working zoom in view mode. Such an exit now falls
back to the current floor's saved view zoom.
- **Fix (HP-1543-02): a motion sensor re-tripped mid-flash stayed
invisible.** A rapid off→on before the current flash ended kept the
old CSS animation timeline, so the second detection played nothing.
Every witnessed trip now gets a fresh animation identity and the
flash restarts; under reduced motion the static ring stays, as
before.
## v1.54.3 — 2026-08-01
Patch release: three quality-of-life rounds — icon satellites now follow
the per-device size multiplier, editor zoom stopped leaking into view
mode, and motion sensors got a visual language of their own.
- **The per-device size multiplier now scales the icon's satellites
too.** Reported by an owner with a screenshot: shrinking a device left
the value badge, the temperature/humidity plates, the LQI label, the
«new» dot and the alarm ring at full size, towering over the tiny
icon. Every satellite now follows the multiplier along with the icon.
- **Editor zoom is a working tool, not a saved setting.** Zooming to
500% to place a marker precisely and then closing the editor used to
drop you into a 500% view mode. Leaving any editor now restores the
viewport you had in view mode before you started editing.
- **Fix: editor zoom could still resurrect when switching floors.** The
editor viewport raced the per-floor viewport store and won: switch
floors while editing and the 500% was written down as that floor's
saved view, greeting you on the next visit (steps to reproduce came
from the owner). Editor zoom is never written to the store anymore.
- **Tripped motion and presence sensors signal with a yellow ring.** The
rule stays «fill = on»: motion is a one-shot flash — three pulses at
the moment of detection, with no pulsing during the cool-down tail —
while occupancy/presence hold a static ring for as long as they report
someone present.
- Stand only: a `services.yaml` for the demo stand's `demo_guard` to
keep hassfest green; nothing of it ships in the integration.
## v1.54.2 — 2026-07-31
Patch release: the one remaining finding of the v1.54.1 re-audit
(HP-1541-01), pinned by regression tests on both sides of the contract.
- **Fix: a vacuum whose own `selected_map` is `0` split calibration and
trail between two map ids.** The v1.54.1 map-id contract («the first value
that exists wins, and zero is a value») was applied to the source entity
but not to the card's fallback on the vacuum's `selected_map`: the
frontend still judged it by truthiness and turned `0` into `default`,
while the server recorder stored the run under `0`. Calibration was saved
under a key the recorder never used, and the recorded trail never rendered
after a reload. The fallback now follows the same not-nullish rule on both
sides (`vacMapIdWithFallback` in the card, `resolve_map_id` on the
server), with cross-runtime regressions for `selected_map` = `0`, `"0"`
and `""`.
## v1.54.1 — 2026-07-31
Patch release: everything the adversarial audit of v1.54.0 found
(HP-1540-01..06), each fix pinned by a regression test that fails on the
old code.
- **Fix: the first calibration of a freshly discovered vacuum silently did
nothing.** Until the device dialog was saved once, the robot had no config
marker — yet the «Живая позиция» section was fully interactive, and every
handler quietly bailed out while auto-calibration still announced success.
Any vacuum edit now materialises the marker itself, and the success toast
only appears after the matrix has verifiably landed in the config.
- **Fix: a robot whose first map is `map_index: 0` lost its server-side
trail.** The backend picked the map id by truthiness and dropped the zero,
so the recorded run was stored under a key the card never looked up. Both
sides now share one explicit rule — the first value that exists wins, and
zero is a value.
- **Fix: one robot on two floors recorded history for the last floor only.**
The recorder kept a single marker per source entity, so the second
placement silently evicted the first. Every marker fed by a source now
gets its own copy of the run.
- **Fix: auto-calibration ignored plans drawn with rectangle rooms.** The
room matcher only accepted polygon outlines and then blamed the room
names. Legacy rectangles count like everywhere else in the card.
- **Fix: overlapping recorder refreshes leaked state subscriptions.** Two
config saves racing each other could both subscribe and strand one
callback until restart; refreshes are serialised now and teardown wins
over any refresh still in flight.
- The «no rooms» / «no match» toasts and docs/VACUUM.md no longer send you
to the three-point calibration that no longer exists — they point at
«Подогнать вручную», which does.
## v1.54.0 — 2026-07-31
### Live robot vacuums
The plan now shows the robot while it works. The device marker stays where
you put it — that is the dock — and a round puck drives the plan in real
time, pouring its path out from under itself. Everything is display only:
the card never commands the robot.
- **Calibration without arithmetic.** «Настроить автоматически» matches the
robot's room list against your rooms by name and solves the transform in
one click. When names do not match, the fit panel lays the robot's rooms
over the plan as a dashed ghost: drag it into place, stretch it by the
corner handles, quarter-turn and mirror with two buttons. Mirror is on by
default — every robot map we have measured flips Y versus the screen.
One calibration per robot map, so two floors stay independent.
- **The path is recorded server-side.** The integration watches the robot
itself, so the trail records with no card open, survives page reloads, and
every screen sees the same line. The current run and one previous run are
kept — cleaned versus not-yet-cleaned at a glance.
- **«Показывать путь робота»**: never / while cleaning (default) / always.
Only the last mode also draws the previous run, faded.
- The trail never runs ahead of the icon: drawn segments lag one point and
the growing tip is glued to the puck's animated centre every frame. It is
drawn as a dark halo under a light core, so it stays readable over any
room fill. The puck teleports instead of gliding when the view changes —
zoom, floor switch or a return to the browser tab.
- Adapters: Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack), Valetudo.
Verified against a live Dreame X50 Master.
### Also
- **«Свет по источникам» is the default fill for new spaces** and leads the
options list. Existing plans are untouched: a space whose owner never
chose a fill still renders as before.
- **Fixed: the "what to run" search showed no results.** The list is a
scrollable box and, as a flex item, collapsed into a 1px sliver — the
matches were there, rendered into nothing.
## v1.53.1 — 2026-07-30
- **Fix: the "what to run" search showed no results.** The results were
there — the list is a scrollable box, and as a flex item inside the dialog
body it collapsed into a 1px sliver, so 26 matching automations rendered
into nothing visible. Reported within minutes of v1.53.0 by the owner. The
list keeps its height now; the smoke measures that height instead of
merely counting DOM rows, which is why it passed the broken build.
## v1.53.0 — 2026-07-30
**A tap can run your automation** (owner's spec)
- **New tap action: "Run automation/script/scene".** We cannot know every
exotic device, but you know what a tap on it should do: pick any
automation, script or scene from a searchable list, and the tap runs it —
`automation.trigger`, `script.turn_on` or `scene.turn_on` per kind, with a
"Started" toast. A script is the idiomatic HA "action", so all three
runnable kinds are offered — no trigger-less dummy automations needed.
Saving refuses a run action without a target; a target deleted later warns
in the dialog and toasts safely on tap.
- **"Ask for confirmation" checkbox** — the accidental-tap guard, available
for any state-changing action: toggle and run alike, wall-switch markers
with bound targets included. The dialog is the card's own (Esc, backdrop
or Cancel = nothing happens), so it works on a wall tablet.
- **Curtains toggle natively:** covers and valves joined the card-wide
toggle domains — with one deliberate exception: garage doors, doors and
gates (cover device classes garage/door/gate) stay OUT of the default
toggle, an accidental tap must not open the driveway. An explicit
per-device toggle remains the owner's conscious choice, and locks/alarm
panels stay untouchable from the plan, as always.
## v1.52.2 — 2026-07-29
**From the v1.52.1 review** (no runtime changes — test and wording quality)
- The plan-editor regression check now targets THE lamp (HP-1521-01): the
old assertion accepted any yellow badge, and the lit socket in the same
fixture would have satisfied it with the lamp fix removed. Verified by
mutation: reverting the v1.52.1 gate makes the smoke fail.
- The one remaining "yellow in every fill mode" wording — the checklist
entry and the _stateClass comment — now states the actual contract: the
lit-source state is computed by the glow-pool condition, and the yellow
badge shows only where the glow spot is not drawn (HP-1521-02).
## v1.52.1 — 2026-07-29
**From the v1.52.0 review**
- **A lit lamp always has exactly one indicator (HP-1520-01).** The glow
layer is hidden in the plan editor, but the yellow suppression still
applied there — a lit lamp showed neither the spot nor the badge. The
suppression gate now equals the layer's actual visibility: wherever the
spot is not drawn, the yellow badge returns.
- **The static card honours a marker's size and rotation (HP-1513-01).** The
same stored marker rendered at base size, unrotated, on the read-only
card. It now mirrors the full card's --dev-scale and angle — geometry
only, still a schematic.
- Documentation caught up with the v1.52.0 colour contract (HP-1520-02):
the RGB-tint expectations in TESTING/UX-MODES and a stale inline comment.
## v1.52.0 — 2026-07-29
**One look for light sources, whatever flipped them** (owner's rule)
- **A lamp's colour lives only in its glow.** The RGB tint of the icon,
border and shadow is gone: depending on whether the state carried colour
data, the same lamp used to land in the "coloured icon on a dark badge" or
the "plain yellow badge" branch — turning one lamp off by tap and the rest
by the wall switch produced visibly different results. The branch is gone.
- **In glow fill the indicator IS the glow spot.** A light source's badge
stays standard, lit or not — the pool of light around it says everything.
A lit socket, fan or kettle keeps its yellow even in glow fill: they cast
no light, the rule is for sources only.
- **In every other fill a lit source is plain yellow**, like a heating
radiator valve — RGB and white lamps alike.
- Icon morphing (the shining-bulb outline) stays in every mode, and the
ripple colour still falls back to the lamp's light colour.
## v1.51.3 — 2026-07-29
- **The icon size multiplier scales the glyph, not just the badge.** Changing
a device's size grew the badge, the ripple and the value badges but left
the icon itself at its default size — a big empty box around a small glyph
(user report). The glyph now derives from the same per-device size as
everything else and keeps its proportion at any multiplier and zoom.
## v1.51.2 — 2026-07-29
**From the v1.51.1 review**
- **The auto grid is the same on both cards (HP-1511-01).** The full card
reserves grid cells for hidden devices (their ghosts keep a place in the
device editor); the static card compacted the grid over visible ones only,
so a freshly discovered marker with no saved position landed in different
spots on the two cards. The static card now feeds the full roster to the
same grid and still draws only the visible.
- **A ripple-display ghost keeps its base icon (HP-1511-02).** Hidden markers
with the "ripple" presentation rendered as an icon-less inactive pulse —
unrecognisable in the editor. A ghost now drops the display dressing
entirely: base icon and name, whatever the display mode.
## v1.51.1 — 2026-07-29
**From the v1.51.0 review**
- **The static card counts hidden devices in room LQI again (HP-1510-01).**
Its visibility filter had quietly become the aggregation filter: the same
room showed different Zigbee health on the two cards. Aggregation and
rendering use separate lists now — hidden devices count toward signal on
both cards, are drawn on neither, and still cast no light.
- **A ghost shows no live numbers (HP-1510-02).** A hidden device in "Show
hidden" suppressed the state colors but still painted its value text,
temperature, humidity, LQI badge and state-morphed icon. All of that is
gone: the ghost keeps only the base icon and name — enough to recognise it
and open the dialog.
## v1.51.0 — 2026-07-29
**Hiding is an explicit flag now** (docs/FILTERING.md)
+352
View File
@@ -6,6 +6,358 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.55.2 — 2026-08-02
Патч-релиз: спокойное первое открытие — без вспышки зума и прыжков
макета, пока Home Assistant догружается, — плюс новая настройка цвета
фона и две полировки редактора.
- **План больше не мигает видом по умолчанию перед сохранённым зумом**
(HP-1551). Сохранённый зум применялся на такт позже первого рендера:
карточка на кадр рисовала стандартную подгонку и лишь потом прыгала к
вашей позиции. Теперь он применяется синхронно, до первой отрисовки —
план появляется ровно там, где вы его оставили.
- **Вуаль первого открытия прячет план, пока макет не устаканится**
(HP-1552). Пока Home Assistant догружает свои панели, высота сцены
может меняться несколько раз, и план заметно прыгал вместе с ней.
Теперь карточка ждёт под тёмной вуалью с пульсирующим контуром домика,
пока высота не перестанет меняться, и показывает план уже на месте.
В режиме киоска вуаль не показывается вовсе.
- **Новая настройка: цвет фона вокруг плана** (HP-1554). Области вокруг
плана теперь можно задать любой цвет — в общих настройках для всей
карточки или отдельно для каждого пространства. Бэкенд проверяет
значение как hex-цвет #rrggbb.
- **Круглые концы и стыки линий в редакторе подложки** (HP-1553). Штрихи
обрывались и стыковались квадратными срезами; теперь концы линий и
углы скруглены — обведённые стены и контуры выглядят аккуратно.
- **Ручки стен в инструменте Resize теперь выглядят по делу.** Они вдвое
меньше прежних, несут значок «стенка со стрелочками», повёрнутый вдоль
своей стены, и показывают курсор grab — при этом зона клика осталась
прежней, большой.
## v1.55.1 — 2026-08-01
Патч-релиз: четыре находки аудита v1.55.0, все — в новом инструменте
ресайза комнат (HP-1550-01..04).
- **Отложенное сохранение больше не утаскивает живое перетаскивание на
сервер** (HP-1550-01). Превью ресайза писалось прямо в общий объект
конфигурации, и отложенная запись, оставшаяся в очереди от прошлой
правки, могла сфотографировать его посреди перетаскивания — ресайз,
который вы затем отменили по Esc, тихо оставался на сервере и
возвращался после перезагрузки. Теперь живая геометрия живёт в
отдельном слое, который видит только отрисовка; в конфигурацию она
попадает ровно один раз — при отпускании ручки.
- **Прерванное перетаскивание отменяется, а не сохраняется**
(HP-1550-03). Когда систему прерывает сам поток ввода — переключение
приложения, ладонь на планшете — инструмент считал это отпусканием и
сохранял недоделанную геометрию. Теперь такие прерывания идут тем же
путём, что и Esc: исходная геометрия возвращается, без шага отмены и
без записи.
- **Дверь посреди стены больше не мешает менять её размер**
(HP-1550-04). Невидимая зона клика двери ложилась поверх ручки стены,
и такую стену нельзя было схватить ни у одной из комнат. В
инструменте ресайза ручки теперь главные в hit-тесте (проёмы там всё
равно не редактируются — они просто едут вместе со стеной), а во всех
остальных инструментах редактора проёмы кликаются как раньше.
- **Минимум ~30 см теперь держится для комнаты любой формы**
(HP-1550-02). Старая проверка мерила только параллельные
противоположные стены: треугольную комнату можно было сплющить в
щепку, а повёрнутая комната при масштабировании незаметно уводила
свою реальную короткую сторону ниже порога. Обе меры теперь не
зависят от ориентации: перетаскивание стены упирается в любое
препятствие на её пути (вершину треугольника, косую стену), а рамка
масштабирования использует истинную минимальную ширину комнаты.
Комнаты, уже более тонкие, по-прежнему можно улучшать, но не ухудшать.
## v1.55.0 — 2026-08-01
Минорный релиз: комнаты больше не высечены в камне — новый инструмент
«Размер» в редакторе плана меняет их габариты перетаскиванием стен;
плюс обе находки аудита v1.54.3 (HP-1543-01/02).
- **Ресайз комнат — новый инструмент «Размер» в редакторе плана**
(спека: docs/RESIZE.md). У каждой стены появляется ручка в середине;
тяните её — и стена едет целиком, оставаясь параллельной себе, с
привязкой к сетке. Работает и для полигонов (включая Г-образные), и
для старых прямоугольников.
- **Общие стены всегда двигаются вместе.** Если перетаскиваемая стена
совпадает с границей соседа, сосед следует за ней: ваша комната
растёт — соседняя сжимается, щели и наложения не появляются по
построению. На Т-стыках следует только совпадающий отрезок, при
необходимости в контур соседа вставляются новые вершины.
- **Живые цифры во время перетаскивания.** Перетаскиваемая стена и две
смежные показывают длины в реальных метрах/футах, а площадь комнаты
в м² обновляется в центре комнаты вживую — для общей стены — площади
обеих комнат.
- **Стена упирается там, где должна.** Комната не может стать тоньше
~30 см (ни своя, ни соседская), контур не пересекает сам себя,
растущая стена останавливается о чужие комнаты и острова, а двери и
окна — якоря: проём едет вместе со стеной, и стена с проёмами не
может стать для них слишком короткой.
- **Рамка масштабирования всей комнаты.** Клик по комнате в
инструменте выбирает её: перетаскивание уголка пунктирной рамки
пропорционально масштабирует контур — с теми же упорами.
- **Esc и Ctrl+Z.** Esc отменяет текущее перетаскивание и возвращает
исходную геометрию; одно отпускание ручки — один шаг отмены, Ctrl+Z
откатывает до 30 шагов, пока инструмент активен.
- **Исправлено: детекция наложения комнат пропускала равновысокие
прямоугольники, надвинутые друг на друга.** Найдено при постройке
упоров ресайза; slide-over теперь считается наложением везде, где
используется эта проверка, включая инструмент рисования.
- **Исправлено (HP-1543-01): редакторский зум оставался на экране
после смены этажа внутри редактора.** Выход из редактора на другом
этаже, чем тот, где начинали, пропускал восстановление вьюпорта и
оставлял рабочий зум редактора в режиме просмотра. Теперь такой
выход откатывается к сохранённому view-зуму текущего этажа.
- **Исправлено (HP-1543-02): датчик движения, сработавший повторно
посреди вспышки, оставался невидимым.** Быстрое off→on до конца
текущей вспышки не перезапускало CSS-анимацию — второе срабатывание
ничего не проигрывало. Теперь каждый зафиксированный trip получает
новую идентичность анимации, и вспышка стартует заново; при
prefers-reduced-motion остаётся статичное кольцо, как и раньше.
## v1.54.3 — 2026-08-01
Патч-релиз: три раунда качества жизни — спутники значка теперь слушаются
пер-девайсного множителя размера, редакторский зум перестал протекать в
режим просмотра, а датчики движения получили собственный визуальный язык.
- **Пер-девайсный множитель размера теперь масштабирует и спутники
значка.** Репорт владельца со скриншотом: у уменьшенного устройства
бейдж значения, плашки температуры и влажности, подпись LQI, точка
«new» и кольцо тревоги оставались полноразмерными и нависали над
крошечным значком. Теперь все спутники следуют за множителем вместе со
значком.
- **Редакторский зум — рабочий инструмент, а не сохранённая настройка.**
Приблизить до 500%, чтобы точно поставить маркер, и закрыть редактор
раньше означало оказаться в режиме просмотра на тех же 500%. Выход из
любого редактора теперь восстанавливает тот вид, который был в режиме
просмотра до начала правок.
- **Исправлено: редакторский зум всё ещё «воскресал» при переключении
этажей.** Редакторский вьюпорт гонялся с пер-этажным хранилищем вида и
побеждал: переключите этаж во время правок — и 500% записывались как
сохранённый вид этого этажа и встречали вас в следующий раз (шаги
воспроизведения — от владельца). Теперь редакторский зум в хранилище
не пишется никогда.
- **Сработавшие датчики движения и присутствия сигнализируют жёлтым
кольцом.** Правило прежнее — «подложка = включено»: движение — разовая
вспышка, три удара в момент обнаружения, без пульсации на хвосте
cool-down; occupancy/presence держат статичное кольцо, пока
докладывают о присутствии.
- Только для стенда: `services.yaml` для стендового `demo_guard`, чтобы
hassfest оставался зелёным; в саму интеграцию это не попадает.
## v1.54.2 — 2026-07-31
Патч-релиз: единственная оставшаяся находка повторного аудита v1.54.1
(HP-1541-01), закреплена регрессиями с обеих сторон контракта.
- **Исправлено: пылесос с собственным `selected_map: 0` делил калибровку и
след между двумя id карты.** Контракт map id из v1.54.1 («побеждает
первое существующее значение, и ноль — значение») был применён к
source-сущности, но не к фолбэку карточки на `selected_map` самого
пылесоса: фронтенд по-прежнему судил по «истинности» и превращал `0` в
`default`, тогда как серверный рекордер писал уборку под `0`. Калибровка
сохранялась под ключом, которого рекордер не использовал, а записанный
след после перезагрузки не отображался. Теперь фолбэк живёт по тому же
правилу «не-null» с обеих сторон (`vacMapIdWithFallback` в карточке,
`resolve_map_id` на сервере), с кросс-регрессиями для `selected_map` =
`0`, `"0"` и `""`.
## v1.54.1 — 2026-07-31
Патч-релиз: всё, что нашёл adversarial-аудит v1.54.0 (HP-1540-01..06),
каждый фикс закреплён регрессией, падающей на старом коде.
- **Исправлено: первая калибровка только что обнаруженного пылесоса молча
ничего не делала.** Пока диалог устройства ни разу не сохранён, у робота
нет маркера в конфиге — а раздел «Живая позиция» уже полностью
интерактивен: каждый обработчик тихо выходил, но автокалибровка всё равно
объявляла успех. Теперь любая правка пылесоса сама создаёт маркер, а тост
успеха появляется только после того, как матрица действительно легла в
конфиг.
- **Исправлено: робот с первой картой `map_index: 0` терял серверный
след.** Бэкенд выбирал id карты по «истинности» и выбрасывал ноль, так
что записанная уборка хранилась под ключом, который карточка не искала.
Теперь обе стороны живут по одному явному правилу: побеждает первое
существующее значение, и ноль — значение.
- **Исправлено: один робот на двух этажах писал историю только для
последнего этажа.** Рекордер держал по одному маркеру на источник, и
второе размещение молча вытесняло первое. Теперь каждый маркер источника
получает свою копию уборки.
- **Исправлено: автокалибровка не видела планы из прямоугольных комнат.**
Сопоставление принимало только полигоны, а потом сваливало вину на имена
комнат. Старые прямоугольники считаются, как и во всей остальной
карточке.
- **Исправлено: пересекающиеся обновления рекордера текли подписками.**
Два одновременных сохранения конфига могли подписаться оба и бросить один
callback до перезапуска; обновления теперь сериализованы, а teardown
выигрывает у любого ещё летящего обновления.
- Тосты «нет комнат» / «имена не совпали» и docs/VACUUM.md больше не
отправляют к несуществующей калибровке по трём точкам — они ведут к
«Подогнать вручную», которая существует.
## v1.54.0 — 2026-07-31
### Роботы-пылесосы вживую
План теперь показывает робота за работой. Маркер устройства остаётся там,
куда вы его поставили — это база, — а по плану в реальном времени едет
круглая шайба, «выливая» путь из-под себя. Только отображение: карточка
ничего роботу не командует.
- **Калибровка без арифметики.** «Настроить автоматически» сопоставляет
комнаты робота с вашими по именам и решает привязку в один клик. Если
имена не совпали, панель подгонки кладёт комнаты робота на план
пунктирным призраком: перетащите на место, растяните за уголки, поворот
на 90° и зеркало — двумя кнопками. Зеркало включено по умолчанию: у всех
измеренных нами роботов ось Y перевёрнута относительно экрана. Своя
калибровка на каждую карту робота, поэтому два этажа не мешают друг другу.
- **Путь пишется на сервере.** Интеграция следит за роботом сама, поэтому
след записывается даже без открытой карточки, переживает перезагрузку
страницы, и все экраны видят одну и ту же линию. Хранятся текущая уборка
и одна предыдущая — сразу видно, где робот уже прошёл, а где ещё нет.
- **«Показывать путь робота»**: никогда / во время уборки (по умолчанию) /
всегда. Прошлая уборка бледной линией рисуется только в последнем режиме.
- След никогда не обгоняет иконку: отрисованные отрезки отстают на точку, а
растущий кончик каждый кадр приклеен к центру едущей шайбы. Рисуется
тёмным ореолом со светлой сердцевиной — читается на любой заливке комнат.
При смене вида (зум, этаж, возврат на вкладку) шайба телепортируется, а
не едет через весь план.
- Поддержаны Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack) и
Valetudo. Проверено на живом Dreame X50 Master.
### Кроме того
- **«Свет по источникам» — режим по умолчанию для новых пространств** и
первый в списке. Существующие планы не трогаем: пространство, где режим
не выбирали, выглядит как раньше.
- **Исправлено: поиск «что запускать» не показывал результатов.** Список —
прокручиваемый блок, и как flex-элемент он схлопывался в полоску 1px:
совпадения были, но отрисовывались в ничто.
## v1.53.1 — 2026-07-30
- **Фикс: поиск «что запускать» не показывал результатов.** Результаты были —
список это прокручиваемый блок, и как flex-элемент внутри тела диалога он
схлопывался в полоску 1px: 26 подходящих автоматизаций отрисовывались в
ничто. Найдено владельцем через минуты после v1.53.0. Теперь список держит
высоту; смок измеряет эту высоту, а не просто считает строки в DOM —
именно поэтому он и пропустил сломанную сборку.
## v1.53.0 — 2026-07-30
**Нажатие может запускать вашу автоматизацию** (ТЗ владельца)
- **Новое действие по нажатию: «Запустить автоматизацию/скрипт/сцену».** Мы
не можем знать каждое экзотическое устройство, но вы знаете, что должен
делать тап по нему: выберите любую автоматизацию, скрипт или сцену из
списка с поиском — тап её запустит (`automation.trigger`,
`script.turn_on` или `scene.turn_on` по типу цели, с тостом «Запущено»).
Скрипт — идиоматичное «действие» HA, поэтому предлагаются все три
запускаемых типа: не нужны автоматизации-пустышки без триггера.
Сохранение без цели блокируется; удалённая позже цель подсвечивается в
диалоге и безопасно отвечает тостом при тапе.
- **Чекбокс «Спрашивать подтверждение»** — защита от случайных нажатий для
любого действия, меняющего состояние: и toggle, и запуск, включая
настенные выключатели с привязанными целями. Диалог собственный (Esc,
клик мимо или «Отмена» — ничего не происходит), поэтому работает и на
настенном планшете.
- **Шторы переключаются нативно:** cover и valve добавлены в домены
card-wide toggle — с одним осознанным исключением: гаражные ворота, двери
и калитки (классы garage/door/gate) остаются ВНЕ дефолтного переключения —
случайный тап не должен открыть гараж. Явный per-device toggle остаётся
осознанным выбором владельца, а замки и охранные панели, как и всегда,
с плана неприкосновенны.
## v1.52.2 — 2026-07-29
**По ревью v1.52.1** (рантайм не менялся — качество теста и формулировок)
- Проверка регресса в редакторе плана целится ИМЕННО в лампу (HP-1521-01):
старый ассерт принимал любой жёлтый значок, и горящая розетка из той же
фикстуры прошла бы его даже без фикса лампы. Проверено мутацией: откат
гейта v1.52.1 валит смок.
- Последняя формулировка «жёлтый в любом режиме» — пункт чек-листа и
комментарий в _stateClass — приведена к фактическому контракту: состояние
источника считается условием glow-пула, а жёлтая подложка показывается
только там, где пятно не рисуется (HP-1521-02).
## v1.52.1 — 2026-07-29
**По ревью v1.52.0**
- **У горящей лампы всегда ровно один индикатор (HP-1520-01).** В редакторе
плана glow-слой скрыт, но подавление жёлтого там всё равно действовало —
горящая лампа не показывала ни пятна, ни подложки. Гейт подавления теперь
равен фактической видимости слоя: где пятно не рисуется, жёлтая подложка
возвращается.
- **Статичная карточка уважает размер и поворот маркера (HP-1513-01).** Один
и тот же маркер рендерился на read-only карточке базовым и неповёрнутым.
Теперь она зеркалит --dev-scale и угол полной карточки — только геометрия,
по-прежнему схема.
- Документация догнала цветовой контракт v1.52.0 (HP-1520-02): ожидания
RGB-окраски в TESTING/UX-MODES и устаревший комментарий в коде.
## v1.52.0 — 2026-07-29
**Один вид источников света, чем бы их ни переключали** (правило владельца)
- **Цвет лампы живёт только в её свечении.** RGB-окраска иконки, рамки и
тени убрана: в зависимости от того, пришли ли с состоянием данные о
цвете, одна и та же лампа попадала то в ветку «цветная иконка на тёмной
подложке», то в «жёлтая подложка» — выключение одной лампы тапом и
остальных выключателем выглядело по-разному. Ветки больше нет.
- **В режиме glow индикатор — само пятно света.** Подложка источника всегда
стандартная, горит он или нет — всё говорит свет вокруг. Розетка,
вентилятор или чайник остаются жёлтыми и в glow: они не светят, правило —
только для источников.
- **В остальных режимах горящий источник — просто жёлтый**, как греющая
термоголовка; RGB и обычные лампы одинаково.
- Морфинг иконки (контур «лампа с лучами») остаётся во всех режимах, а цвет
пульсации по-прежнему берёт фолбэком цвет свечения лампы.
## v1.51.3 — 2026-07-29
- **Множитель «размер значка» масштабирует и сам глиф, а не только
подложку.** Изменение размера устройства растило подложку, пульсацию и
бейджи значений, но иконка внутри оставалась дефолтной — большая пустая
рамка вокруг маленького глифа (репорт пользователя). Теперь глиф считается
от того же поустройственного размера, что и всё остальное, и держит
пропорцию при любом множителе и зуме.
## v1.51.2 — 2026-07-29
**По ревью v1.51.1**
- **Авто-сетка одинакова на обеих карточках (HP-1511-01).** Полная карточка
резервирует клетки сетки за скрытыми устройствами (их призраки держат
место в редакторе); статичная уплотняла сетку по одним видимым — свежее
устройство без сохранённой позиции вставало на разных местах двух
карточек. Теперь статичная передаёт сетке полный список и по-прежнему
рисует только видимых.
- **Ripple-призрак сохраняет базовую иконку (HP-1511-02).** Скрытый маркер с
отображением «пульсация» рендерился безликим неподвижным пульсом — в
редакторе его нельзя было узнать. Призрак теперь полностью снимает
«одежду» отображения: базовая иконка и имя, каким бы ни был режим.
## v1.51.1 — 2026-07-29
**По ревью v1.51.0**
- **Статичная карточка снова учитывает скрытые устройства в LQI комнаты
(HP-1510-01).** Её фильтр видимости незаметно стал фильтром агрегации: одна
и та же комната показывала разное Zigbee-здоровье на двух карточках. Теперь
агрегация и отрисовка используют разные списки — скрытые устройства
считаются в сигнале на обеих карточках, не рисуются ни на одной и
по-прежнему не дают света.
- **Призрак не показывает живых чисел (HP-1510-02).** Скрытое устройство в
«Показать скрытые» подавляло цвета состояний, но по-прежнему рисовало
значение, температуру, влажность, бейдж LQI и морф иконки. Всё это убрано:
у призрака только базовая иконка и имя — достаточно, чтобы узнать
устройство и открыть диалог.
## v1.51.0 — 2026-07-29
**Скрытие — теперь явная галка** (docs/FILTERING.md)
+7 -2
View File
@@ -49,8 +49,13 @@ 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
+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.
+32 -9
View File
@@ -11,18 +11,20 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-07-28)
## Snapshot (2026-08-01)
| Item | State |
|---|---|
| Version | **v1.51.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.55.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` 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.51.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.2** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| 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 |
@@ -48,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)
@@ -129,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
@@ -150,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.
+105 -7
View File
@@ -206,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
@@ -244,6 +246,40 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
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
@@ -256,9 +292,11 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
[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 yellows its icon in
every fill mode by the same condition that lights the glow pool
[auto: smoke_yellow_principle + unit primaryEntity/litLightEntity]
(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]
@@ -546,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
@@ -768,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`
+8 -4
View File
@@ -59,8 +59,10 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
- Icon dragging (ONLY here). Click on a device opens the **edit dialog directly**
(binding, name, icon, size/angle, display badge/ripple + colors, tap override,
model/link/description/PDFs, room).
- + add device/entity/virtual, hide device, ↺ reset layout, 👁 show-all (curation
tool), ⬡ icon rules.
- + add device/entity/virtual, the "Hide device from plan" checkbox (since
v1.51.0 the one hiding mechanism, docs/FILTERING.md), ↺ reset layout,
👁 "Show hidden" (local editor tool; replaced the shared show-all toggle),
⬡ icon rules.
## Deprecations decided
@@ -74,7 +76,9 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
1. State-reflecting icons (open/closed door variants etc., like core HA).
2. `display: value` — show the measurement instead of an icon.
3. Light color in the icon/ripple (RGB lights).
3. Light color in the icon/ripple (RGB lights). *(Shipped in v1.27.0;
superseded in v1.52.0: the colour lives in the glow spot and the ripple
fallback only, the icon tint was removed by the owner's rule.)*
4. Alarm visual (leak/smoke/doorbell): red pulse overlay.
5. Rooms as sub-areas without an HA area + manual device placement by room id.
6. Backlog (not planned): music notes for players, directional TV ripples.
@@ -85,7 +89,7 @@ Header in View: space tabs, device count, zoom cluster. Nothing else.
edit interactions/buttons (biggest UX win, smallest surface).
- **It.2 — Plan tab:** move markup tools + space dialogs + labels drag + openings
editing under Plan; colored frame indicator.
- **It.3 — Devices tab:** drag + direct-edit click + curation tools under Devices.
- **It.3 — Devices tab:** drag + direct-edit click + filtering tools under Devices.
- **It.4+:** follow-up features 1–5 above, each its own release.
+156
View File
@@ -0,0 +1,156 @@
# Live robot vacuums on the plan — the spec (source of truth)
Status: approved by the owner 2026-07-31. Scope decisions final: all
three Tier-A adapters in P1, the trail ships in P1, the marker's placed
position IS the dock, and tap-to-clean is out — display only, no
commands (owner: «не нужно вообще»).
## Principle
The device marker «Пылесос» NEVER moves: it stands where the user
placed it — that is the base/dock — with its normal badge, states and
tap actions. While the robot cleans, a SECOND visual appears: a round
puck (the vacuum icon in a circle), no badge plate, with a soft
pulse, driving around the plan on live coordinates. Owner's exact
wording: «Иконка движущегося пылесоса должна быть круглой, без
подложки и с легкой пульсацией. Основное устройство "Пылесос" при этом
остается всегда на своем месте (база)». The puck is not clickable UI
chrome duplicating the device — a tap on it opens the same more-info as
the base marker. When the robot docks, the puck drives home and
dissolves into the base marker.
## Data tiers (auto-detected, zero user input)
- **Tier A — coordinates + own calibration data.** `vacuum_position
{x,y,angle}` (+ `calibration_points`, rooms, sometimes `path`):
Xiaomi Cloud Map Extractor, dreame-vacuum (Tasshack), Valetudo camera
(sca075). All three ship in P1.
- **Tier B — bare coordinates.** Roomba core (`position {x,y,theta}`),
raw Valetudo MQTT, template sensors. Works after manual calibration.
P3.
- **Tier C — current room only.** `current_room` / `current_segment`.
The room being cleaned gets a soft fill pulse + a 🤖 badge in the
room card; no puck. Segment→room matched by name, manual override in
the dialog.
- **Tier D — nothing.** Today's behaviour; yellow badge on the base
marker while cleaning per the «yellow = working right now» principle.
Tiers degrade gracefully: coords gone → room highlight; that gone too →
just the base marker.
## Coordinate binding
Affine transform (translate+rotate+scale+mirror), 6 numbers, least
squares over ≥3 point pairs. Stored per robot map:
`marker.vacuum.calibration[map_id]` — multi-floor robots get one matrix
per map; an active map without a matrix falls back to Tier C.
- **Path 1, auto-calibration by rooms (the default):** Tier-A adapters
expose the robot's room list with coordinates; our rooms carry HA
area bindings. Match by name, take centroids of ≥3 matches, solve,
show a live preview («робот сейчас здесь») — one click to confirm.
- **Path 2, the fit panel (replaced the 3-point wizard, owner call
2026-07-31):** the robot's rooms render as a translucent dashed ghost
over the plan; the user DRAGS the ghost into place and stretches it by
its four corner handles (uniform scale about the opposite corner, like
a graphics-editor frame). Rotation is quarter-turn buttons, mirror is
one toggle — both re-anchor about the ghost centre. Mirror defaults ON:
every robot map seen so far has Y flipped versus the screen. No numeric
fields; the old park-the-robot-three-times wizard is gone entirely —
it was the most fragile part of the feature.
- The panel folds into the same stored 6-number matrix
(S·R(rot)·mirror + offset); legacy matrices reopen in the panel with
the rotation snapped to the nearest quarter.
## Puck behaviour
Appears when the robot leaves `docked` AND live coords flow; CSS
interpolation ~1.2 s between updates; a data gap over 10 s teleports
without animation (no gliding through walls on sparse cloud updates).
Soft pulse always while visible (respects prefers-reduced-motion: the
pulse freezes, position still animates). Size: same --icon-size
system as devices, circle, transparent background, no badge plate.
Stale coords (>60 s while «cleaning»): puck freezes and dims. On
`docked`/`idle`-at-base the puck rides home and fades out. Hidden
markers render neither base nor puck (general hidden rules). Works in
the full card, static card and kiosk; in editors no puck — the base
marker is edited as usual.
## Trail (ships in P1)
- Integration `path` when available (Map Extractor) — transform and
draw as-is, includes history from before the card was opened.
- Otherwise self-recorded: client-side ring buffer, ~600 points with
Douglas-Peucker thinning, one SVG polyline.
- Style fixed, no options: cartography casing — a dark translucent halo under a light core. Neutral (pure black/white alphas) and readable over any room fill; blend modes were rejected: each has a blind luminance where the line vanishes, and mix-blend-mode is costly on old kiosk WebViews. Lifecycle:
appears on cleaning start, lives until dock + 10 min, dissolves;
`docked → cleaning` clears the old trail. Recorded SERVER-SIDE by the integration itself (trails.py): it watches the source entity, so the path records with zero cards open and every screen sees the same line. Stored per marker: the current run and ONE previous run (owner call — users compare cleaned vs uncleaned). The previous run renders at 40% opacity even at rest; the current run trims its live tail while moving. Rotation on run start or map switch; 2000-point cap with decimation; store writes debounced 10 s; houseplan_trail_updated notifies live cards.
- Marker option «Показывать след уборки», on by default where data
exists.
## Setup UX
A «Живая позиция» section in the device dialog, vacuum markers only:
status line (which source was found / nothing), the «Настроить
автоматически» button (Tier A — the integration reports a room list),
the «Подогнать вручную» button opening the fit panel (drag the ghost
map, stretch by the corners, rotate/mirror), one «Живая позиция на
плане» checkbox (on by default), the «Показывать путь робота» select
(never / while cleaning / always), and for multi-map robots a list of
calibrated maps. The source entity is discovered via the device
registry — no YAML, no entity pickers. The section works before the
marker is ever saved: the first vacuum edit materialises the marker
itself (HP-1540-01).
## Storage & validation
`marker.vacuum: { live, trail, room_highlight, source, calibration:
{[map_id]: [6 numbers]}, segment_map: {[segment]: room_id} }` — all
optional (old configs stay valid), matrices are 6 finite numbers,
backend-validated. Calibration is server-side state shared by every
screen, like the whole plan.
## Cases covered explicitly
Two vacuums (independent markers, calibrations, pucks); one robot on
two floors (maps ↔ spaces, the puck only renders in the space whose map
is active); robot outside the plan (±4 canvas bounds allow it, trail
clipped by viewport); integration restart changes map_id (rebind by
room-list match, else ask to recalibrate); user redraws rooms (the
matrix does not depend on rooms); demo stand gets a scripted synthetic
robot as the showcase.
## Out of scope
Commands of any kind (owner decision: display only) — no tap-to-clean,
no zone sending. No-go zones, cleaned-area polygons, cleaning history,
multi-robot collision avoidance — v2 candidates.
## Trail display modes
`marker.vacuum.trail_mode`: `never` | `cleaning` (default — the line hides
the instant the run ends) | `always` (the only mode that also draws the
previous run, at 40% opacity). The legacy boolean `trail` still maps in
(`false` → never). Recording is independent of the mode: the server always
records, the mode only decides what is drawn.
The last segment is a rAF-driven tip line whose endpoint is glued to the
puck's animated centre every frame, so the path pours out from under the
icon instead of popping in when the next telemetry point lands.
## Phases
- **P1:** adapter framework + all three Tier-A adapters +
auto-calibration by rooms + manual 3-point wizard + the puck + trail (both sources).
- **P2 (next):** Tier C room highlight + the demo-stand scripted robot.
- **P3:** Tier B zoo (Roomba, raw Valetudo MQTT), Deebot, multi-map
polish by feedback.
## Shipped in P1
Adapters for the three Tier-A integrations, auto-calibration by room
names, the drag-and-stretch fit panel, the puck, server-side trails
(current + previous run) with the three display modes. Verified against a
live Dreame X50 Master: room centres arrive as plain x/y, the active map
name lives on the vacuum entity (`selected_map`), and the robot's Y axis
is flipped versus the screen — hence mirror-on by default.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.51.0",
"version": "1.55.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.51.0",
"version": "1.55.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.51.0",
"version": "1.55.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+1358 -44
View File
File diff suppressed because it is too large Load Diff
+48 -2
View File
@@ -35,6 +35,9 @@
"markup.add": "Add",
"markup.merge": "Merge",
"markup.split": "Split",
"markup.resize": "Resize",
"title.markup_resize": "Resize rooms: drag a wall handle; click a room for a corner scale frame",
"markup.hint_resize": "drag a wall handle · click a room — corner frame · Esc cancels a drag · Ctrl+Z — undo a step",
"markup.opening": "Opening",
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
"opening.new": "New opening",
@@ -340,5 +343,48 @@
"toast.plans_list_failed": "Could not list the stored plans: {err}",
"toast.plan_delete_failed": "Could not delete the plan: {err}",
"marker.hide": "Hide device from plan",
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor."
}
"marker.hide_tip": "The device is not drawn on the plan but still counts toward the room signal. To see it: the \"Show hidden\" button in the device editor.",
"tap.run": "Run automation/script/scene",
"marker.run_target_label": "What to run",
"marker.run_search_ph": "Search: automation, script or scene…",
"marker.run_target_gone": "Target {id} not found — pick again",
"marker.tap_confirm": "Ask for confirmation",
"marker.tap_confirm_tip": "Show a confirmation dialog before acting — a guard against accidental taps.",
"run.automation": "automation",
"run.script": "script",
"run.scene": "scene",
"confirm.tap_run": "Run \"{name}\"?",
"confirm.tap_toggle": "Toggle \"{name}\"?",
"toast.run_started": "Started: {name}",
"toast.run_target_missing": "Run target not found — check the device settings",
"toast.run_target_required": "Pick an automation, script or scene",
"btn.run": "Run",
"vac.section": "Robot vacuum: live position",
"vac.status_found": "Position source found: {name}",
"vac.status_none": "The integration reports no coordinates — the robot will only be shown at its base",
"vac.autocal": "Set up automatically",
"vac.live": "Live position on the plan",
"vac.trail": "Show the robot's path",
"vac.cal_maps": "Calibrated maps: {maps}",
"vac.autocal_no_rooms": "The integration reports no room list — open “Fit manually”",
"vac.autocal_no_match": "Room names did not match (need ≥3 in common) — open “Fit manually”",
"vac.autocal_res_warn": "Matched {rooms} rooms but the fit is rough — verify and refine via “Fit manually” if needed",
"vac.autocal_done": "Done: bound via {rooms} rooms. Start a cleanup and check",
"vac.cal_need_pos": "The robot is not reporting coordinates — start a cleanup and pause it",
"vac.cal_done": "Calibration saved. Start a cleanup and check",
"vac.cal_cancelled": "Calibration cancelled",
"vac.fit": "Fit manually",
"vac.fit_hint": "Drag the robot map into place, stretch by the corners",
"vac.fit_rotate": "Rotate 90°",
"vac.fit_mirror": "Mirror",
"vac.trail_never": "Never",
"vac.trail_cleaning": "While cleaning",
"vac.trail_always": "Always",
"gs.bg_group": "Stage background",
"gs.bg_color": "Background around the plan",
"gs.bg_default": "Theme default",
"gs.bg_theme": "theme default",
"space.bg_color": "Background around the plan",
"space.bg_inherit": "Inherit general",
"space.bg_inherited": "inherits general settings"
}
+48 -2
View File
@@ -35,6 +35,9 @@
"markup.add": "Добавить",
"markup.merge": "Объединить",
"markup.split": "Разделить",
"markup.resize": "Размер",
"title.markup_resize": "Изменение размера комнат: тяните ручку стены; клик по комнате — угловая рамка масштаба",
"markup.hint_resize": "тяните ручку стены · клик по комнате — угловая рамка · Esc отменяет перетаскивание · Ctrl+Z — отмена шага",
"markup.opening": "Проём",
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
"opening.new": "Новый проём",
@@ -340,5 +343,48 @@
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
"marker.hide": "Скрыть устройство с плана",
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств."
}
"marker.hide_tip": "Устройство не отображается на плане, но участвует в расчёте сигнала комнаты. Показать: кнопка «Показать скрытые» в редакторе устройств.",
"tap.run": "Запустить автоматизацию/скрипт/сцену",
"marker.run_target_label": "Что запускать",
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
"marker.tap_confirm": "Спрашивать подтверждение",
"marker.tap_confirm_tip": "Перед выполнением показать диалог подтверждения — защита от случайных нажатий.",
"run.automation": "автоматизация",
"run.script": "скрипт",
"run.scene": "сцена",
"confirm.tap_run": "Запустить «{name}»?",
"confirm.tap_toggle": "Переключить «{name}»?",
"toast.run_started": "Запущено: {name}",
"toast.run_target_missing": "Цель запуска не найдена — проверьте настройки устройства",
"toast.run_target_required": "Выберите автоматизацию, скрипт или сцену",
"btn.run": "Выполнить",
"vac.section": "Робот-пылесос: живая позиция",
"vac.status_found": "Источник координат найден: {name}",
"vac.status_none": "Интеграция не отдаёт координаты — робот будет показан только на базе",
"vac.autocal": "Настроить автоматически",
"vac.live": "Живая позиция на плане",
"vac.trail": "Показывать путь робота",
"vac.cal_maps": "Откалиброваны карты: {maps}",
"vac.autocal_no_rooms": "Интеграция не отдаёт список комнат — откройте «Подогнать вручную»",
"vac.autocal_no_match": "Не совпали имена комнат (нужно ≥3 общих) — откройте «Подогнать вручную»",
"vac.autocal_res_warn": "Совпало комнат: {rooms}, но привязка грубовата — проверьте и при необходимости откройте «Подогнать вручную»",
"vac.autocal_done": "Готово: привязка по {rooms} комнатам. Запустите уборку и проверьте",
"vac.cal_need_pos": "Робот сейчас не отдаёт координаты — запустите уборку и поставьте на паузу",
"vac.cal_done": "Калибровка сохранена. Запустите уборку и проверьте",
"vac.cal_cancelled": "Калибровка отменена",
"vac.fit": "Подогнать вручную",
"vac.fit_hint": "Перетащите карту робота на место, растяните за уголки",
"vac.fit_rotate": "Повернуть 90°",
"vac.fit_mirror": "Отразить",
"vac.trail_never": "Не показывать никогда",
"vac.trail_cleaning": "Во время уборки",
"vac.trail_always": "Показывать всегда",
"gs.bg_group": "Фон сцены",
"gs.bg_color": "Цвет фона вокруг плана",
"gs.bg_default": "Как в теме",
"gs.bg_theme": "по умолчанию — из темы",
"space.bg_color": "Цвет фона вокруг плана",
"space.bg_inherit": "Наследовать общий",
"space.bg_inherited": "наследуется из общих настроек"
}
+49 -5
View File
@@ -596,7 +596,7 @@ export function safeUrl(url: string | null | undefined): string | null {
// ---------------- tap actions ----------------
export type TapAction = 'info' | 'more-info' | 'toggle';
export type TapAction = 'info' | 'more-info' | 'toggle' | 'run';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
/**
@@ -615,12 +615,16 @@ export type TapAction = 'info' | 'more-info' | 'toggle';
* Adding an option here and forgetting the schema now fails the test suite.
*/
export const DISPLAY_MODES = ['badge', 'ripple', 'icon_ripple', 'value'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle'] as const;
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run'] as const;
/** Space-level fill: 'glow' is a whole-space light model, not a per-room one. */
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'glow'] as const;
// 'glow' leads: it is the default for new spaces since v1.54 — the owner's
// call, it sells the card best. Existing configs keep whatever they chose;
// an absent fill_mode still falls back to 'none' (spaceDisplayOf), so an
// update never repaints somebody's plan.
export const SPACE_FILL_MODES = ['glow', 'none', 'lqi', 'light', 'temp'] as const;
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp'] as const;
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
/**
* Domains that must NEVER toggle from a plan tap, even with an explicit
@@ -637,10 +641,16 @@ export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel'])
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
*/
/** Cover classes that stay OUT of the card-wide toggle: an accidental tap
* opening the garage or the driveway gate is a security matter, like locks.
* An explicit per-device toggle remains the owner's conscious choice. */
export const COVER_GUARDED_CLASSES = new Set(['garage', 'door', 'gate']);
export function resolveTapAction(
explicit: string | null | undefined,
cardDefault: string | null | undefined,
domain: string | null | undefined,
deviceClass?: string | null,
): TapAction {
// Pure light sources (the device's PRIMARY function is a lamp: bulbs,
// chandeliers, night lights, light groups) toggle by default — no explicit
@@ -648,10 +658,30 @@ export function resolveTapAction(
// backlight) have a non-light primary and keep the info default.
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
if (want === 'more-info') return 'more-info';
// 'run' is EXPLICIT-only by construction: it needs a per-marker target, so
// it can never arrive as a card-wide default
if (want === 'run') return explicit === 'run' ? 'run' : 'info';
if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
if (explicit === 'toggle') return 'toggle';
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
if (!TOGGLE_SAFE_DOMAINS.has(domain)) return 'info';
// covers joined the safe set for curtains and blinds (owner, 2026-07-29) —
// but the garage door is a cover too, and it stays shut on a default tap
if (domain === 'cover' && COVER_GUARDED_CLASSES.has(String(deviceClass || ''))) return 'info';
return 'toggle';
}
/** Domains a tap may RUN (owner's spec 2026-07-29): the runnable units of
* HA. An automation is triggered, a script and a scene are turned on. */
export const RUN_TARGET_DOMAINS = ['automation', 'script', 'scene'] as const;
/** Service to start a runnable target, or null when the id is not runnable. */
export function runServiceFor(target: string | null | undefined): { domain: string; service: string } | null {
const dom = String(target || '').split('.')[0];
if (dom === 'automation') return { domain: 'automation', service: 'trigger' };
if (dom === 'script') return { domain: 'script', service: 'turn_on' };
if (dom === 'scene') return { domain: 'scene', service: 'turn_on' };
return null;
}
// ---------------- floors import ----------------
@@ -709,6 +739,8 @@ export interface SpaceDisplay {
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
/** Background around the plan; null = inherit the global setting / theme. */
bgColor: string | null;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -736,9 +768,21 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
bgColor: typeof s.bg_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.bg_color) ? s.bg_color : null,
};
}
/**
* Effective background color around the plan: the per-space override wins,
* then the global config.settings.bg_color, then '' — meaning "keep the
* theme default" (the stage's stylesheet background stays untouched).
*/
export function stageBgOf(settings: any, disp: { bgColor: string | null }): string {
if (disp.bgColor) return disp.bgColor;
const g = settings?.bg_color;
return typeof g === 'string' && /^#[0-9a-f]{6}$/i.test(g) ? g : '';
}
// ---------------- global fill colors ----------------
export interface FillColorEntry {
+605
View File
@@ -0,0 +1,605 @@
/**
* Room resize geometry — pure functions only (docs/RESIZE.md).
*
* Mechanism A: dragging a wall along its normal, shared stretches of
* neighbours move together (T-junctions insert vertices). Mechanism B:
* uniform scale of one room about a bbox corner. Every stop («упор») is
* decided here so it can be unit-tested; the card only wires pointers.
*
* All coordinates are render units (NORM_W-scaled), same as the card's
* space model. Nothing here touches Lit or the DOM.
*/
import { intersection } from 'polyclip-ts';
import {
polygonArea, segmentsProperlyCross, polyContainsPoly, roomsOverlap,
} from './logic';
/** Minimal room dimension in centimetres (owner: «мин. габарит ~30 см»). */
export const MIN_ROOM_CM = 30;
export interface RoomIn { id: string; poly: number[][] }
/** Opening in render units: centre, wall angle (deg), full length. */
export interface OpeningIn { id: string; x: number; y: number; length: number }
export interface EdgeDragPlan {
roomId: string;
edge: number; // edge index i: v[i] -> v[i+1]
a: number[]; // edge endpoints BEFORE the drag
b: number[];
n: [number, number]; // outward unit normal (d > 0 grows the room)
}
export interface EdgeDragResult {
/** roomId -> new outline (only rooms that changed). */
polys: Record<string, number[][]>;
/** openingId -> new centre (only openings that travelled with the wall). */
openings: Record<string, [number, number]>;
/** roomId -> the moved stretches AFTER the move (for clearance/labels). */
movedSpans: Record<string, [number[], number[]][]>;
}
// ---------------- tiny vector helpers ----------------
const sub = (p: number[], q: number[]) => [p[0] - q[0], p[1] - q[1]];
const add2 = (p: number[], d: number[]) => [p[0] + d[0], p[1] + d[1]];
const dot = (p: number[], q: number[]) => p[0] * q[0] + p[1] * q[1];
const len2d = (p: number[]) => Math.hypot(p[0], p[1]);
function signedArea(poly: number[][]): number {
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return s / 2;
}
function pointInPoly(p: number[], poly: number[][]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function distPointToSpan(p: number[], a: number[], b: number[]): number {
const ab = sub(b, a);
const l2 = dot(ab, ab);
if (l2 < 1e-12) return len2d(sub(p, a));
let t = dot(sub(p, a), ab) / l2;
t = Math.max(0, Math.min(1, t));
return len2d(sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t]));
}
/**
* Outward unit normal of edge i. Candidate is the +90° rotation of the edge
* direction; a probe point decides the sign, so polygon orientation (either
* winding survives in real configs) does not matter.
*/
export function edgeNormal(poly: number[][], i: number): [number, number] {
const a = poly[i], b = poly[(i + 1) % poly.length];
const d = sub(b, a);
const l = len2d(d) || 1;
let n: [number, number] = [d[1] / l, -d[0] / l];
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const probe = Math.max(l * 0.01, 1e-4);
if (pointInPoly([mid[0] + n[0] * probe, mid[1] + n[1] * probe], poly)) n = [-n[0], -n[1]];
return n;
}
/** Translate BOTH endpoints of edge i by the normal times d (docs/RESIZE.md, mechanism A). */
export function movePolyEdge(poly: number[][], i: number, d: number, n?: [number, number]): number[][] {
const nn = n || edgeNormal(poly, i);
const j = (i + 1) % poly.length;
return poly.map((p, k) => (k === i || k === j ? [p[0] + nn[0] * d, p[1] + nn[1] * d] : [...p]));
}
/** Collinear overlap stretches of `poly`'s edges with segment a-b, as [p,q] pairs (pre-move). */
export function sharedSpansWith(poly: number[][], a: number[], b: number[], eps: number): [number[], number[]][] {
const out: [number[], number[]][] = [];
const ab = sub(b, a);
const L = len2d(ab);
if (L < eps) return out;
const u = [ab[0] / L, ab[1] / L];
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const off1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
const off2 = Math.abs((q2[0] - a[0]) * u[1] - (q2[1] - a[1]) * u[0]);
if (off1 > eps || off2 > eps) continue; // not collinear with a-b
const t1 = dot(sub(q1, a), u);
const t2 = dot(sub(q2, a), u);
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(L, Math.max(t1, t2));
if (hi - lo > eps) out.push([[a[0] + u[0] * lo, a[1] + u[1] * lo], [a[0] + u[0] * hi, a[1] + u[1] * hi]]);
}
return out;
}
/**
* Neighbour sync: translate the stretches of `poly` that coincide with segment
* a-b by vector D, inserting vertices at partial-contact boundaries (T-junction
* → the neighbour may become L-shaped). Returns null when nothing coincides.
*/
export function shiftSharedSpans(
poly: number[][], a: number[], b: number[], D: [number, number], eps: number,
): number[][] | null {
const spans = sharedSpansWith(poly, a, b, eps);
if (!spans.length) return null;
const onSpan = (p: number[]) => spans.some(([p1, p2]) => distPointToSpan(p, p1, p2) <= eps);
const n = poly.length;
const out: number[][] = [];
for (let j = 0; j < n; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % n];
out.push(onSpan(q1) ? add2(q1, D) : [...q1]);
const e = sub(q2, q1);
const elen = len2d(e);
if (elen < eps) continue;
const u = [e[0] / elen, e[1] / elen];
// collinear with a-b? (both endpoints on the a-b LINE)
const abL = len2d(sub(b, a)) || 1;
const uv = [(b[0] - a[0]) / abL, (b[1] - a[1]) / abL];
const o1 = Math.abs((q1[0] - a[0]) * uv[1] - (q1[1] - a[1]) * uv[0]);
const o2 = Math.abs((q2[0] - a[0]) * uv[1] - (q2[1] - a[1]) * uv[0]);
if (o1 > eps || o2 > eps) continue;
const tA = dot(sub(a, q1), u);
const tB = dot(sub(b, q1), u);
const lo = Math.max(0, Math.min(tA, tB));
const hi = Math.min(elen, Math.max(tA, tB));
if (hi - lo <= eps) continue;
// interior boundaries split the edge: entering the overlap emits the static
// point then its moved copy, leaving it emits the moved copy then the static
if (lo > eps && lo < elen - eps) {
const p = [q1[0] + u[0] * lo, q1[1] + u[1] * lo];
out.push([...p], add2(p, D));
}
if (hi > eps && hi < elen - eps) {
const p = [q1[0] + u[0] * hi, q1[1] + u[1] * hi];
out.push(add2(p, D), [...p]);
}
}
return out;
}
/** Drop consecutive duplicates and collinear middle vertices (commit-time cleanup). */
export function simplifyPoly(poly: number[][], eps = 1e-6): number[][] {
let pts = poly.filter((p, i) => len2d(sub(p, poly[(i + 1) % poly.length])) > eps);
for (let pass = 0; pass < 2; pass++) {
pts = pts.filter((p, i) => {
const prev = pts[(i - 1 + pts.length) % pts.length];
const next = pts[(i + 1) % pts.length];
const cross = (p[0] - prev[0]) * (next[1] - prev[1]) - (p[1] - prev[1]) * (next[0] - prev[0]);
const span = len2d(sub(next, prev)) || 1;
return Math.abs(cross) / span > eps; // keep only real corners
});
}
return pts.length >= 3 ? pts : poly;
}
/** Simple polygon: no properly crossing edges (shared walls touching is fine). */
export function polyIsSimple(poly: number[][]): boolean {
const n = poly.length;
if (n < 3) return false;
for (let i = 0; i < n; i++)
for (let j = i + 1; j < n; j++) {
if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
if (segmentsProperlyCross(poly[i], poly[(i + 1) % n], poly[j], poly[(j + 1) % n])) return false;
}
return true;
}
/**
* Normal clearance between the moved stretches and every PARALLEL wall of the
* same room with an overlapping projection — the «opposite wall» distance that
* enforces the 30 cm minimum. Infinity when no opposite wall exists.
*/
export function minParallelClearance(
poly: number[][], spans: [number[], number[]][], eps = 1e-6,
): number {
let best = Infinity;
for (const [a, b] of spans) {
const ab = sub(b, a);
const L = len2d(ab);
if (L < eps) continue;
const u = [ab[0] / L, ab[1] / L];
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const e = sub(q2, q1);
const elen = len2d(e);
if (elen < eps) continue;
const cosang = Math.abs((e[0] * u[0] + e[1] * u[1]) / elen);
if (cosang < 1 - 1e-4) continue; // not parallel
// projection overlap along the span direction
const t1 = dot(sub(q1, a), u);
const t2 = dot(sub(q2, a), u);
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(L, Math.max(t1, t2));
if (hi - lo <= eps) continue;
const d1 = Math.abs((q1[0] - a[0]) * u[1] - (q1[1] - a[1]) * u[0]);
if (d1 <= eps) continue; // the span itself / collinear leftovers
if (d1 < best) best = d1;
}
}
return best;
}
/**
* HP-1550-02: orientation-independent clearance of the moved stretches.
*
* minParallelClearance only saw PARALLEL opposite walls, so a triangle (no
* parallel wall at all) reported Infinity and the 30 cm floor was simply off —
* the base could be dragged to a 5-unit sliver. This measure looks at the whole
* band the span sweeps along its normal: every vertex strictly inside the band
* and every edge crossing the band interior counts with its perpendicular
* distance from the span line. Two exclusions keep it honest:
* - anything ON the span line (offset ≤ eps) is the span itself, a collinear
* wall remainder or a T-insert — not an opposite obstacle;
* - the band ENDS (projection ≤ eps or ≥ L − eps) are excluded, so the
* |d|-long step edge a T-junction inserts at the very end of the span does
* not read as a paper-thin room on every small drag.
* Offsets cannot change sign inside the band (that would cross the span —
* polyIsSimple already rejected it), so an edge's minimum lies at a clip bound.
* Infinity still means «nothing opposite at all» (e.g. growing outward).
*/
export function minSpanClearance(
poly: number[][], spans: [number[], number[]][], eps = 1e-6,
): number {
let best = Infinity;
for (const [a, b] of spans) {
const ab = sub(b, a);
const L = len2d(ab);
if (L < eps) continue;
const u = [ab[0] / L, ab[1] / L];
const soff = (p: number[]) => (p[0] - a[0]) * u[1] - (p[1] - a[1]) * u[0];
const tOf = (p: number[]) => (p[0] - a[0]) * u[0] + (p[1] - a[1]) * u[1];
const lo = eps, hi = L - eps;
for (const v of poly) {
const o = Math.abs(soff(v));
if (o <= eps) continue;
const tv = tOf(v);
if (tv <= lo || tv >= hi) continue;
if (o < best) best = o;
}
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const o1 = soff(q1), o2 = soff(q2);
if (Math.abs(o1) <= eps || Math.abs(o2) <= eps) continue; // attached to the moving wall
const t1 = tOf(q1), t2 = tOf(q2);
const tlo = Math.max(lo, Math.min(t1, t2));
const thi = Math.min(hi, Math.max(t1, t2));
if (thi - tlo <= eps) continue; // casts no shadow on the span interior
const dt = t2 - t1;
if (Math.abs(dt) < eps) { // perpendicular-ish edge fully inside the band
best = Math.min(best, Math.abs(o1), Math.abs(o2));
continue;
}
const offAt = (tt: number) => Math.abs(o1 + ((tt - t1) / dt) * (o2 - o1));
best = Math.min(best, offAt(tlo), offAt(thi));
}
}
return best;
}
/** Convex hull (monotone chain) — only the width measure below needs it. */
function convexHull(pts: number[][]): number[][] {
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (p.length < 3) return p;
const cross = (o: number[], a: number[], b: number[]) =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower: number[][] = [];
for (const pt of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
lower.push(pt);
}
const upper: number[][] = [];
for (let i = p.length - 1; i >= 0; i--) {
const pt = p[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
upper.push(pt);
}
lower.pop(); upper.pop();
return lower.concat(upper);
}
/**
* HP-1550-02: the TRUE minimum width of a polygon — rotating calipers over the
* convex hull (the min over hull edge directions of the perpendicular extent).
* The axis-aligned bbox lied under rotation: a 500×100 rectangle turned 45° has
* a ≈424×424 bbox, so a 0.1 scale slid the real 100-side down to 10 unchecked.
* A similarity scales every distance by k, which makes k·minPolyWidth exact —
* and a concave room is judged by its overall silhouette, so a small notch
* that takes no part in the operation cannot veto a legal scale.
*/
export function minPolyWidth(poly: number[][]): number {
const h = convexHull(poly);
if (h.length < 3) return 0;
let best = Infinity;
for (let i = 0; i < h.length; i++) {
const a = h[i], b = h[(i + 1) % h.length];
const e = sub(b, a);
const L = len2d(e);
if (L < 1e-12) continue;
const u = [e[0] / L, e[1] / L];
let w = 0;
for (const pt of h) w = Math.max(w, Math.abs((pt[0] - a[0]) * u[1] - (pt[1] - a[1]) * u[0]));
if (w < best) best = w;
}
return Number.isFinite(best) ? best : 0;
}
/**
* Do two outlines ILLEGALLY share floor area? `roomsOverlap` alone misses the
* «slide-over» case: equal-height rectangles overlapping horizontally have all
* their edge intersections on collinear stretches, so nothing «properly
* crosses» and nothing is strictly inside. A real polygon intersection area
* settles it; legal full nesting (island rooms) stays legal.
*/
export function illegalOverlap(a: number[][], b: number[][], eps: number): boolean {
if (roomsOverlap(a, b, eps)) return true;
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
let area = 0;
try {
const res = intersection(
[[...a.map((p) => [p[0], p[1]]), [a[0][0], a[0][1]]]] as any,
[[...b.map((p) => [p[0], p[1]]), [b[0][0], b[0][1]]]] as any,
);
for (const poly of res as any) if (poly?.[0]) area += polygonArea(poly[0]);
} catch {
return false; // a degenerate clip must not block the drag; the other stops still hold
}
return area > Math.max(1e-7, eps * eps);
}
// ---------------- mechanism A: the full drag pipeline ----------------
export function planEdgeDrag(rooms: RoomIn[], roomId: string, edge: number): EdgeDragPlan | null {
const room = rooms.find((r) => r.id === roomId);
if (!room || !room.poly || room.poly.length < 3) return null;
if (edge < 0 || edge >= room.poly.length) return null;
const a = [...room.poly[edge]];
const b = [...room.poly[(edge + 1) % room.poly.length]];
return { roomId, edge, a, b, n: edgeNormal(room.poly, edge) };
}
/** Apply the drag at distance d: own edge + every coinciding neighbour stretch. */
export function applyEdgeDrag(
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, eps: number,
): EdgeDragResult {
const D: [number, number] = [plan.n[0] * d, plan.n[1] * d];
const res: EdgeDragResult = { polys: {}, openings: {}, movedSpans: {} };
if (Math.abs(d) < 1e-9) return res;
for (const r of rooms) {
if (r.id === plan.roomId) {
res.polys[r.id] = movePolyEdge(r.poly, plan.edge, d, plan.n);
res.movedSpans[r.id] = [[add2(plan.a, D), add2(plan.b, D)]];
continue;
}
const spans = sharedSpansWith(r.poly, plan.a, plan.b, eps);
if (!spans.length) continue;
const shifted = shiftSharedSpans(r.poly, plan.a, plan.b, D, eps);
if (shifted) {
res.polys[r.id] = shifted;
res.movedSpans[r.id] = spans.map(([p, q]) => [add2(p, D), add2(q, D)] as [number[], number[]]);
}
}
// openings ON the moving wall travel with it (docs/RESIZE.md: anchors)
for (const o of openings) {
if (distPointToSpan([o.x, o.y], plan.a, plan.b) <= eps) res.openings[o.id] = [o.x + D[0], o.y + D[1]];
}
return res;
}
/** An opening must sit fully on ONE wall of some room: centre on the edge, both ends within it. */
function openingFits(o: { x: number; y: number; length: number }, polys: number[][][], eps: number): boolean {
for (const poly of polys) {
for (let j = 0; j < poly.length; j++) {
const q1 = poly[j], q2 = poly[(j + 1) % poly.length];
const e = sub(q2, q1);
const elen = len2d(e);
if (elen < eps) continue;
const u = [e[0] / elen, e[1] / elen];
const off = Math.abs((o.x - q1[0]) * u[1] - (o.y - q1[1]) * u[0]);
if (off > eps) continue;
const t = (o.x - q1[0]) * u[0] + (o.y - q1[1]) * u[1];
if (t - o.length / 2 >= -eps && t + o.length / 2 <= elen + eps) return true;
}
}
return false;
}
/** Openings that sit on any wall of any of the given (pre-move) outlines. */
function openingsOnRooms(openings: OpeningIn[], polys: number[][][], eps: number): OpeningIn[] {
return openings.filter((o) =>
polys.some((poly) => {
for (let j = 0; j < poly.length; j++)
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
return false;
}),
);
}
export interface StopOpts {
minDim: number; // canvas units (≈30 cm through cell_cm)
eps: number; // collinearity epsilon (canvas units)
}
/** All the stops of docs/RESIZE.md for one candidate distance. */
export function validateEdgeDrag(
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, d: number, opts: StopOpts,
): boolean {
const { minDim, eps } = opts;
if (!Number.isFinite(d)) return false;
if (Math.abs(d) < 1e-9) return true;
const res = applyEdgeDrag(rooms, openings, plan, d, eps);
const changedIds = Object.keys(res.polys);
const newPolyOf = (r: RoomIn) => res.polys[r.id] || r.poly;
for (const id of changedIds) {
const r = rooms.find((x) => x.id === id)!;
const np = res.polys[id];
// simple + orientation preserved + not degenerate
if (!polyIsSimple(np)) return false;
const s0 = signedArea(r.poly), s1 = signedArea(np);
if (Math.abs(s1) < eps || s0 * s1 <= 0) return false;
// minimum size: orientation-independent clearance of the moved stretches
// (HP-1550-02 — the parallel-walls-only measure left triangles and other
// non-parallel geometry without the 30 cm floor); a room already thinner
// keeps its clearance (improving is allowed, worsening is not)
const oldSpans: [number[], number[]][] = id === plan.roomId
? [[plan.a, plan.b]]
: sharedSpansWith(r.poly, plan.a, plan.b, eps);
const cOld = minSpanClearance(r.poly, oldSpans, eps);
const cNew = minSpanClearance(np, res.movedSpans[id] || [], eps);
if (cNew < Math.min(minDim, cOld) - eps) return false;
// every pre-existing room relationship must SURVIVE the drag: an island
// stays an island (a jump fully past a thin island crosses no edge, so
// containment is checked explicitly), a nested room stays inside its
// parent, and unrelated rooms must not start sharing area or nesting
for (const other of rooms) {
if (other.id === id) continue;
const otherNew = newPolyOf(other);
if (polyContainsPoly(r.poly, other.poly, eps)) { // our island
if (!polyContainsPoly(np, otherNew, eps)) return false;
continue;
}
if (polyContainsPoly(other.poly, r.poly, eps)) { // we are the island
if (!polyContainsPoly(otherNew, np, eps)) return false;
continue;
}
if (polyContainsPoly(np, otherNew, eps) || polyContainsPoly(otherNew, np, eps)) return false;
if (illegalOverlap(np, otherNew, eps)) return false;
}
}
// openings: everything that sat on a wall of an affected room must still fit
const oldPolys = changedIds.map((id) => rooms.find((x) => x.id === id)!.poly);
const allNew = rooms.map(newPolyOf);
for (const o of openingsOnRooms(openings, oldPolys, eps * 2)) {
const c = res.openings[o.id];
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
if (!openingFits(moved, allNew, eps * 2)) return false;
}
return true;
}
/**
* Largest valid distance toward dWanted, stepping back by `step` (the grid
* pitch, so a stopped wall still lands on the grid). 0 = the wall stays put.
*/
export function clampEdgeDrag(
rooms: RoomIn[], openings: OpeningIn[], plan: EdgeDragPlan, dWanted: number, step: number, opts: StopOpts,
): number {
if (!Number.isFinite(dWanted) || Math.abs(dWanted) < 1e-9) return 0;
const sign = Math.sign(dWanted);
let mag = Math.abs(dWanted);
const s = Math.max(step, 1e-6);
for (let guard = 0; guard < 4096 && mag > 1e-9; guard++, mag -= s) {
const d = sign * mag;
if (validateEdgeDrag(rooms, openings, plan, d, opts)) return d;
}
return 0;
}
// ---------------- mechanism B: the scale frame ----------------
export interface ScaleResult {
poly: number[][];
openings: Record<string, [number, number]>;
}
/** Uniform scale of the room about `fixed`; exclusive openings follow, shared ones stay. */
export function applyRoomScale(
room: RoomIn, openings: OpeningIn[], otherPolys: number[][][], fixed: [number, number], k: number, eps: number,
): ScaleResult {
const scalePt = (p: number[]) => [fixed[0] + (p[0] - fixed[0]) * k, fixed[1] + (p[1] - fixed[1]) * k];
const res: ScaleResult = { poly: room.poly.map(scalePt), openings: {} };
for (const o of openings) {
let on = false;
for (let j = 0; j < room.poly.length; j++)
if (distPointToSpan([o.x, o.y], room.poly[j], room.poly[(j + 1) % room.poly.length]) <= eps) { on = true; break; }
if (!on) continue;
// an opening on a wall shared with a neighbour belongs to the neighbour's
// wall once the scale detaches ours — it stays put (docs/RESIZE.md)
const shared = otherPolys.some((poly) => {
for (let j = 0; j < poly.length; j++)
if (distPointToSpan([o.x, o.y], poly[j], poly[(j + 1) % poly.length]) <= eps) return true;
return false;
});
if (!shared) {
const c = scalePt([o.x, o.y]);
res.openings[o.id] = [c[0], c[1]];
}
}
return res;
}
/** Stops for one candidate scale factor (neighbours are never dragged along). */
export function validateRoomScale(
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], k: number, opts: StopOpts,
): boolean {
const { minDim, eps } = opts;
if (!Number.isFinite(k) || k <= 0) return false;
if (Math.abs(k - 1) < 1e-9) return true;
const room = rooms.find((r) => r.id === roomId);
if (!room) return false;
const otherPolys = rooms.filter((r) => r.id !== roomId).map((r) => r.poly);
const res = applyRoomScale(room, openings, otherPolys, fixed, k, eps * 2);
const np = res.poly;
// minimum size: a similarity scales every distance by exactly k, so the TRUE
// minimum width of the original scales to k·w0 (HP-1550-02 — the axis-aligned
// bbox side let a rotated rectangle shrink its real short side unchecked);
// an already-thin room keeps the improve-only rule
const w0 = minPolyWidth(room.poly);
if (w0 * k < Math.min(minDim, w0) - eps) return false;
// the neighbour is a wall to hit: pre-existing nesting must survive,
// everything else must not gain shared area or become nested (engulfing a
// foreign room via scale is a stop, not a new island)
for (const r of rooms) {
if (r.id === roomId) continue;
if (polyContainsPoly(room.poly, r.poly, eps)) { // our island
if (!polyContainsPoly(np, r.poly, eps)) return false;
continue;
}
if (polyContainsPoly(r.poly, room.poly, eps)) { // we are the island
if (!polyContainsPoly(r.poly, np, eps)) return false;
continue;
}
if (polyContainsPoly(np, r.poly, eps) || polyContainsPoly(r.poly, np, eps)) return false;
if (illegalOverlap(np, r.poly, eps)) return false;
}
// openings of this room (moved or kept) must still fit on some wall
const allNew = rooms.map((r) => (r.id === roomId ? np : r.poly));
for (const o of openingsOnRooms(openings, [room.poly], eps * 2)) {
const c = res.openings[o.id];
const moved = c ? { ...o, x: c[0], y: c[1] } : o;
if (!openingFits(moved, allNew, eps * 2)) return false;
}
return true;
}
/** Closest valid factor to kWanted (bisecting toward 1, which is always valid). */
export function clampRoomScale(
rooms: RoomIn[], openings: OpeningIn[], roomId: string, fixed: [number, number], kWanted: number, opts: StopOpts,
): number {
if (!Number.isFinite(kWanted) || kWanted <= 0) return 1;
if (validateRoomScale(rooms, openings, roomId, fixed, kWanted, opts)) return kWanted;
let good = 1, bad = kWanted;
for (let i = 0; i < 28; i++) {
const mid = (good + bad) / 2;
if (validateRoomScale(rooms, openings, roomId, fixed, mid, opts)) good = mid;
else bad = mid;
}
return good;
}
// ---------------- live numbers ----------------
/** Room area in m² from render units via the grid scale. */
export function areaM2(poly: number[][], gridPitch: number, cellCm: number): number {
const cmPerUnit = cellCm / gridPitch;
return (polygonArea(poly) * cmPerUnit * cmPerUnit) / 1e4;
}
/** "12.4 m²" or "133 ft²" per the HA unit system. */
export function formatArea(m2: number, imperial: boolean): string {
if (imperial) return `${Math.round(m2 * 10.7639)} ft²`;
return `${(Math.round(m2 * 10) / 10).toFixed(1)} m²`;
}
+29 -10
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
@@ -65,9 +65,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
loc,
iconRules,
});
// the static card never shows hidden devices — there is no editor here
const devs = all.filter((d) => d.space === o.spaceId && !d.hidden);
const defPos = defaultPositions(devs, space, iconPct);
// Two lists, two jobs (HP-1510-01): AGGREGATION sees every device of the
// space — hidden ones still count toward room LQI, same as the full card —
// while RENDERING sees only the visible ones (there is no editor here, so
// hidden devices are never drawn). Filtering one list for both jobs made
// the same room show different Zigbee health on the two cards.
const spaceDevs = all.filter((d) => d.space === o.spaceId);
const devs = spaceDevs.filter((d) => !d.hidden);
// The auto grid is computed over the FULL roster, hidden included — the
// full card reserves grid cells for hidden devices (their ghosts keep a
// place in the device editor), so the static card must too, or the same
// visible marker with no saved position lands in different spots on the
// two cards (HP-1511-01). Rendering still draws `devs` only.
const defPos = defaultPositions(spaceDevs, space, iconPct);
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders)
@@ -83,9 +93,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const fillC = r.area
? roomFillStyle(
fill,
fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
fill === 'lqi' ? areaLqi(o.hass, spaceDevs, r.area) : null,
fill === 'light' ? areaLights(o.hass, spaceDevs, r.area) : 'none',
fill === 'temp' ? areaTemp(o.hass, spaceDevs, r.area) : null,
disp.tempMin,
disp.tempMax,
fillColorsOf(o.cfg?.settings),
@@ -111,8 +121,15 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
<ha-icon icon="${d.icon}"></ha-icon>
// per-marker size and rotation, exactly as on the full card (HP-1513-01):
// the same stored marker must look the same on both cards. Geometry only —
// no live-state dressing here, the static card stays a schematic.
const scale = Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1;
const angle = Number(d.marker?.angle) || 0;
const st = [`left:${left}%`, `top:${top}%`];
if (scale !== 1) st.push(`--dev-scale:${scale}`);
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="${st.join(';')}">
<ha-icon icon="${d.icon}" style=${angle ? `transform:rotate(${angle}deg)` : nothing}></ha-icon>
</div>`;
});
@@ -129,9 +146,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
// the static card paints the same background around the plan as the full one
const stageBg = stageBgOf(o.cfg?.settings, disp);
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
+315 -24
View File
@@ -151,6 +151,44 @@ export const cardStyles = css`
position: absolute;
inset: 0;
}
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
.stage.hpboot .zoomwrap,
.stage.hpboot .zoombadge {
visibility: hidden;
}
.bootveil {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: var(--hp-bg, #16212e);
opacity: 1;
transition: opacity 0.15s ease;
pointer-events: none;
}
.bootveil.off {
opacity: 0;
}
.bootveil .boothouse {
position: static; /* .stage svg pins itself to inset:0 — not this one */
width: 56px;
height: 56px;
fill: var(--hp-accent);
opacity: 0.85;
animation: hp-boot-pulse 1.3s ease-in-out infinite;
}
@keyframes hp-boot-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.94); }
50% { opacity: 0.9; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.bootveil .boothouse {
animation: none;
opacity: 0.7;
}
}
.zoomctl {
display: inline-flex;
gap: 2px;
@@ -262,6 +300,15 @@ export const cardStyles = css`
.stage.markup .op-hit:active {
cursor: grabbing;
}
/* HP-1550-04: in the resize tool the wall handles own the hit test — the
transparent .op-hit of a door at the midpoint of a wall used to sit ON
TOP of the handle and made that wall ungrabbable for both rooms.
Openings are not editable in this tool (they ride along with the wall),
so their hit area goes fully inert; every other Plan tool is untouched. */
.stage.markup.tool-resize .op-hit {
pointer-events: none;
cursor: default;
}
.oplock {
pointer-events: none; /* inert while editing; clickable in View (rule below) */
position: absolute;
@@ -866,28 +913,83 @@ export const cardStyles = css`
width: 130px;
}
.dev.valonly {
/* all satellite metrics from --dev-size, not --icon-size: the per-device
size multiplier must scale the value plate with its marker — same bug
class as the v1.51.3 glyph fix (pinned to base size, the multiplier
grew nothing). */
width: auto;
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.16);
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.16);
}
.dev.valonly .valtext {
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
white-space: nowrap;
}
/* RGB lights: the bulb takes the light's actual color */
.dev.rgb ha-icon { color: var(--light-color); }
.dev.rgb.on {
box-shadow: 0 0 10px var(--light-color);
border-color: var(--light-color);
background: var(--hp-bg);
color: var(--light-color);
/* v1.52.0: the RGB tint of the icon/border is gone — a lamp's colour
lives ONLY in its glow spot (owner's rule). The ripple-color fallback
keeps using the light colour; that is set inline via --ripple-color. */
/* Owner's rule (2026-08-01, вариант «б»): «движение = разовая вспышка
в момент обнаружения; cool-down не пульсирует; присутствие =
статичное кольцо пока обитаемо». The yellow FILL stays reserved for
«включено» — both sense states keep the neutral badge. Both rings
are declared BEFORE .dev.alarm on purpose: equal specificity on the
shared ::after means the later alarm rule wins if a device ever
carries both — red beats yellow. */
/* MOTION tripped: a short ONE-SHOT flash — three beats of the yellow
ring (3 × 1.1s), then silence, even while the entity still reports
'on' (that tail is the sensor's cool-down, not motion). The
iteration count is FINITE, and the card itself drops the class
~3.3s after the off→on transition (_senseTick/_stateClass in
houseplan-card.ts); base opacity 0 keeps the ring invisible should
the class outlive the animation by a frame. */
.dev.senseflash::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0;
animation: hp-sense 1.1s ease-in-out 3;
pointer-events: none;
}
/* HP-1543-02: a rapid off→on retrip must RESTART the flash. The card
flips the .sf2 marker on every trip (_senseTick generation parity);
hp-sense-b duplicates hp-sense exactly — switching the animation-name
is what makes the browser abandon the old timeline and start a fresh
one on the same pseudo-element (a same-name animation never restarts
while the class stays on). */
.dev.senseflash.sf2::after { animation-name: hp-sense-b; }
/* OCCUPANCY/PRESENCE while 'on': a calm STATIC ring, no animation —
«комната обитаема» is a state, not an event, so it must not blink.
Brightness matches the reduced-motion variant of the flash. */
.dev.sensehold::after {
content: '';
position: absolute;
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 2px solid var(--hp-on);
border-radius: 50%;
opacity: 0.4;
pointer-events: none;
}
@keyframes hp-sense {
0% { transform: scale(0.9); opacity: 0.5; }
60% { transform: scale(1.12); opacity: 0.12; }
100% { transform: scale(1.12); opacity: 0; }
}
/* identical twin of hp-sense — exists ONLY as the alternate animation
identity for the retrip restart (HP-1543-02), keep the two in sync */
@keyframes hp-sense-b {
0% { transform: scale(0.9); opacity: 0.5; }
60% { transform: scale(1.12); opacity: 0.12; }
100% { transform: scale(1.12); opacity: 0; }
}
/* alarms pulse red over everything */
.dev.alarm::after {
content: '';
position: absolute;
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
inset: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.35);
border: 3px solid #f25a4a;
border-radius: 50%;
animation: hp-alarm 1s ease-out infinite;
@@ -899,13 +1001,18 @@ export const cardStyles = css`
}
@media (prefers-reduced-motion: reduce) {
.dev.alarm::after { animation: none; opacity: 0.9; }
/* the motion flash becomes a static ring for the same ~3.3s class
window — consistent with alarm's treatment; sensehold is static
by design already */
.dev.senseflash::after,
.dev.senseflash.sf2::after { animation: none; opacity: 0.4; } /* HP-1543-02: retrip re-arms the window, ring stays static */
}
.dev .newdot {
position: absolute;
top: calc(var(--icon-size, 2.5cqw) * -0.12);
right: calc(var(--icon-size, 2.5cqw) * -0.12);
width: calc(var(--icon-size, 2.5cqw) * 0.34);
height: calc(var(--icon-size, 2.5cqw) * 0.34);
top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.12);
right: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * -0.12);
width: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.34);
height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.34);
border-radius: 50%;
background: #f0301f;
border: 2px solid var(--card-background-color, var(--hp-bg));
@@ -939,7 +1046,11 @@ export const cardStyles = css`
z-index: 2;
}
.dev ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
/* from --dev-size, NOT --icon-size: the per-device size multiplier must
scale the GLYPH with its badge. Pinned to the base size, "make this
icon bigger" grew an empty box around a default-size glyph (user
report via the owner, 2026-07-29). */
--mdc-icon-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.62);
display: flex;
align-items: center;
justify-content: center;
@@ -993,14 +1104,14 @@ export const cardStyles = css`
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
margin-left: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.18);
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.14);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
line-height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.68);
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
@@ -1010,14 +1121,14 @@ export const cardStyles = css`
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
margin-left: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.1);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid #4fc3f7;
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.18);
padding: 0 calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.14);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
line-height: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.68);
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
@@ -1027,8 +1138,8 @@ export const cardStyles = css`
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: calc(var(--icon-size, 2.5cqw) * 0.05);
font-size: calc(var(--icon-size, 2.5cqw) * 0.38);
margin-top: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.05);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.38);
font-weight: 700;
line-height: 1;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 0.9);
@@ -1233,10 +1344,190 @@ export const cardStyles = css`
color: var(--hp-muted);
font-size: 11px;
}
/* live vacuum: a round puck, no badge plate, soft pulse (docs/VACUUM.md) */
.vacpuck {
position: absolute;
/* the base badge, but round and 20% smaller — the owner's wording:
«иконка похожа на иконку базы, только круглая и чуть меньше» */
--puck-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.8);
width: var(--puck-size);
height: var(--puck-size);
border-radius: 50%;
transform: translate(-50%, -50%);
background: var(--hp-bg);
border: 1px solid var(--hp-line);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
color: var(--hp-txt);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 6;
/* glide between sparse position updates; .jump disables it so the robot
never appears to drive through walls after a data gap */
transition: left 1.2s linear, top 1.2s linear;
animation: vacpulse 2.2s ease-out infinite;
}
.vacpuck.jump { transition: none; }
.vacpuck.stale { opacity: 0.45; animation: none; }
.vacpuck ha-icon {
--mdc-icon-size: calc(var(--puck-size) * 0.68);
color: var(--hp-txt);
/* same centering recipe as .dev ha-icon: without flex + line-height 0
the glyph sits on its text baseline and appears to float around the
circle (owner report 2026-07-31) */
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
width: var(--mdc-icon-size);
height: var(--mdc-icon-size);
}
@keyframes vacpulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--hp-accent) 45%, transparent); }
70% { box-shadow: 0 0 0 12px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
@media (prefers-reduced-motion: reduce) {
.vacpuck { animation: none; }
}
.vactrail {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
overflow: visible;
}
.vactrail polyline {
fill: none;
stroke-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
/* dark halo + light core: neutral, and one of the two always contrasts
with whatever fill is underneath */
.vactrail g.prev { opacity: 0.4; }
.vactrail .case {
stroke: rgba(0, 0, 0, 0.4);
stroke-width: 2.25;
}
.vactrail .core {
stroke: rgba(255, 255, 255, 0.82);
stroke-width: 0.9;
}
.vacbox .vacbtns { display: flex; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
.vacfit {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 12;
overflow: visible;
touch-action: none;
cursor: grab;
/* the devlayer is pointer-events: none and every child opts back in —
without this line real clicks flew straight through the overlay
(owner: «уголки не кликабельны»; synthetic smoke events bypass
hit-testing, which is why they lied) */
pointer-events: auto;
}
.vacfit:active { cursor: grabbing; }
.vacfit polygon {
fill: color-mix(in srgb, var(--hp-accent) 16%, transparent);
stroke: var(--hp-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
stroke-dasharray: 6 4;
}
.vacfit text {
fill: var(--hp-accent);
font-size: 26px;
text-anchor: middle;
dominant-baseline: middle;
pointer-events: none;
user-select: none;
}
/* room resize tool (docs/RESIZE.md) */
/* wall handle: invisible finger-sized hit circle (HP-1550-04 hit priority
kept), the visible glyph lives in the sibling .rszicon */
.rszhandle {
fill: transparent;
stroke: none;
pointer-events: all;
cursor: grab;
touch-action: none;
}
.rszhandle:active { cursor: grabbing; }
/* wall-with-arrows glyph: accent ink over a bg halo, readable on any plan */
.rszicon { pointer-events: none; }
.rszicon path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.rszhalo { stroke: var(--hp-bg); stroke-width: 6; }
.rszink { stroke: var(--hp-accent); stroke-width: 2; }
.rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
/* corner (scale-frame) handles keep the classic filled circle */
.rszcorner {
fill: var(--hp-bg);
stroke: var(--hp-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
cursor: nwse-resize;
}
.rszcorner:hover { fill: var(--hp-accent); }
.rszcorner:active { cursor: nwse-resize; }
.rszframe {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
stroke-dasharray: 6 5;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.measurelabel.rszarea {
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.6);
}
.vacfitdot { fill: var(--hp-accent); pointer-events: none; }
.vacfithandle {
fill: var(--hp-bg);
stroke: var(--hp-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
cursor: nwse-resize;
}
.vaccalbar {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
gap: 12px;
align-items: center;
background: var(--hp-panel, #16212e);
color: var(--hp-fg, #e7eef7);
border: 1px solid var(--hp-accent);
border-radius: 10px;
padding: 10px 14px;
z-index: 60;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
}
.candlist {
max-height: 160px;
overflow-y: auto;
border-top: 1px solid var(--hp-line);
/* A scrollable box is a flex item that HAPPILY collapses: inside the
dialog body (a flex column) this list rendered its rows into a 1px
sliver — the DOM had 26 candidates and the user saw nothing. In the
binding dropdown it sits inside .droppanel (block context) and never
showed the bug. Field report, 2026-07-30. */
flex: 0 0 auto;
min-height: 2.6em;
}
.cand {
display: flex;
+14
View File
@@ -51,6 +51,20 @@ export interface Marker {
description?: string | null;
pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
/** 'run' target: automation./script./scene. entity id. */
tap_target?: string | null;
/** Ask before toggle/run — accidental-tap guard (owner's spec). */
tap_confirm?: boolean | null;
/** live robot vacuum (docs/VACUUM.md); absent on non-vacuum markers */
vacuum?: {
live?: boolean | null;
trail?: boolean | null; // legacy bool; trail_mode wins
trail_mode?: 'never' | 'cleaning' | 'always' | null;
room_highlight?: boolean | null;
source?: string | null;
calibration?: Record<string, number[]>;
segment_map?: Record<string, string>;
} | null;
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
ripple_color?: string | null;
Executable
+328
View File
@@ -0,0 +1,328 @@
/**
* Live robot vacuums: coordinate math and integration adapters.
*
* Pure logic, no Lit — everything here is unit-tested directly. The renderer
* consumes three things: a solved affine matrix (vacuum mm → plan canvas
* units), normalised telemetry from whatever integration the user happens to
* run, and a thinned trail. See docs/VACUUM.md for the approved contract.
*/
export type Affine = [number, number, number, number, number, number];
export type Pt = [number, number];
/** target = [a b; d e]·source + [c f] */
export function applyAffine(m: Affine, x: number, y: number): Pt {
return [m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]];
}
/**
* Least-squares affine over point pairs (≥3). Solves two independent 3-unknown
* systems via normal equations; returns null for degenerate input (collinear
* points make the normal matrix singular — the wizard asks for a spread).
*/
export function solveAffine(pairs: Array<[Pt, Pt]>): Affine | null {
if (pairs.length < 3) return null;
// normal matrix A^T A (3x3) and right-hand sides for tx and ty
let sxx = 0, sxy = 0, sx = 0, syy = 0, sy = 0, n = 0;
let bx0 = 0, bx1 = 0, bx2 = 0, by0 = 0, by1 = 0, by2 = 0;
for (const [[x, y], [tx, ty]] of pairs) {
if (![x, y, tx, ty].every(Number.isFinite)) return null;
sxx += x * x; sxy += x * y; sx += x; syy += y * y; sy += y; n += 1;
bx0 += x * tx; bx1 += y * tx; bx2 += tx;
by0 += x * ty; by1 += y * ty; by2 += ty;
}
const A = [sxx, sxy, sx, sxy, syy, sy, sx, sy, n];
const solve3 = (b: number[]): number[] | null => {
// Cramer via explicit inverse of the symmetric 3x3
const [a, b1, c, d, e, f, g, h, i] = A;
const det = a * (e * i - f * h) - b1 * (d * i - f * g) + c * (d * h - e * g);
if (!Number.isFinite(det) || Math.abs(det) < 1e-9) return null;
const inv = [
(e * i - f * h) / det, (c * h - b1 * i) / det, (b1 * f - c * e) / det,
(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det,
(d * h - e * g) / det, (b1 * g - a * h) / det, (a * e - b1 * d) / det,
];
return [
inv[0] * b[0] + inv[1] * b[1] + inv[2] * b[2],
inv[3] * b[0] + inv[4] * b[1] + inv[5] * b[2],
inv[6] * b[0] + inv[7] * b[1] + inv[8] * b[2],
];
};
const rx = solve3([bx0, bx1, bx2]);
const ry = solve3([by0, by1, by2]);
if (!rx || !ry) return null;
const m: Affine = [rx[0], rx[1], rx[2], ry[0], ry[1], ry[2]];
return m.every(Number.isFinite) ? m : null;
}
/** Worst residual in target units — the wizard warns above a threshold. */
export function affineResidual(m: Affine, pairs: Array<[Pt, Pt]>): number {
let worst = 0;
for (const [s, t] of pairs) {
const p = applyAffine(m, s[0], s[1]);
worst = Math.max(worst, Math.hypot(p[0] - t[0], p[1] - t[1]));
}
return worst;
}
// ---------------- telemetry adapters ----------------
export interface VacRoom { id: string; name: string; cx: number; cy: number;
x0?: number; y0?: number; x1?: number; y1?: number }
export interface VacTelemetry {
pos: { x: number; y: number; a: number | null } | null;
path: Pt[] | null; // integration-provided full path, vacuum coords
rooms: VacRoom[]; // for auto-calibration; empty when unknown
mapId: string; // multi-floor robots: one calibration per map
}
const num = (v: unknown): number | null => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
/**
* Map-id normalisation contract, shared with the backend recorder
* (custom_components/houseplan/trails.py: resolve_map_id). The FIRST value
* that is not null/undefined wins — truthiness is wrong here, because a
* zero-based `map_index: 0` is a perfectly valid first map and an empty
* string is still an id. The backend used an `or`-chain and dropped the
* zero, so server trails were stored under a key the renderer never looked
* up (HP-1540-02).
*/
export function vacMapIdFromAttrs(attrs: Record<string, any>): string {
return String(attrs.map_name ?? attrs.current_map ?? attrs.map_index ?? attrs.selected_map ?? 'default');
}
/**
* The card-side fallback half of that contract (HP-1541-01): when source
* telemetry names no map ('default'), the vacuum entity's own selected_map
* decides — under the SAME not-nullish rule as above. The old truthiness
* check in _vacMapId turned `selected_map: 0` into 'default' while the
* server recorder (trails.py resolve_map_id) stored the trail under '0', so
* calibration and saved runs lived under a key the renderer never matched.
*/
export function vacMapIdWithFallback(teleMapId: string, selectedMap: unknown): string {
if (teleMapId !== 'default') return teleMapId;
return selectedMap != null ? String(selectedMap) : 'default';
}
/**
* Normalise the attribute zoo. One parser instead of per-brand classes: the
* three Tier-A integrations (Xiaomi Cloud Map Extractor, Tasshack
* dreame-vacuum, Valetudo camera) all descend from the map-card conventions
* and differ only in field spellings.
*/
export function readVacTelemetry(attrs: Record<string, any> | null | undefined): VacTelemetry | null {
if (!attrs) return null;
const p = attrs.vacuum_position || attrs.robot_position || null;
const pos = p && num(p.x) != null && num(p.y) != null
? { x: num(p.x)!, y: num(p.y)!, a: num(p.a ?? p.angle ?? p.theta) }
: null;
// path: [{x,y},…] (Map Extractor) or [[x,y],…]
let path: Pt[] | null = null;
const rawPath = attrs.path?.points ?? attrs.path;
if (Array.isArray(rawPath) && rawPath.length) {
path = [];
for (const q of rawPath) {
const x = num(Array.isArray(q) ? q[0] : q?.x);
const y = num(Array.isArray(q) ? q[1] : q?.y);
if (x != null && y != null) path.push([x, y]);
}
if (!path.length) path = null;
}
// rooms: {id:{name,x0,y0,x1,y1}} | [{id,name,x0..}] | {id:{name,outline}}
const rooms: VacRoom[] = [];
const rawRooms = attrs.rooms;
const entries: Array<[string, any]> = Array.isArray(rawRooms)
? rawRooms.map((r: any, i: number) => [String(r?.id ?? i), r])
: rawRooms && typeof rawRooms === 'object' ? Object.entries(rawRooms) : [];
for (const [id, r] of entries) {
if (!r || typeof r !== 'object') continue;
const name = String(r.name ?? r.label ?? '').trim();
let cx = num(r.cx ?? r.center?.x); let cy = num(r.cy ?? r.center?.y);
if (cx == null || cy == null) {
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
if (x0 != null && y0 != null && x1 != null && y1 != null) {
cx = (x0 + x1) / 2; cy = (y0 + y1) / 2;
}
}
// Tasshack dreame-vacuum: the room centre is plain x/y (verified against
// a live X50 Master; x/y sits within its own x0..x1 bbox)
if (cx == null || cy == null) { cx = num(r.x); cy = num(r.y); }
if (name && cx != null && cy != null) {
const room: VacRoom = { id, name, cx, cy };
const x0 = num(r.x0), y0 = num(r.y0), x1 = num(r.x1), y1 = num(r.y1);
if (x0 != null && y0 != null && x1 != null && y1 != null) {
room.x0 = Math.min(x0, x1); room.y0 = Math.min(y0, y1);
room.x1 = Math.max(x0, x1); room.y1 = Math.max(y0, y1);
}
rooms.push(room);
}
}
const mapId = vacMapIdFromAttrs(attrs);
if (!pos && !rooms.length && !path) return null;
return { pos, path, rooms, mapId };
}
/** Attribute sets that mark an entity as a live-position source. */
export function isVacSourceState(st: { attributes?: Record<string, any> } | null | undefined): boolean {
const a = st?.attributes;
return !!(a && (a.vacuum_position || a.robot_position));
}
// ---------------- auto-calibration by rooms ----------------
const canonName = (s: string): string => s.toLowerCase().replace(/[\s_\-.,]+/g, '');
/**
* Match the robot's room list against plan rooms by name and solve the
* transform over centroids. Room centroids are coarse anchors, which is fine:
* the residual check below rejects a bad fit, and the user always sees a live
* preview before accepting.
*/
export function autoCalibrate(
vacRooms: VacRoom[],
planRooms: Array<{ name: string; cx: number; cy: number }>,
): { matrix: Affine; matched: string[]; residual: number } | null {
const byName = new Map(planRooms.map((r) => [canonName(r.name), r]));
const pairs: Array<[Pt, Pt]> = [];
const matched: string[] = [];
for (const vr of vacRooms) {
const pr = byName.get(canonName(vr.name));
if (!pr) continue;
pairs.push([[vr.cx, vr.cy], [pr.cx, pr.cy]]);
matched.push(vr.name);
}
if (pairs.length < 3) return null;
const matrix = solveAffine(pairs);
if (!matrix) return null;
return { matrix, matched, residual: affineResidual(matrix, pairs) };
}
// ---------------- trail ----------------
export const TRAIL_MAX = 600;
export const VAC_TELEPORT_GAP_MS = 10000;
export const VAC_STALE_MS = 60000;
export const VAC_TRAIL_LINGER_MS = 10 * 60000;
/** Ramer–Douglas–Peucker; keeps ends, drops points under eps deviation. */
export function thinPath(pts: Pt[], eps: number): Pt[] {
if (pts.length < 3) return pts.slice();
const keep = new Uint8Array(pts.length);
keep[0] = keep[pts.length - 1] = 1;
const stack: Array<[number, number]> = [[0, pts.length - 1]];
while (stack.length) {
const [a, b] = stack.pop()!;
const [ax, ay] = pts[a]; const [bx, by] = pts[b];
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1e-9;
let worst = 0, wi = -1;
for (let i = a + 1; i < b; i++) {
const d = Math.abs((pts[i][0] - ax) * dy - (pts[i][1] - ay) * dx) / len;
if (d > worst) { worst = d; wi = i; }
}
if (wi > 0 && worst > eps) {
keep[wi] = 1;
stack.push([a, wi], [wi, b]);
}
}
const out: Pt[] = [];
for (let i = 0; i < pts.length; i++) if (keep[i]) out.push(pts[i]);
return out;
}
/** Append a point; over the cap → thin, and if thinning was not enough, decimate. */
export function pushTrailPoint(buf: Pt[], p: Pt, epsHint: number): Pt[] {
const last = buf[buf.length - 1];
if (last && last[0] === p[0] && last[1] === p[1]) return buf;
buf.push(p);
if (buf.length <= TRAIL_MAX) return buf;
let thinned = thinPath(buf, epsHint);
if (thinned.length > TRAIL_MAX) thinned = thinned.filter((_, i) => i % 2 === 0 || i === thinned.length - 1);
return thinned;
}
/** true → the robot is actively driving (a puck should exist). */
export function isVacMoving(state: string | undefined): boolean {
return state === 'cleaning' || state === 'returning' || state === 'on';
}
// ---------------- the fit panel (drag + corner-stretch calibration) ----------------
/** What the user manipulates; folds into the same stored 6-number matrix. */
export interface FitParams {
ox: number; oy: number; // translation, canvas units
s: number; // uniform scale, canvas units per robot unit
rot: 0 | 90 | 180 | 270; // whole-quarter rotation
mir: boolean; // mirror (robots' Y usually grows the other way)
}
const ROT_CS: Record<number, [number, number]> = { 0: [1, 0], 90: [0, 1], 180: [-1, 0], 270: [0, -1] };
/** target = S · R(rot) · diag(mir ? −1 : 1, 1) · source + (ox, oy) */
export function fitMatrix(p: FitParams): Affine {
const [c, s_] = ROT_CS[p.rot] || [1, 0];
const mx = p.mir ? -1 : 1;
return [p.s * c * mx, -p.s * s_, p.ox, p.s * s_ * mx, p.s * c, p.oy];
}
/**
* Decompose a stored matrix back into panel params. Rotation snaps to the
* nearest quarter — a legacy 3-point matrix reopens as an editable start,
* not verbatim, and that is fine: the ghost shows the result live.
*/
export function fitFromMatrix(m: Affine): FitParams | null {
const det = m[0] * m[4] - m[1] * m[3];
if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
const mir = det < 0;
const s = Math.sqrt(Math.abs(det));
// the second column (b, e) = S·(−sin, cos) is mirror-free
let ang = Math.atan2(-m[1], m[4]) * 180 / Math.PI;
ang = ((Math.round(ang / 90) * 90) % 360 + 360) % 360;
return { ox: m[2], oy: m[5], s, rot: ang as FitParams['rot'], mir };
}
/** A sane opening position: the robot map centred over the plan at 60% size. */
export function initialFit(
rooms: VacRoom[],
vb: [number, number, number, number],
): FitParams {
const bx: number[] = [], by: number[] = [];
for (const r of rooms) {
if (r.x0 != null) { bx.push(r.x0, r.x1!); by.push(r.y0!, r.y1!); }
else { bx.push(r.cx); by.push(r.cy); }
}
// no rooms at all: an arbitrary honest guess the user will drag anyway
if (!bx.length) return { ox: vb[0] + vb[2] / 2, oy: vb[1] + vb[3] / 2, s: vb[2] / 10000, rot: 0, mir: true };
const minX = Math.min(...bx), maxX = Math.max(...bx);
const minY = Math.min(...by), maxY = Math.max(...by);
const span = Math.max(maxX - minX, maxY - minY) || 1;
const s = (Math.min(vb[2], vb[3]) * 0.6) / span;
// mirror on by default: every robot map seen so far has Y flipped vs screen
const p: FitParams = { ox: 0, oy: 0, s, rot: 0, mir: true };
const m = fitMatrix(p);
const [ccx, ccy] = applyAffine(m, (minX + maxX) / 2, (minY + maxY) / 2);
p.ox = vb[0] + vb[2] / 2 - ccx;
p.oy = vb[1] + vb[3] / 2 - ccy;
return p;
}
/** Re-anchor params so the source point (sx, sy) stays at the same target spot. */
export function reanchorFit(p: FitParams, prev: FitParams, sx: number, sy: number): FitParams {
const [px, py] = applyAffine(fitMatrix(prev), sx, sy);
const trial = fitMatrix({ ...p, ox: 0, oy: 0 });
const [qx, qy] = applyAffine(trial, sx, sy);
return { ...p, ox: px - qx, oy: py - qy };
}
export type VacTrailMode = 'never' | 'cleaning' | 'always';
/** marker.vacuum → display mode; legacy bool maps in (false = never). */
export function vacTrailMode(v: { trail?: boolean | null; trail_mode?: string | null } | null | undefined): VacTrailMode {
const m = v?.trail_mode;
if (m === 'never' || m === 'cleaning' || m === 'always') return m;
if (v?.trail === false) return 'never';
return 'cleaning';
}
+8
View File
@@ -25,3 +25,11 @@ test('i18n: placeholders match between languages', () => {
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
}
});
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
for (const [lang, d] of [['en', en], ['ru', ru]]) {
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.autocal_res_warn']) {
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
}
}
});
+26 -3
View File
@@ -15,7 +15,7 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility } from '../test-build/logic.js';
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -190,8 +190,13 @@ test('tap action: defaults — info everywhere except pure lights (v1.39.0)', ()
test('tap action: card-wide toggle only touches safe domains', () => {
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
// covers/valves joined the safe set for curtains (owner, 2026-07-29)…
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'curtain'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'toggle');
// …but the garage door is a cover too, and it STAYS SHUT on a default tap
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'garage'), 'info');
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'gate'), 'info');
assert.equal(resolveTapAction('toggle', null, 'cover', 'garage'), 'toggle', 'explicit stays a conscious choice');
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
});
@@ -936,3 +941,21 @@ test('poleOfInaccessibility: the VISUAL centre, not just any interior point', ()
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
});
test('tap action run: explicit-only, with runnable targets (owner spec 2026-07-29)', () => {
// 'run' resolves only as an explicit per-marker action
assert.equal(resolveTapAction('run', undefined, 'switch'), 'run');
assert.equal(resolveTapAction(null, 'run', 'switch'), 'info', 'never a card-wide default');
// runnable domains map to their services; anything else is not runnable
assert.deepEqual(runServiceFor('automation.morning'), { domain: 'automation', service: 'trigger' });
assert.deepEqual(runServiceFor('script.curtains'), { domain: 'script', service: 'turn_on' });
assert.deepEqual(runServiceFor('scene.movie'), { domain: 'scene', service: 'turn_on' });
assert.equal(runServiceFor('light.lamp'), null);
assert.equal(runServiceFor(''), null);
// covers and valves joined the card-wide toggle domains
assert.ok(TOGGLE_SAFE_DOMAINS.has('cover') && TOGGLE_SAFE_DOMAINS.has('valve'));
assert.equal(resolveTapAction(null, 'toggle', 'cover', 'shutter'), 'toggle');
// locks and alarms stay forbidden, run or not
assert.equal(resolveTapAction('toggle', undefined, 'lock'), 'info');
});
+258
View File
@@ -0,0 +1,258 @@
// Room resize geometry (docs/RESIZE.md): every «упор» and the T-junction
// vertex insertion are pinned here numerically.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
edgeNormal, movePolyEdge, sharedSpansWith, shiftSharedSpans, simplifyPoly,
polyIsSimple, minParallelClearance, planEdgeDrag, applyEdgeDrag,
validateEdgeDrag, clampEdgeDrag, applyRoomScale, validateRoomScale,
clampRoomScale, areaM2, formatArea, MIN_ROOM_CM,
} from '../test-build/resize.js';
import { roomPoly } from '../test-build/logic.js';
const OPTS = { minDim: 25, eps: 0.5 }; // 25 units ≈ 30 cm at cell_cm=5, pitch 1000/240
const STEP = 5;
// A: 300×300 square, right edge is index 1 ((400,100)→(400,400))
const A = () => ({ id: 'A', poly: [[100, 100], [400, 100], [400, 400], [100, 400]] });
// R: full-height neighbour to the right — the ENTIRE wall x=400 is shared
const R = () => ({ id: 'R', poly: [[400, 100], [700, 100], [700, 400], [400, 400]] });
// B: shorter neighbour to the right — T-junction (covers y 100..300 only)
const B = () => ({ id: 'B', poly: [[400, 100], [700, 100], [700, 300], [400, 300]] });
const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
const polyEq = (got, want, tol = 1e-6) => {
assert.equal(got.length, want.length, `vertex count: ${JSON.stringify(got)}`);
for (let i = 0; i < want.length; i++) {
closeTo(got[i][0], want[i][0], tol);
closeTo(got[i][1], want[i][1], tol);
}
};
test('edgeNormal points outward for both windings', () => {
const a = A();
assert.deepEqual(edgeNormal(a.poly, 1).map((v) => Math.round(v) + 0), [1, 0]); // right wall → +x
assert.deepEqual(edgeNormal(a.poly, 3).map((v) => Math.round(v) + 0), [-1, 0]); // left wall → −x
const ccw = [...a.poly].reverse(); // reversed winding, same square
const n = edgeNormal(ccw, ccw.findIndex((p) => p[0] === 400 && p[1] === 400));
assert.deepEqual(n.map((v) => Math.round(v) + 0), [1, 0]);
});
test('movePolyEdge translates BOTH edge endpoints along the normal', () => {
polyEq(movePolyEdge(A().poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
polyEq(movePolyEdge(A().poly, 1, -50), [[100, 100], [350, 100], [350, 400], [100, 400]]);
});
test('legacy x/y/w/h rectangles resize through roomPoly', () => {
const poly = roomPoly({ x: 100, y: 100, w: 300, h: 300 });
polyEq(movePolyEdge(poly, 1, 50), [[100, 100], [450, 100], [450, 400], [100, 400]]);
});
test('full shared wall: the neighbour moves synchronously, no gap by construction', () => {
const rooms = [A(), R()];
const plan = planEdgeDrag(rooms, 'A', 1);
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
polyEq(res.polys.A, [[100, 100], [450, 100], [450, 400], [100, 400]]);
polyEq(res.polys.R, [[450, 100], [700, 100], [700, 400], [450, 400]]); // R shrank, walls still coincide
});
test('T-junction: only the coinciding stretch of the neighbour moves, vertices are inserted', () => {
const rooms = [A(), B()];
// drag B's left wall (edge 3: (400,300)→(400,100)) 50 units INTO A
const plan = planEdgeDrag(rooms, 'B', 3);
assert.deepEqual(plan.n.map((v) => Math.round(v) + 0), [-1, 0]);
const res = applyEdgeDrag(rooms, [], plan, 50, OPTS.eps);
polyEq(res.polys.B, [[350, 100], [700, 100], [700, 300], [350, 300]]);
// A becomes L-shaped: the shared stretch (y 100..300) caves in, the rest stays
polyEq(res.polys.A, [[100, 100], [350, 100], [350, 300], [400, 300], [400, 400], [100, 400]]);
});
test('stop: own room minimum size (~30 cm)', () => {
const rooms = [A()];
const plan = planEdgeDrag(rooms, 'A', 1);
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // width 20 < 25
assert.equal(validateEdgeDrag(rooms, [], plan, -275, OPTS), true); // width 25 — the floor
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -275);
});
test('stop: the shrinking neighbour keeps its minimum size too', () => {
const rooms = [A(), R()];
const plan = planEdgeDrag(rooms, 'A', 1);
assert.equal(validateEdgeDrag(rooms, [], plan, 290, OPTS), false); // R would be 10 wide
closeTo(clampEdgeDrag(rooms, [], plan, 290, STEP, OPTS), 275); // R stays 25
});
test('stop: a growing wall may touch a foreign room but never overlap it', () => {
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
const rooms = [A(), F];
const plan = planEdgeDrag(rooms, 'A', 1);
assert.equal(validateEdgeDrag(rooms, [], plan, 150, OPTS), false); // crosses F
assert.equal(validateEdgeDrag(rooms, [], plan, 100, OPTS), true); // touching = legal shared wall
closeTo(clampEdgeDrag(rooms, [], plan, 150, STEP, OPTS), 100);
});
test('stop: islands (islandsOf) block the wall, including a jump fully past them', () => {
const P = { id: 'P', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] };
const I = { id: 'I', poly: [[250, 250], [350, 250], [350, 350], [250, 350]] };
const rooms = [P, I];
const plan = planEdgeDrag(rooms, 'P', 1); // right wall of the parent
assert.equal(validateEdgeDrag(rooms, [], plan, -200, OPTS), false); // wall at 300 cuts the island
assert.equal(validateEdgeDrag(rooms, [], plan, -280, OPTS), false); // wall at 220 — island fully outside (no edge crossing!)
assert.equal(validateEdgeDrag(rooms, [], plan, -155, OPTS), false); // wall at 345 crosses the island
assert.equal(validateEdgeDrag(rooms, [], plan, -150, OPTS), true); // wall at 350 — flush with the island is a touch, legal
closeTo(clampEdgeDrag(rooms, [], plan, -280, STEP, OPTS), -150);
});
test('openings: a door ON the moving wall travels with it', () => {
const rooms = [A(), R()];
const plan = planEdgeDrag(rooms, 'A', 1);
const ops = [{ id: 'o1', x: 400, y: 200, length: 60 }];
const res = applyEdgeDrag(rooms, ops, plan, 50, OPTS.eps);
assert.deepEqual(res.openings.o1, [450, 200]);
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), true);
});
test('stop: a side wall cannot get too short for its opening (own room)', () => {
const rooms = [A()];
const plan = planEdgeDrag(rooms, 'A', 1);
const ops = [{ id: 'o2', x: 350, y: 100, length: 80 }]; // top wall, spans x 310..390
assert.equal(validateEdgeDrag(rooms, ops, plan, -50, OPTS), false); // top wall ends at 350 < 390
assert.equal(validateEdgeDrag(rooms, ops, plan, -10, OPTS), true); // ends exactly at 390
closeTo(clampEdgeDrag(rooms, ops, plan, -50, STEP, OPTS), -10);
});
test('stop: the neighbour’s opening anchors the drag too', () => {
const rooms = [A(), R()];
const plan = planEdgeDrag(rooms, 'A', 1);
const ops = [{ id: 'o3', x: 460, y: 100, length: 40 }]; // R’s top wall, spans x 440..480
// the wall corner may not land INSIDE the door: at d=50 the joint (x=450)
// would sit in the middle of the opening — that is the «упор»
assert.equal(validateEdgeDrag(rooms, ops, plan, 50, OPTS), false);
assert.equal(validateEdgeDrag(rooms, ops, plan, 40, OPTS), true); // joint exactly at the door edge
closeTo(clampEdgeDrag(rooms, ops, plan, 50, STEP, OPTS), 40);
// fully past the door the opening sits on the GROWN room's wall — the
// composite wall y=100 never shortens, so this is legal by construction
assert.equal(validateEdgeDrag(rooms, ops, plan, 100, OPTS), true);
});
test('scale: all vertices scale proportionally about the fixed corner', () => {
const res = applyRoomScale(A(), [], [], [100, 100], 1.5, OPTS.eps);
polyEq(res.poly, [[100, 100], [550, 100], [550, 550], [100, 550]]);
});
test('scale stops: minimum size and the neighbour as a hard wall', () => {
const F = { id: 'F', poly: [[500, 100], [700, 100], [700, 400], [500, 400]] };
const rooms = [A(), F];
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS), false); // 15 < 25
const kMin = clampRoomScale(rooms, [], 'A', [100, 100], 0.05, OPTS);
closeTo(kMin * 300, 25, 0.5); // clamped at the 30 cm floor
assert.equal(validateRoomScale(rooms, [], 'A', [100, 100], 2, OPTS), false); // overlaps F
const kMax = clampRoomScale(rooms, [], 'A', [100, 100], 2, OPTS);
closeTo(kMax, 400 / 300, 1e-3); // right wall lands exactly on F
});
test('scale never drags the neighbour; a SHARED opening stays with the neighbour wall', () => {
const rooms = [A(), R()];
const shared = { id: 'os', x: 400, y: 200, length: 60 }; // on the shared wall
const own = { id: 'oo', x: 100, y: 200, length: 60 }; // on A’s left wall only
const res = applyRoomScale(A(), [shared, own], [R().poly], [400, 400], 0.5, OPTS.eps);
assert.equal(res.openings.os, undefined); // stays put
assert.deepEqual(res.openings.oo, [250, 300]); // follows the transform
assert.equal(validateRoomScale(rooms, [shared], 'A', [400, 400], 0.5, OPTS), true);
});
test('scale stop: an exclusive opening must still fit', () => {
const rooms = [A()];
const ops = [{ id: 'o4', x: 250, y: 100, length: 200 }]; // top wall, needs 200 units
assert.equal(validateRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS), false); // wall 150 < opening 200
const k = clampRoomScale(rooms, ops, 'A', [100, 100], 0.5, OPTS);
// the opening centre scales too: it fits while 100·(1−1.5k) ≤ ε, i.e. k ≥ 0.66
closeTo(k, 0.66, 1e-3);
});
test('shared spans + shiftSharedSpans invariants', () => {
const spans = sharedSpansWith(B().poly, [400, 100], [400, 400], OPTS.eps);
assert.equal(spans.length, 1);
polyEq([spans[0][0], spans[0][1]].sort((p, q) => p[1] - q[1]), [[400, 100], [400, 300]]);
assert.equal(shiftSharedSpans(A().poly, [900, 100], [900, 400], [10, 0], OPTS.eps), null); // nothing coincides
});
test('simplifyPoly drops collinear leftovers, polyIsSimple flags a bowtie', () => {
polyEq(simplifyPoly([[0, 0], [50, 0], [100, 0], [100, 100], [0, 100]]), [[0, 0], [100, 0], [100, 100], [0, 100]]);
assert.equal(polyIsSimple([[0, 0], [100, 100], [100, 0], [0, 100]]), false);
assert.equal(polyIsSimple(A().poly), true);
});
test('minParallelClearance: the opposite-wall distance', () => {
closeTo(minParallelClearance(A().poly, [[[400, 100], [400, 400]]], OPTS.eps), 300);
// L-shape: only walls with an OVERLAPPING projection count — the x=80 wall
// spans y 60..200 and casts no shadow on the y 0..60 span, so the opposite
// wall is x=0 at distance 200 (the x=80 obstruction is the simplicity stop)
const L = [[0, 0], [200, 0], [200, 60], [80, 60], [80, 200], [0, 200]];
closeTo(minParallelClearance(L, [[[200, 0], [200, 60]]], OPTS.eps), 200);
});
test('live numbers: areaM2 and formatArea', () => {
const pitch = 1000 / 240;
const poly = [[0, 0], [100, 0], [100, 100], [0, 100]]; // 24 cells → 120 cm a side
closeTo(areaM2(poly, pitch, 5), 1.44, 1e-9);
assert.equal(formatArea(1.44, false), '1.4 m²');
assert.equal(formatArea(1.44, true), '16 ft²');
assert.equal(MIN_ROOM_CM, 30);
});
test('zero drag is always valid and clamps to zero', () => {
const rooms = [A()];
const plan = planEdgeDrag(rooms, 'A', 1);
assert.equal(validateEdgeDrag(rooms, [], plan, 0, OPTS), true);
assert.equal(clampEdgeDrag(rooms, [], plan, 0, STEP, OPTS), 0);
});
// ================= HP-1550-02 (the v1.55.0 audit): the 30 cm floor must be
// orientation-independent — triangles have no parallel opposite wall and a
// rotated rectangle hides its true short side from the axis-aligned bbox.
test('HP-1550-02: triangle — the min-size stop holds without a parallel opposite wall', () => {
// base 300 wide, apex 300 above it; drag the base toward the apex
const T = { id: 'T', poly: [[0, 0], [300, 0], [150, 300]] };
const rooms = [T];
const plan = planEdgeDrag(rooms, 'T', 0);
assert.deepEqual(plan.n.map((v) => Math.round(v) + 0), [0, -1]); // outward = away from the apex
assert.equal(validateEdgeDrag(rooms, [], plan, -295, OPTS), false); // height 5 < 25
const d = clampEdgeDrag(rooms, [], plan, -295, STEP, OPTS);
const height = 300 - Math.abs(d);
assert.ok(height >= OPTS.minDim - OPTS.eps, `triangle squeezed to height ${height}`);
});
test('HP-1550-02: rotated rectangle — scale stops at the TRUE short side, not the bbox', () => {
// 500×100 rectangle rotated 45°: its axis-aligned bbox is ≈424×424, so the
// bbox measure let k=0.1 slip through while the real short side became 10
const c45 = Math.SQRT1_2;
const rot = ([x, y]) => [x * c45 - y * c45, x * c45 + y * c45];
const P = [[0, 0], [500, 0], [500, 100], [0, 100]].map(rot);
const rooms = [{ id: 'S', poly: P }];
const fixed = [P[0][0], P[0][1]];
assert.equal(validateRoomScale(rooms, [], 'S', fixed, 0.1, OPTS), false); // side 10 < 25
const k = clampRoomScale(rooms, [], 'S', fixed, 0.1, OPTS);
assert.ok(k * 100 >= OPTS.minDim - OPTS.eps, `short side shrank to ${k * 100}`);
});
test('HP-1550-02: concave room — a non-parallel obstacle stops the drag', () => {
// a "roof" vertex dips into the room at (200,120); the bottom wall dragged up
// has NO parallel opposite wall, yet must stop ~30 cm short of the dip
const C = { id: 'C', poly: [[0, 0], [400, 0], [400, 200], [200, 120], [0, 200]] };
const rooms = [C];
const plan = planEdgeDrag(rooms, 'C', 0);
assert.equal(validateEdgeDrag(rooms, [], plan, -115, OPTS), false); // 5 from the dip
assert.equal(validateEdgeDrag(rooms, [], plan, -95, OPTS), true); // 25 — the floor
closeTo(clampEdgeDrag(rooms, [], plan, -115, STEP, OPTS), -95);
});
test('HP-1550-02: an already-thin triangle may improve but never worsen', () => {
const T = { id: 'T', poly: [[0, 0], [300, 0], [150, 20]] }; // height 20 < 25 already
const rooms = [T];
const plan = planEdgeDrag(rooms, 'T', 0);
assert.equal(validateEdgeDrag(rooms, [], plan, -5, OPTS), false); // 20 → 15: worse
assert.equal(validateEdgeDrag(rooms, [], plan, 50, OPTS), true); // 20 → 70: better
});
+181
View File
@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { solveAffine, applyAffine, affineResidual, readVacTelemetry, autoCalibrate, thinPath, pushTrailPoint, isVacMoving, isVacSourceState, vacMapIdFromAttrs, vacMapIdWithFallback } from '../test-build/vacuum.js';
test('solveAffine recovers rotation+scale+mirror+offset exactly', () => {
// target = mirror-X, rotate 90°, scale 0.02, offset (300, 400)
const f = ([x, y]) => [300 + 0.02 * y, 400 + 0.02 * x];
const src = [[1000, 2000], [8000, 2500], [3000, 9000], [7000, 7000]];
const m = solveAffine(src.map((p) => [p, f(p)]));
assert.ok(m);
for (const p of [[5000, 5000], [0, 0], [12000, 3000]]) {
const got = applyAffine(m, p[0], p[1]);
const want = f(p);
assert.ok(Math.hypot(got[0] - want[0], got[1] - want[1]) < 1e-6, String(p));
}
assert.ok(affineResidual(m, src.map((p) => [p, f(p)])) < 1e-6);
});
test('solveAffine rejects degenerate input', () => {
assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 1], [1, 1]]]), null); // 2 pairs
// collinear
assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 0], [1, 0]], [[2, 0], [2, 0]]]), null);
assert.equal(solveAffine([[[0, NaN], [0, 0]], [[1, 0], [1, 0]], [[2, 3], [2, 0]]]), null);
});
test('readVacTelemetry: Map Extractor shape', () => {
const t = readVacTelemetry({
vacuum_position: { x: 25500, y: 24800, a: 271 },
path: [{ x: 25000, y: 24000 }, { x: 25100, y: 24100 }],
rooms: { 16: { name: 'Kitchen', x0: 20000, y0: 20000, x1: 26000, y1: 25000 } },
map_name: '0',
});
assert.deepEqual(t.pos, { x: 25500, y: 24800, a: 271 });
assert.deepEqual(t.path, [[25000, 24000], [25100, 24100]]);
assert.equal(t.rooms[0].name, 'Kitchen');
assert.equal(t.rooms[0].cx, 23000);
assert.equal(t.mapId, '0');
});
test('readVacTelemetry: Valetudo/Tasshack shapes + junk safety', () => {
const t = readVacTelemetry({ robot_position: { x: '120', y: '340', angle: '90' }, rooms: [{ id: 7, name: 'Спальня', cx: 10, cy: 20 }] });
assert.deepEqual(t.pos, { x: 120, y: 340, a: 90 });
assert.equal(t.rooms[0].id, '7');
assert.equal(readVacTelemetry({ vacuum_position: { x: 'nope', y: 1 } }), null);
assert.equal(readVacTelemetry({}), null);
assert.equal(readVacTelemetry(null), null);
assert.ok(isVacSourceState({ attributes: { vacuum_position: { x: 1, y: 2 } } }));
assert.ok(!isVacSourceState({ attributes: { battery: 1 } }));
});
test('readVacTelemetry: Tasshack room centres come as plain x/y', () => {
// shape captured from a live Dreame X50 Master (dacha, 2026-07-31)
const t = readVacTelemetry({
vacuum_position: { x: 1399, y: -55, a: 181 },
rooms: { 2: { room_id: 2, name: 'Кладовка', x0: 800, y0: -2000, x1: 4200, y1: 300, x: 2575, y: -825 } },
});
assert.equal(t.rooms[0].name, 'Кладовка');
// bbox wins when present (both are valid anchors); x/y covers bbox-less dialects
assert.equal(t.rooms[0].cx, 2500);
const t2 = readVacTelemetry({ vacuum_position: { x: 0, y: 0 }, rooms: { 2: { name: 'Кладовка', x: 2575, y: -825 } } });
assert.equal(t2.rooms[0].cx, 2575);
});
test('autoCalibrate matches by name and solves', () => {
const f = ([x, y]) => [0.01 * x + 100, -0.01 * y + 900];
const vac = [
{ id: '1', name: 'Kitchen', cx: 1000, cy: 2000 },
{ id: '2', name: 'Bed Room', cx: 9000, cy: 2500 },
{ id: '3', name: 'office', cx: 3000, cy: 8000 },
{ id: '4', name: 'Garage', cx: 5000, cy: 5000 }, // no plan match
];
const plan = [
{ name: 'kitchen', cx: f([1000, 2000])[0], cy: f([1000, 2000])[1] },
{ name: 'BEDROOM', cx: f([9000, 2500])[0], cy: f([9000, 2500])[1] },
{ name: 'Office', cx: f([3000, 8000])[0], cy: f([3000, 8000])[1] },
];
const r = autoCalibrate(vac, plan);
assert.ok(r);
assert.deepEqual(r.matched, ['Kitchen', 'Bed Room', 'office']);
assert.ok(r.residual < 1e-6);
// two matches only -> null
assert.equal(autoCalibrate(vac.slice(0, 2), plan.slice(0, 2)), null);
});
test('thinPath keeps corners, drops straight-line noise', () => {
const pts = [];
for (let i = 0; i <= 100; i++) pts.push([i, 0]);
for (let i = 1; i <= 100; i++) pts.push([100, i]);
const out = thinPath(pts, 0.5);
assert.ok(out.length <= 5, String(out.length));
assert.deepEqual(out[0], [0, 0]);
assert.deepEqual(out[out.length - 1], [100, 100]);
assert.ok(out.some((p) => p[0] === 100 && p[1] === 0)); // the corner survives
});
test('pushTrailPoint dedups and respects the cap', () => {
let buf = [];
for (let i = 0; i < 3000; i++) buf = pushTrailPoint(buf, [i, (i * 7) % 13], 0.5);
assert.ok(buf.length <= 600, String(buf.length));
buf = pushTrailPoint(buf, buf[buf.length - 1], 0.5); // dup ignored
assert.ok(buf.length <= 600);
});
test('isVacMoving', () => {
assert.ok(isVacMoving('cleaning'));
assert.ok(isVacMoving('returning'));
assert.ok(!isVacMoving('docked'));
assert.ok(!isVacMoving('idle'));
assert.ok(!isVacMoving(undefined));
});
test('fitMatrix/fitFromMatrix round-trip, mirror and quarters', async () => {
const { fitMatrix, fitFromMatrix, initialFit, reanchorFit } = await import('../test-build/vacuum.js');
for (const rot of [0, 90, 180, 270]) for (const mir of [false, true]) {
const p = { ox: 123.4, oy: -55.5, s: 0.083, rot, mir };
const q = fitFromMatrix(fitMatrix(p));
assert.equal(q.rot, rot, `rot ${rot} mir ${mir}`);
assert.equal(q.mir, mir);
assert.ok(Math.abs(q.s - p.s) < 1e-9 && Math.abs(q.ox - p.ox) < 1e-9 && Math.abs(q.oy - p.oy) < 1e-9);
}
// the real X50 matrix shape: X forward, Y flipped == mir + 180? decompose sanity
const q = fitFromMatrix([0.08, 0, 590, 0, -0.08, 677]);
assert.equal(q.mir, true);
// initialFit centres the map bbox on the canvas
const rooms = [{ id: '1', name: 'A', cx: 500, cy: 500, x0: 0, y0: 0, x1: 1000, y1: 1000 }];
const f = initialFit(rooms, [0, 0, 1000, 1000]);
const m = fitMatrix(f);
const c = applyAffine(m, 500, 500);
assert.ok(Math.abs(c[0] - 500) < 1e-6 && Math.abs(c[1] - 500) < 1e-6);
assert.ok(Math.abs(1000 * f.s - 600) < 1e-6); // 60% of the canvas
assert.equal(f.mir, true);
// reanchor keeps the chosen source point fixed through a rotation
const p2 = { ...f, rot: 90 };
const r2 = reanchorFit(p2, f, 500, 500);
const c2 = applyAffine(fitMatrix(r2), 500, 500);
assert.ok(Math.abs(c2[0] - c[0]) < 1e-6 && Math.abs(c2[1] - c[1]) < 1e-6);
});
// HP-1540-02: the map-id contract, mirrored by trails.py resolve_map_id —
// the first value that is not null/undefined wins; zero and '' are valid ids.
test('vacMapIdFromAttrs: first NOT-nullish value wins, zero survives', () => {
assert.equal(vacMapIdFromAttrs({ map_index: 0 }), '0');
assert.equal(vacMapIdFromAttrs({ map_index: '0' }), '0');
assert.equal(vacMapIdFromAttrs({ map_name: '', selected_map: 'Floor' }), '');
assert.equal(vacMapIdFromAttrs({ map_name: 'A', map_index: 0 }), 'A');
assert.equal(vacMapIdFromAttrs({ current_map: 2 }), '2');
assert.equal(vacMapIdFromAttrs({ selected_map: 'Vac' }), 'Vac');
assert.equal(vacMapIdFromAttrs({}), 'default');
});
// HP-1541-01: the vacuum-entity fallback half of the contract. Truthiness
// here turned selected_map: 0 into 'default' while the server recorder
// stored the trail under '0' — reloads never showed the saved run.
test('vacMapIdWithFallback: selected_map 0 / "0" / "" survive, nullish falls back', () => {
assert.equal(vacMapIdWithFallback('default', 0), '0');
assert.equal(vacMapIdWithFallback('default', '0'), '0');
assert.equal(vacMapIdWithFallback('default', ''), '');
assert.equal(vacMapIdWithFallback('default', 'Vac'), 'Vac');
assert.equal(vacMapIdWithFallback('default', null), 'default');
assert.equal(vacMapIdWithFallback('default', undefined), 'default');
assert.equal(vacMapIdWithFallback('1', 0), '1'); // telemetry wins over fallback
});
// HP-1541-01 cross-runtime contract: for the same inputs the card-side chain
// (vacMapIdFromAttrs -> vacMapIdWithFallback) must yield exactly what
// trails.py resolve_map_id stores. Mirrored by
// tests_backend/test_trail_recorder.py::test_map_id_contract_first_not_none_wins.
test('map-id contract: frontend chain matches backend resolve_map_id', () => {
const chain = (srcAttrs, vacSel) => vacMapIdWithFallback(vacMapIdFromAttrs(srcAttrs), vacSel);
assert.equal(chain({}, 0), '0');
assert.equal(chain({}, '0'), '0');
assert.equal(chain({}, ''), '');
assert.equal(chain({}, 'Vac'), 'Vac');
assert.equal(chain({}, undefined), 'default');
assert.equal(chain({ map_index: 0 }, 'Vac'), '0'); // source wins over vacuum
});
test('readVacTelemetry keeps numeric zero map_index as map id (HP-1540-02)', () => {
const t = readVacTelemetry({ vacuum_position: { x: 1, y: 2 }, map_index: 0 });
assert.equal(t.mapId, '0');
});
+336
View File
@@ -0,0 +1,336 @@
"""TrailRecorder wiring: subscription callback, dialects, run end.
Loads trails.py with STUBBED Home Assistant modules — but only inside a
snapshot of sys.modules that is restored immediately afterwards. Injecting
fake `homeassistant` modules globally poisons the real HA harness running in
the same pytest session (it did, once).
"""
import sys, types, pathlib, importlib.util
ROOT = pathlib.Path(__file__).resolve().parent.parent
def _load_trails():
saved = {k: v for k, v in sys.modules.items() if k == "homeassistant" or k.startswith(("homeassistant.", "houseplan"))}
try:
for name in list(sys.modules):
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
del sys.modules[name]
ha = types.ModuleType("homeassistant"); sys.modules["homeassistant"] = ha
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda f: f
sys.modules["homeassistant.core"] = core
sys.modules["homeassistant.helpers"] = types.ModuleType("homeassistant.helpers")
er = types.ModuleType("homeassistant.helpers.entity_registry")
er.async_get = lambda hass: None
er.async_entries_for_device = lambda reg, dev: []
sys.modules["homeassistant.helpers.entity_registry"] = er
ev = types.ModuleType("homeassistant.helpers.event")
ev.async_call_later = lambda hass, delay, cb: (lambda: None)
ev.async_track_state_change_event = lambda hass, ents, cb: (lambda: None)
sys.modules["homeassistant.helpers.event"] = ev
stm = types.ModuleType("homeassistant.helpers.storage")
class _Store:
def __init__(self, *a, **k): pass
async def async_load(self): return None
async def async_save(self, d): pass
stm.Store = _Store
sys.modules["homeassistant.helpers.storage"] = stm
pkg = types.ModuleType("houseplan"); pkg.__path__ = [str(ROOT / "custom_components" / "houseplan")]
sys.modules["houseplan"] = pkg
c = types.ModuleType("houseplan.const"); c.DOMAIN = "houseplan"
sys.modules["houseplan.const"] = c
spec = importlib.util.spec_from_file_location(
"houseplan.trails", str(ROOT / "custom_components" / "houseplan" / "trails.py")
)
mod = importlib.util.module_from_spec(spec)
sys.modules["houseplan.trails"] = mod
spec.loader.exec_module(mod)
return mod
finally:
for name in list(sys.modules):
if name == "homeassistant" or name.startswith(("homeassistant.", "houseplan")):
del sys.modules[name]
sys.modules.update(saved)
trails = _load_trails()
class S:
def __init__(self, state, attrs): self.state, self.attributes = state, attrs
class States:
def __init__(self, d): self.d = d
def get(self, k): return self.d.get(k)
class Bus:
def __init__(self): self.fired = []
def async_fire(self, *a): self.fired.append(a)
class Hass:
def __init__(self, states): self.states, self.bus, self.data = States(states), Bus(), {}
def _rec():
states = {
"vacuum.x50": S("cleaning", {"selected_map": "Первый этаж"}),
"camera.map": S("idle", {"vacuum_position": {"x": 1000, "y": -500, "a": 90}, "map_index": 1}),
}
hass = Hass(states)
rec = trails.TrailRecorder(hass, None)
rec.pairs = {"camera.map": [("m1", "vacuum.x50")]}
return rec, hass, states
class E:
def __init__(self, eid): self.data = {"entity_id": eid}
def test_state_events_record_points_and_map_id():
rec, hass, states = _rec()
rec._on_state(E("camera.map"))
states["camera.map"] = S("idle", {"vacuum_position": {"x": 1100, "y": -500}, "map_index": 1})
rec._on_state(E("camera.map"))
run = rec.book.data["m1"]["current"]
assert run["points"] == [[1000.0, -500.0], [1100.0, -500.0]]
assert run["map_id"] == "1"
assert hass.bus.fired, "live cards must be notified"
def test_docking_ends_the_run():
rec, hass, states = _rec()
rec._on_state(E("camera.map"))
states["vacuum.x50"] = S("docked", {})
rec._on_state(E("vacuum.x50"))
assert rec.book.data["m1"]["current"]["ended"] is not None
def test_sample_seeds_a_run_already_in_progress():
# HA restarted mid-cleanup: the first point must not wait for an event
rec, _hass, _states = _rec()
assert rec._sample("camera.map", 123.0)
assert rec.book.data["m1"]["current"]["points"] == [[1000.0, -500.0]]
def test_junk_position_ignored():
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": {"x": "nope", "y": 1}})
assert not rec._sample("camera.map", 1.0)
states["camera.map"] = S("idle", {})
assert not rec._sample("camera.map", 1.0)
def test_unknown_source_is_noop():
rec, _hass, _states = _rec()
assert not rec._sample("camera.other", 1.0)
def test_object_style_position_is_read():
# Tasshack in-memory attributes hold a Point OBJECT, not a dict
class Point:
def __init__(self, x, y): self.x, self.y = x, y
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": Point(2020, 3096), "map_index": 1})
assert rec._sample("camera.map", 5.0)
assert rec.book.data["m1"]["current"]["points"] == [[2020.0, 3096.0]]
def test_unavailable_vacuum_is_no_verdict():
# HA boot: the vacuum reads unavailable — the open run must NOT be ended
rec, _hass, states = _rec()
rec._sample("camera.map", 1.0)
states["vacuum.x50"] = S("unavailable", {})
assert not rec._sample("camera.map", 2.0)
assert rec.book.data["m1"]["current"]["ended"] is None
del states["vacuum.x50"]
assert not rec._sample("camera.map", 3.0)
assert rec.book.data["m1"]["current"]["ended"] is None
# ---------------- v1.54.0 audit regressions ----------------
def _run_isolated(coro):
"""Run a coroutine on a private loop WITHOUT touching the ambient one.
asyncio.run() clears the thread's current-loop slot when it finishes; in
the CI HA harness (pytest-asyncio keeps a session event loop) that
poisoned the setup of every test that followed — 'There is no current
event loop in thread MainThread' across whole files.
"""
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def test_map_index_zero_matches_frontend_contract():
# HP-1540-02: `map_index: 0` is a VALID first map. The old or-chain
# dropped it and fell through to selected_map — the server stored the
# run under a key the renderer never looked up.
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": {"x": 10, "y": 20}, "map_index": 0})
assert rec._sample("camera.map", 1.0)
assert rec.book.data["m1"]["current"]["map_id"] == "0"
def test_vacuum_selected_map_zero_fallback_recorded_as_zero():
# HP-1541-01: source names no map, vacuum reports selected_map: 0 — the
# recorder must store the run under "0", the same id the fixed card-side
# fallback (vacMapIdWithFallback) resolves. Before the fix the card asked
# for calibration/trails under "default" and never found this run.
rec, _hass, states = _rec()
states["camera.map"] = S("idle", {"vacuum_position": {"x": 10, "y": 20}})
states["vacuum.x50"] = S("cleaning", {"selected_map": 0})
assert rec._sample("camera.map", 1.0)
assert rec.book.data["m1"]["current"]["map_id"] == "0"
def test_map_id_contract_first_not_none_wins():
# HP-1540-02: the shared contract — first NOT-None value, stringified
cases = [
({"map_index": 0}, {"selected_map": "Floor"}, "0"),
({"map_index": "0"}, {}, "0"),
({"map_name": ""}, {"selected_map": "Floor"}, ""),
({"map_name": "A", "map_index": 0}, {}, "A"),
({"current_map": 2}, {}, "2"),
({"selected_map": "Src"}, {"selected_map": "Vac"}, "Src"),
({}, {"selected_map": "Vac"}, "Vac"),
# HP-1541-01: the vacuum-entity fallback with a zero-ish id — must
# match the card's vacMapIdWithFallback (test/vacuum.test.mjs)
({}, {"selected_map": 0}, "0"),
({}, {"selected_map": "0"}, "0"),
({}, {"selected_map": ""}, ""),
({}, {}, "default"),
]
for src_attrs, vac_attrs, want in cases:
assert trails.resolve_map_id(src_attrs, vac_attrs) == want, (src_attrs, vac_attrs)
def test_one_source_two_floor_markers_both_record():
# HP-1540-03: the multi-floor case — the same robot placed on two floors.
# Both markers must receive the server-side run, on every map.
states = {
"vacuum.x50": S("cleaning", {}),
"camera.map": S("idle", {"vacuum_position": {"x": 100, "y": 200}, "map_index": 0}),
}
hass = Hass(states)
rec = trails.TrailRecorder(hass, None)
rec.pairs = {"camera.map": [("m_floor1", "vacuum.x50"), ("m_floor2", "vacuum.x50")]}
rec._on_state(E("camera.map"))
# the robot moves to the second map: BOTH books rotate to the new run
states["camera.map"] = S("idle", {"vacuum_position": {"x": 300, "y": 400}, "map_index": 1})
rec._on_state(E("camera.map"))
for marker in ("m_floor1", "m_floor2"):
book = rec.book.data[marker]
assert book["previous"]["map_id"] == "0", marker
assert book["previous"]["points"] == [[100.0, 200.0]], marker
assert book["current"]["map_id"] == "1", marker
assert book["current"]["points"] == [[300.0, 400.0]], marker
def test_refresh_builds_pair_lists_and_dedups_subscription():
# HP-1540-03: two markers over one source/vacuum → one entity set, both pairs
import asyncio
tracked = []
def track(hass, ents, cb):
tracked.append(list(ents))
return lambda: None
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = track
try:
cfgm = [
{"id": "m_f1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
{"id": "m_f2", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
]
class CS:
async def async_load(self):
return {"config": {"markers": cfgm}}
class RT:
config_store = CS()
hass = Hass({
"vacuum.x50": S("docked", {}),
"camera.map": S("idle", {}),
})
rec = trails.TrailRecorder(hass, RT())
_run_isolated(rec.async_refresh())
assert rec.pairs == {"camera.map": [("m_f1", "vacuum.x50"), ("m_f2", "vacuum.x50")]}
assert tracked == [["camera.map", "vacuum.x50"]]
finally:
trails.async_track_state_change_event = old_track
def test_overlapping_refreshes_leave_one_subscription_teardown_zero():
# HP-1540-05: two config/set refreshes racing across the awaited load used
# to BOTH subscribe; teardown removed only the last handle and the other
# callback leaked until HA restart.
import asyncio
active = []
seq = {"n": 0}
def track(hass, ents, cb):
seq["n"] += 1
hid = seq["n"]
active.append(hid)
return lambda: active.remove(hid)
old_track = trails.async_track_state_change_event
trails.async_track_state_change_event = track
try:
gate = asyncio.Event()
class CS:
async def async_load(self):
await gate.wait()
return {"config": {"markers": [
{"id": "m1", "binding": "entity:vacuum.x50", "vacuum": {"source": "camera.map"}},
]}}
class RT:
config_store = CS()
hass = Hass({
"vacuum.x50": S("docked", {}),
"camera.map": S("idle", {}),
})
async def scenario():
rec = trails.TrailRecorder(hass, RT())
t1 = asyncio.ensure_future(rec.async_refresh())
t2 = asyncio.ensure_future(rec.async_refresh())
for _ in range(3): # both tasks are launched; one parks on the gate
await asyncio.sleep(0)
gate.set()
await t1
await t2
assert len(active) == 1, f"exactly one live subscription, got {active}"
# teardown during an in-flight refresh must also end with zero
gate.clear()
t3 = asyncio.ensure_future(rec.async_refresh())
for _ in range(3):
await asyncio.sleep(0)
rec.teardown()
gate.set()
await t3
assert active == [], f"teardown must leave zero subscriptions, got {active}"
_run_isolated(scenario())
finally:
trails.async_track_state_change_event = old_track
+66
View File
@@ -0,0 +1,66 @@
"""TrailBook: the pure part of the server-side vacuum trails."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan"))
import importlib.util
spec = importlib.util.spec_from_file_location(
"trailbook_pure",
pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py",
)
# import only TrailBook without HA deps: read the source and exec the class
src = (pathlib.Path(__file__).parent.parent / "custom_components" / "houseplan" / "trails.py").read_text(encoding="utf-8")
ns = {"Any": object, "annotations": None}
exec(src[src.index("TRAIL_CAP"):src.index("class TrailRecorder")], ns)
TrailBook = ns["TrailBook"]
TRAIL_CAP = ns["TRAIL_CAP"]
def test_append_dedups_and_records():
b = TrailBook()
assert b.on_point("m", "0", 1.0, 2.0, 100.0)
assert not b.on_point("m", "0", 1.0, 2.0, 101.0) # same point
assert b.on_point("m", "0", 3.0, 4.0, 102.0)
run = b.data["m"]["current"]
assert run["points"] == [[1.0, 2.0], [3.0, 4.0]]
assert run["ended"] is None
def test_end_then_new_run_rotates_current_to_previous():
b = TrailBook()
b.on_point("m", "0", 1, 1, 1.0)
b.on_point("m", "0", 2, 2, 2.0)
assert b.end_run("m", 3.0)
assert not b.end_run("m", 4.0) # idempotent
b.on_point("m", "0", 9, 9, 5.0)
rec = b.data["m"]
assert rec["previous"]["points"] == [[1, 1], [2, 2]]
assert rec["previous"]["ended"] == 3.0
assert rec["current"]["points"] == [[9, 9]]
# a third run forgets the first entirely — exactly two are kept
b.end_run("m", 6.0)
b.on_point("m", "0", 7, 7, 7.0)
assert rec if rec is b.data["m"] else True
assert b.data["m"]["previous"]["points"] == [[9, 9]]
def test_map_switch_mid_run_starts_a_new_run():
b = TrailBook()
b.on_point("m", "floor1", 1, 1, 1.0)
b.on_point("m", "floor2", 2, 2, 2.0)
assert b.data["m"]["current"]["map_id"] == "floor2"
assert b.data["m"]["previous"]["map_id"] == "floor1"
def test_cap_decimates_but_keeps_the_freshest_point():
b = TrailBook()
for i in range(TRAIL_CAP + 1):
b.on_point("m", "0", float(i), 0.0, float(i))
pts = b.data["m"]["current"]["points"]
assert len(pts) <= TRAIL_CAP // 2 + 2
assert pts[-1] == [float(TRAIL_CAP), 0.0]
def test_junk_store_data_tolerated():
b = TrailBook("not a dict")
assert b.data == {}
b.on_point("m", "0", 1, 1, 1.0)
assert b.data["m"]["current"]["points"] == [[1, 1]]
+68
View File
@@ -164,6 +164,24 @@ def test_space_display_settings():
v.SPACE_SCHEMA(bad_mode)
def test_bg_color_setting():
"""Background-around-the-plan color: strict #rrggbb, globally and per space."""
ok_space = {
"id": "f1", "title": "F", "view_box": [0, 0, 1, 1], "rooms": [],
"settings": {"bg_color": "#102030"},
}
v.SPACE_SCHEMA(ok_space)
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#A1b2C3"}})
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok_space, settings={"bg_color": "red"}))
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "url(javascript:x)"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#12345"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#1234567"}})
def test_space_temp_bounds():
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
ok = {
@@ -390,6 +408,16 @@ def test_every_display_mode_the_editor_offers_is_accepted():
v.MARKER_SCHEMA(_marker(display="wat"))
def test_run_target_is_bounded_to_runnable_domains():
"""Owner's spec 2026-07-29: a tap may RUN automations, scripts and scenes —
and nothing else. The target rides the marker, so the schema is the door."""
for ok in ("automation.morning", "script.curtains", "scene.movie"):
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=ok, tap_confirm=True))
for bad in ("light.lamp", "lock.front_door", "shell_command.rm", "automation.", "x"):
with pytest.raises(vol.Invalid):
v.MARKER_SCHEMA(_marker(tap_action="run", tap_target=bad))
def test_every_tap_action_the_editor_offers_is_accepted():
for action in _ts_list("TAP_ACTIONS"):
v.MARKER_SCHEMA(_marker(tap_action=action))
@@ -913,3 +941,43 @@ def test_check_quota_refuses_when_the_disk_is_nearly_full(tmp_path, monkeypatch)
with pytest.raises(plans.QuotaError) as e:
plans.check_quota(d, 1, max_bytes=10 ** 12, max_files=10 ** 6)
assert e.value.reason == "low_disk_space"
class TestVacuum:
"""marker.vacuum (docs/VACUUM.md): optional everywhere, matrices strict."""
def test_full_object_passes(self):
v.MARKER_SCHEMA(_marker(vacuum={
"live": True, "trail": True, "room_highlight": False,
"source": "camera.robo_map",
"calibration": {"0": [0.02, 0.0, 300.0, 0.0, 0.02, 400.0]},
"segment_map": {"16": "kitchen"},
}))
def test_absent_and_none_pass(self):
v.MARKER_SCHEMA(_marker())
v.MARKER_SCHEMA(_marker(vacuum=None))
def test_bad_matrices_rejected(self):
import pytest
for bad in (
[1, 2, 3, 4, 5], # 5 numbers
[1, 2, 3, 4, 5, 6, 7], # 7 numbers
[1, 2, 3, 4, 5, float("nan")], # non-finite
[1, 2, 3, 4, 5, float("inf")],
[1, 2, 3, 4, 5, "x"], # junk type
):
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"calibration": {"0": bad}}))
def test_unknown_keys_rejected(self):
import pytest
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"teleport": True}))
def test_trail_mode_bounded(self):
import pytest
for ok in ("never", "cleaning", "always", None):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": ok}))
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": "sometimes"}))
+2 -1
View File
@@ -8,7 +8,8 @@
"resolveJsonModule": true
},
"include": [
"src/logic.ts",
"src/logic.ts", "src/vacuum.ts",
"src/resize.ts",
"src/rules.ts",
"src/devices.ts",
"src/types.ts",