Compare commits

...
102 Commits
Author SHA1 Message Date
Matysh f8c8cb4eeb Release v1.45.1
Follow-up review of v1.45.0: R3-1 plan collection moved into the config
transaction, R3-2 the static space card now uses the signed background url.
2026-07-27 22:04:22 +03:00
Matysh c749b52a0d v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2
R3-1 (high): v1.45.0 made the upload safe but left deletion to the client —
after a successful save the card asked the backend to remove everything but the
file it had just committed. Two open editors cannot be ordered: a delayed
request from one deleted the plan the other had just saved, leaving the
accepted configuration pointing at nothing, the exact damage copy-on-write was
introduced to prevent.

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

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

Tests: five backend interleaving cases from the report, six unit tests for the
pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build:
the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22
backend-pure.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
2026-07-27 22:01:41 +03:00
Matysh 15e5dd7392 Release v1.45.0
External review of v1.44.8: R2-1 plan upload transaction boundary,
R2-2 signed-url batching and expiry, R2-3 room climate in one registry pass.
2026-07-27 21:14:42 +03:00
Matysh f1b501a956 test: isolate the plan-upload transaction test from a shared config dir
The HA harness reuses one config directory inside a module, so the s1 upload
left by test_plan_set_validates counted as a third file and the cleanup
assertion read 3 instead of 2. Own space id plus a defensive sweep.
2026-07-27 21:11:32 +03:00
Matysh 5d2dbb1009 v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.

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

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

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

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

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

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

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

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

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

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

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

- new WS houseplan/content/sign mints async_sign_path urls (24 h,
  bound to the connection's refresh token, only for our own endpoint)
- the card resolves display urls through _display(): signed when known,
  requests a batched signature otherwise, re-renders when it lands, and
  drops all signatures every 12 h so long-lived wall tablets stay valid
- houseplan-space-card signs its background too
- backend test asserts the unsigned url is refused and the signed one
  returns the bytes WITHOUT an Authorization header
2026-07-27 14:08:29 +03:00
Matysh c0653dfc73 docs: add docs/CHANGELOG.ru.md (Russian changelog from v1.42.0)
- 10 most recent releases translated; older entries stay English-only
- policy updated in STATUS.md and CONTRIBUTING: user-visible changes go
  into BOTH changelogs in the same commit (the user base is largely
  Russian-speaking — see the Telegram chat)
- cross-links between the two files and from both READMEs
2026-07-27 13:57:48 +03:00
Matysh 946e7543ad Merge dev: v1.43.3..v1.44.2 (feedback fixes, control-first card, review CR-1..CR-3) 2026-07-27 13:05:03 +03:00
Matysh 641c61dc19 test: the files-migrate test now sets the integration up like its neighbours
the new CR-2/CR-3 test sent WS commands without a config entry, so the
handlers were not registered and CI reported success=False
2026-07-27 13:02:10 +03:00
Matysh ae9168f6ec fix v1.44.2: external review CR-1..CR-3
CR-1: the lock invariant is restated precisely (never by an accidental
tap; the door card's labeled button is the ONE sanctioned surface),
unlocking now confirms, and smoke_lock_invariant exercises all five
actuation paths (icon tap, controls[], card entities, _cardToggle,
opening card).

CR-2: attachment migration is transactional — the server COPIES files,
the config is committed with its revision check, and only then the old
folder is removed via the new houseplan/files/cleanup. A rejected save
no longer leaves the stored urls pointing at an emptied folder.

CR-3: migrate returns an exact {source: written} mapping; only confirmed
copies are rewritten, destination name collisions get a unique name
instead of silently linking a pre-existing file, and a failed migration
raises a toast instead of being swallowed.

+1 unit test (119), +1 backend test, +1 smoke (51 total); docs
same-commit
2026-07-27 12:58:27 +03:00
Matysh 45c863138a docs v1.44.1: add the Telegram community chat (@ha_houseplan)
- badges + header line in README.md / README.ru.md
- 'Getting help & sharing your plan' section in both READMEs, asking for
  the version number when reporting (console banner / integration page)
- .github/ISSUE_TEMPLATE/config.yml contact links (chat + discussions)
- CONTRIBUTING 'Where to ask'; STATUS (community row) and SCOPE (field
  feedback source)
2026-07-27 12:51:48 +03:00
Matysh e04ef2f2e6 feat v1.44.0: control-first device card + light-source flag (user feedback)
- device card opens with controllable entities: toggles inline (finger
  targets), cover/lock/climate hand off to HA more-info; metadata and
  manuals moved below; config/diagnostic entities filtered; locks still
  never toggle from a card
- marker.is_light: a smart switch driving dumb fixtures glows in the
  light-sources fill (its own entity or the bound controls) — no
  light-group helper needed
- backend schema; smoke_card_controls.mjs, smoke_glow extended; docs
  same-commit
2026-07-27 12:41:26 +03:00
Matysh a841d17543 ux v1.43.3: room gear discoverability, bigger metrics, touch tooltips take two
- the room gear became a fixed-size pill button (was 0.9em/60% opacity
  inside the label — invisible in practice, field report); shown on
  unnamed rooms too, which is where you name them
- metrics line 0.62em -> 0.75em (unreadable on tablets)
- tooltips: latch on the first touch/pen pointer event instead of
  trusting (hover: none) alone; any touch drops an open tip
- smoke_feedback_v2.mjs; docs same-commit
2026-07-27 12:37:57 +03:00
Matysh e63b7882a6 Merge dev: v1.43.0..v1.43.2 (external audit: P0, P1 and the test layer) 2026-07-27 12:22:31 +03:00
Matysh c1e3cdb768 test: HA-harness expectations follow the authenticated content URLs (audit B1)
- upload/plan_set tests asserted the old public /houseplan_files/... paths
  and only run in CI, so the B1 change surfaced there
- +test for the fail-closed admin check (audit B2/T4: the authorization
  boundary had zero coverage)
2026-07-27 11:23:39 +03:00
Matysh 41b20e1901 test v1.43.2: smokes that can fail, in CI, and an honest TESTING.md
T1: demo/serve.mjs exports check/checkAll/finish — all 48 smokes now
assert named facts and exit non-zero on a mismatch or an uncaught
in-card exception (verified by breaking the kiosk guard on purpose).
Informational values were frozen from a v1.43.1 run and cross-read
against the source; timings assert budgets, not exact numbers.

T2: new CI job 'smoke' gated on 'frontend', builds a FRESH bundle
before running (the committed demo/srv/assets copy is a snapshot) and
uploads per-file logs on failure.

T3: [auto] now means 'a named failing check exists' and each line names
it (43 lines); 72 aspirational markers honestly downgraded to [manual].
Fixed the 'ZERO edit buttons' contradiction (wrong since v1.30.1) and
the opening-click line (true again since v1.43.1).

Three smokes carried pre-v1.39.0/v1.25 expectations and were testing
old behaviour: tap defaults for lights, card-wide tap action, label drag
requiring plan mode.

DEVELOPMENT.md documents the harness contract.
2026-07-27 11:20:31 +03:00
Matysh 49b0cb4e05 perf/fix v1.43.1: external audit P1 — render cost, drag threshold, geometry, backend
L1: memoized space model + open pairs (structural fingerprint key, epoch
bumped synchronously at mutation time, not inside the debounce); hoisted
per-room geometry out of the render loop; smoke asserts zero recomputation
across state pushes.

L4: openings get the 3 px drag threshold used by every other pipeline and
only write when the geometry actually changed — taps open the dialog again.

G2: interiorPoint() replaces the vertex mean, so island rooms inside
concave (U/L) parents are accepted and their evenodd holes render; traced
duplicates still are not containment.

G3: segKey rounds before ordering — one shared wall, one key.

B2: _check_write fails closed when the entry is unavailable.
B3: layout/set honours expected_rev and returns the new rev.
B4: config/set without expected_rev over a non-empty store logs a warning.
B5: coordinates reject NaN/Infinity; spaces/rooms/markers/decor/layout capped.

+2 unit tests (118), +2 backend tests (14), smoke_render_perf; docs
same-commit
2026-07-27 10:58:18 +03:00
Matysh 0fd0ba408d fix v1.43.0: external audit P0 — data loss, split geometry, auth, dialog zombies
L2 (silent data loss): debounce gains flush()/pending(); _reloadConfigOnly
flushes a pending write and defers while one is in flight; conflict path
forces; failed reload now toasts instead of an empty catch; teardown flushes.

G1 (split corruption): same-edge cuts carve the niche properly instead of
walking the outline twice; partition invariant (parts sum to the original)
rejects anything else; +1 unit test covering 5 niche shapes and both legacy
cut shapes.

B1 (unauthenticated content): plans and marker files move to
HouseplanContentView (/api/houseplan/content/..., requires_auth); only the
card bundle stays static; contentUrl() rewrites legacy URLs on read (no
storage migration); repairs.py accepts both prefixes; +1 unit test.

L3 (dialog zombies): all four save catch-blocks guard against a closed
dialog; the card no longer blanks when a save fails after Esc.

smokes: smoke_save_race, smoke_dialog_zombie; docs (TESTING/CHANGELOG/
ARCHITECTURE incl. the optimistic-UI note) same-commit
2026-07-27 10:44:58 +03:00
Matysh 5c7d1ca8bb Merge dev: v1.41.2..v1.42.2 (room settings tier, font scales, pdf migration, touch tooltips) 2026-07-27 10:19:03 +03:00
Matysh b4bb732736 fix v1.42.2: no hover tooltips on touch devices
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 52s
Validate / backend (push) Failing after 3m54s
- taps on tablets synthesized mousemove and popped the tooltip over the
  finger (user feedback item 5); _showTip gated by (hover: none)
- smoke_touch_tips.mjs (matchMedia shim); TESTING/CHANGELOG same-commit
2026-07-27 10:15:30 +03:00
Matysh 10d4084d17 feat v1.42.1: room-card font sizes — 3 sliders with a live preview
- space.settings.card_font_scale (tier 2 base) + room.settings
  name_scale/label_scale (tier 3), 50-300%, multiplied together and
  stacking with resize-k and kiosk per-screen multipliers
- live sample card in both the space and room dialogs
- backend schema; smoke_font_scales.mjs (9 checks); docs same-commit
2026-07-27 10:12:22 +03:00
Matysh 19e19b5c5f feat v1.42.0: room settings — the third settings tier
- four-tier principle fixed in ARCHITECTURE: global > space > room >
  device, specific overrides general, unset inherits
- room.settings { fill_mode, temp_source, hum_source } with backend
  schema; pure roomFillModeOf + sourceValue (+2 test suites, 114)
- gear on room cards in the Plan editor opens Room settings: rename,
  re-area (current area included in the list), fill override (may opt
  out of glow darkness), explicit temp/hum source with a searchable
  device+entity dropdown; the same section in the creation dialog
- source feeds the room card, tooltip and temp fill; works for rooms
  without an HA area (user-feedback case #1)
- smoke_room_settings.mjs (10 checks); TESTING/CHANGELOG/ARCHITECTURE
  same-commit
2026-07-27 09:58:21 +03:00
Matysh 88dc0d1de7 fix v1.41.2: uploaded files survive marker rebinding
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 56s
Validate / backend (push) Failing after 6m31s
- new WS houseplan/files/migrate moves /files/<oldId>/ to the new id
  (admin-only, sanitized ids, merge-safe, removes the empty old dir)
- _saveMarker calls it and rewrites pdf urls (migratePdfUrls pure
  helper, +1 unit test, 112) when rebinding changes the id
- field incident: the sauna heater manuals pointed at an orphaned
  old-id folder that got cleaned up; data restored by hand on the
  home instance (official Harvia PDFs re-downloaded)
- TESTING/CHANGELOG same-commit
2026-07-26 17:42:28 +03:00
Matysh ad7e946e75 Merge dev: v1.40.2..v1.41.1 (kiosk mode, speaker icons, SEO README, new demo GIF)
Validate / backend (push) Failing after 6m2s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m21s
2026-07-23 21:49:55 +03:00
Matysh 9191a94701 docs: new hero demo GIF (real home, owner-approved 2026-07-24); old synthetic kept as demo-synthetic-old.gif 2026-07-23 21:46:14 +03:00
MatyshandGitHub eea669fa12 Add files via upload 2026-07-23 21:45:45 +03:00
Matysh bd9fabfe99 docs v1.41.1: SEO rework of README en/ru
- keyword-rich hero + badges + feature highlights (glow, controls,
  kiosk, virtual walls, room cards, server-side storage)
- kiosk recipe relocated before Installation; RU parity
2026-07-23 21:38:06 +03:00
Matysh b03ef70794 docs: catch STATUS/ARCHITECTURE/UX-MODES up to v1.41.0, kiosk recipe in README.ru
- STATUS.md: fresh snapshot (version, dev-branch workflow, PAT, HACS
  queue reality, test counts) + feature-surface digest since 07-17
- ARCHITECTURE.md: decor layer, glow clip model, open boundaries math,
  controls, island rooms, kiosk, nav persistence
- UX-MODES.md: kiosk as the fourth interaction surface
- README.ru.md: wall-tablet recipe (parity with English)
2026-07-23 21:24:58 +03:00
Matysh 1d6ca968e8 feat v1.41.0: kiosk mode for wall tablets and TVs
- kiosk: true — header hidden, editors hard-blocked, full-height stage;
  full View interactivity preserved (live states, glow, taps, locks)
- swipe between spaces at 1:1 (swipeTarget pure helper, wrap + dots
  indicator), pan wins while zoomed, double tap resets zoom
- cycle: N auto-carousel with 60 s pause after any touch (TV/burn-in)
- per-SCREEN size multipliers (icons x0.5-3, room-card font) in
  localStorage via a 3 s long-press popover; clampScale helper
- GUI editor fields, README wall-tablet recipe, +2 unit tests (111),
  smoke_kiosk.mjs (12 checks); TESTING/CHANGELOG same-commit
2026-07-23 21:12:58 +03:00
Matysh 82eed100b2 ux v1.40.2: default speaker icon for smart speakers (Alice et al.)
- rules split: mdi:soundbar only for soundbars; колонки/станции/yandex/
  alice/speaker -> mdi:speaker; +1 unit test (109)
2026-07-23 18:20:38 +03:00
Matysh f27c91ade1 Merge dev: v1.38.3..v1.40.1 (dashed walls in editor, default light toggle, smart guides, room link) 2026-07-23 18:19:48 +03:00
Matysh 4b4df4b3a2 ux v1.40.1: room click removed — link icon on the room card instead
- rooms inert in View (default cursor); mdi:open-in-new after the room
  name (View, rooms with an area) navigates to the HA area
- smoke_room_link.mjs (7 checks); TESTING/CHANGELOG/UX-MODES same-commit
2026-07-23 17:46:11 +03:00
Matysh df9e158efb feat v1.40.0: smart guides — alignment helper in every editor
- alignGuides/segmentAngle/is45 pure helpers (+2 unit tests, 108);
  per-context candidates (room vertices + path pts / other icons /
  decor endpoints+corners / other room cards), nearest per axis, max
  two guides, dashed accent lines with a source dot
- cursor badge shows length + angle, green on 45deg multiples
- indication only, no magnetism (owner's decision); guides live in
  plan/devices/decor, never in View
- smoke_align_guides.mjs (9 checks); TESTING/CHANGELOG same-commit
2026-07-23 17:38:33 +03:00
Matysh 0522413c48 feat v1.39.0: pure light sources toggle on click by default
- resolveTapAction: no explicit action + domain light -> toggle (kettle
  et al. keep info via their non-light primary); explicit choice wins
- device dialog shows the effective default (defaultTap)
- unit tests updated (+1, 106); smoke_light_default_tap.mjs; docs
  same-commit
2026-07-23 17:28:38 +03:00
Matysh 9bfef453db fix v1.38.4: derived wall segments trimmed under open boundaries
- the markup layer's .seg lines ran solid through open stretches;
  cutSegments extracted (outlineWithout now reuses it) and applied to
  _segments in _renderMarkupLayer
- smoke_openwall: planSegCut check (15 total); docs same-commit
2026-07-23 17:20:06 +03:00
Matysh 9c92bcdf2f ux v1.38.3: true dashed open boundaries in the Plan editor too
- room outline trimming applies in markup (blue .room-outline.outlined);
  merge/split-picked rooms keep the full amber stroke
- smoke_openwall extended (14 checks); TESTING/CHANGELOG same-commit
2026-07-23 17:17:01 +03:00
Matysh 8adb262410 Merge dev: v1.35.0..v1.38.2 2026-07-23 16:48:05 +03:00
Matysh 3811a1ca73 feat v1.38.2: restore last space and editor mode across reloads
- LS_NAV stores {space, mode}; restored in setConfig from the cached
  config, with a retry after the live config when the cache was stale;
  deep-link hash wins; edit modes restored for admins only
- UX-MODES updated (owner reversed the 'always start in View' rule)
- smoke_nav_persist.mjs; TESTING/CHANGELOG same-commit
2026-07-23 15:19:09 +03:00
Matysh 8895354c4e ux v1.38.1: tap-action cleanup + right-click more-info
- per-device action: Device card (default) / more-info / Toggle; 'as
  card default' removed, card editor's global tap_action field deleted
  and ignored; RU: 'по нажатию'
- right click on an icon in View opens more-info (native menu in
  editors; virtual w/o entity -> device card)
- smoke_tap_ctx.mjs; TESTING/CHANGELOG same-commit
2026-07-23 15:11:52 +03:00
Matysh ea41bec86b ux v1.38.0: binding section — radios + entities checkbox + dropdown
- Virtual / Pick-from-HA radios; Show-entities checkbox with tooltip
  gates device entities (groups/helpers always listed); searchable
  dropdown only in HA mode, auto-opens when empty, closes on pick;
  Save guarded until a binding is chosen; logic untouched
- smoke_binding_ui.mjs (16 checks); marker_stay/controls smokes green;
  TESTING/CHANGELOG same-commit
2026-07-23 14:52:01 +03:00
Matysh 05f162434b fix v1.37.3: true dashed open boundary, rendered above the glow
- outlineWithout trims the rooms' solid strokes under open stretches
  (.room.noedge kills the polygon stroke incl. hover; trimmed
  .room-outline path draws the remaining walls)
- dash color follows the space stroke color; openwalls layer moved
  after the glow layer
- +1 unit test (105); smoke_openwall extended; docs same-commit
2026-07-23 14:34:41 +03:00
Matysh 0e14139fe2 ux v1.37.2: glow falloff 70/30 (was 80/20) 2026-07-23 14:29:11 +03:00
Matysh 6b9909768f ux v1.37.1: open-wall tool hover — default cursor, pointer + stretch preview near walls
- _openWallHit shared by click and hover; amber dashed preview of the
  stretch that would open, red solid when the click would close it;
  stage.wallhot drives the pointer cursor
- smoke_openwall_hover.mjs (9 checks); TESTING/CHANGELOG same-commit
2026-07-23 14:24:55 +03:00
Matysh 20c7b55a87 feat v1.37.0: open boundaries (virtual walls)
- room.open_to symmetric links; 'Open boundary' plan tool toggles the
  shared wall pair under the click (sharedBoundary collinear-overlap
  math + distToSegment pull, same as Split); dashed rendering with an
  amber hot state while the tool is active; Esc -> Draw
- glow: the clip becomes the transitive open zone (openZoneOf BFS,
  either-direction links) + door sectors from the zone's outer walls
- model rooms carry open_to; backend ROOM_SCHEMA open_to: [str]
- +3 unit tests (104), smoke_openwall.mjs (8 checks); docs same-commit
2026-07-23 14:18:50 +03:00
Matysh 3a89966e4b ux v1.36.4: glow pool fully lit to 80% radius, falloff on outer 20% 2026-07-23 14:03:15 +03:00
Matysh 8330b48cf3 fix v1.36.3: door sectors no longer punch dark wedges inside the room
- room outline + sectors as separate clipPath children (always union)
  instead of subpaths of one nonzero path where opposite windings
  cancelled the overlap; smoke asserts one contour per path
- TESTING/CHANGELOG same-commit
2026-07-23 13:57:44 +03:00
Matysh 81ea5c0f3c feat v1.36.2: per-source glow radius
- marker.glow_radius_cm overrides the global default per device (field
  in the marker dialog, HA units, placeholder = global default); pools
  and door sectors use the per-source radius
- backend schema; smoke_glow extended (11 checks); docs same-commit
2026-07-23 13:53:19 +03:00
Matysh df2ed0c3e1 fix v1.36.1: hidden grouped lamps toggle the lamp, not the DND switch
- primaryEntity: tiered selection, domain priority beats hidden flag
  (hidden light > visible config switch); visible same-domain still wins
- live-debugged on the real install: individual lamps hidden in the
  registry (light group setup) got switch.*_do_not_disturb / identify
  buttons as primary — tap toggle 'did nothing'
- +1 unit test (101); TESTING/CHANGELOG same-commit
2026-07-23 13:24:02 +03:00
Matysh d7a1b344e4 feat v1.36.0: marker controls — wall switches that really switch
- marker.controls[]: bound light.*/switch.* entities; explicit per-marker
  tap_action=toggle flips them with HA-group semantics in one call
  (controlsAction + isControllable pure helpers, +2 unit tests, 100)
- icon state and RGB tint mirror the targets (stateless remotes and
  virtual dumb-switch markers finally show something); info card lists
  targets with states; locks filtered everywhere
- chips+search UI in the marker dialog; backend schema; smoke_controls
  (9 checks); TESTING/CHANGELOG same-commit
2026-07-23 13:10:58 +03:00
Matysh 7eaf513c9e feat v1.35.0: glow fill — dark house with glowing light sources
- fill_mode 'glow': uniform darkness over every room; lit lamps render
  radial gradient pools (rgb_color -> color temp via kelvinToRgb ->
  configurable default; brightness scales alpha)
- pools clipped by the source's room + doorway sectors (doorSector rays
  to door edges; hasRoomBehind blocks entrance doors); windows and
  islands don't participate (no shadow casting, documented)
- glow radius in HA units (m/ft) in general settings, stored in cm;
  palette group glow_base/glow_light; backend schema updated
- +4 unit tests (98 total); smoke_glow.mjs (10 checks); docs same-commit
2026-07-23 12:51:48 +03:00
Matysh 1e20279adf Merge dev: v1.34.0 (island rooms)
Validate / backend (push) Failing after 5m14s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 1m18s
2026-07-23 12:44:55 +03:00
Matysh 031e5439eb feat v1.34.0: island rooms — nested contours with evenodd holes
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 47s
Validate / backend (push) Failing after 3m19s
- roomsOverlap: full nesting is legal (islands), edge crossings and
  partial overlaps still rejected; polyContainsPoly + islandsOf pure
  helpers (+2 unit tests, 94 total)
- draw tool: per-click point-in-room rejection removed, validation at
  contour closing; parent rooms with islands render as evenodd paths
  (ring fills correctly, island stays clickable through the hole)
- smoke_island_rooms.mjs; smoke_merge_split stale room-count asserts
  fixed (rg pushed a 5th room; cancelWhole silently false for ages)
- TESTING/CHANGELOG same-commit
2026-07-22 22:59:43 +03:00
Matysh 2bf9e44178 Merge dev: v1.32.0..v1.33.5 (background editor, polyline split, editor UX)
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m19s
Validate / backend (push) Failing after 7m38s
2026-07-22 15:34:59 +03:00
Matysh c2eaf50118 ux v1.33.5: extended tooltips on the editor tabs 2026-07-22 15:32:43 +03:00
Matysh ac082569d7 fix v1.33.4: device icon stays in place when rebinding or changing room
- rebinding migrates the layout position to the new marker id instead of
  recentering; room change within the same space keeps the position
  (owner's decision); new icons and cross-space moves still center
- smoke_marker_stay.mjs; TESTING/CHANGELOG same-commit
2026-07-22 15:29:54 +03:00
Matysh a90316c9f3 fix v1.33.3: icon picker shows the auto icon when none set
- marker dialog stores autoIcon (DevItem.icon); ha-icon-picker gets it
  as placeholder, fallback input too; 'Auto: mdi:...' hint with preview
  under the picker, hidden once an explicit icon is chosen
- smoke_icon_placeholder.mjs; TESTING/CHANGELOG same-commit
2026-07-22 15:23:30 +03:00
Matysh 912613bbf8 ux v1.33.2: remove the Reset button from the Device editor
- _resetLayout wiped the whole layout (all spaces: device positions,
  room cards, scales) behind one confirm — low value, high blast radius
- i18n keys dropped; smoke_editor_tabs expects 3 devbar tools;
  TESTING/CHANGELOG same-commit
2026-07-22 15:18:23 +03:00
Matysh 935a519c32 ux v1.33.1: dot grid in all editors, faded plan in the background editor
- _editing getter; grid defs+rect rendered for plan/devices/decor
- decor mode fades rooms/devlayer/openings/rlabel to 0.35, decor stays
  opaque
- smoke_grid_fade.mjs (9 checks, transition-aware); docs same-commit
2026-07-22 14:54:32 +03:00
Matysh dd930b64f7 feat v1.33.0: background editor — visual decor layer
- third mode tab: draw lines/rects/ovals/text with grid snap, drag
  preview; select+move (Delete key), erase, color/width/fill controls;
  Esc ladder; text dialog with S/M/L sizes and dblclick re-edit
- decor renders under rooms, visible in every mode, click-transparent
  outside its editor; stored in space.decor with backend schema
  (line/rect/ellipse/text variants), rev/optimistic locking as usual
- smoke_decor.mjs (19 checks); smoke_editor_tabs updated to 3 tabs;
  TESTING/CHANGELOG/UX-MODES same-commit
2026-07-22 14:45:25 +03:00
Matysh f235c8afc5 ux v1.32.1: dashed hover preview for the Opening tool
- _openingPreview getter mirrors the click's snap (same eps, same
  existing-opening hit test); dashed 90cm ghost + center dot on the wall
- smoke_opening_preview.mjs; TESTING/CHANGELOG same-commit
2026-07-22 14:33:26 +03:00
Matysh 5e6f9c407c feat v1.32.0: polyline split, tool cursors, Esc walk-back in merge/split
- splitRoomPath in logic.ts: wall-to-wall polyline cut with interior
  vertices; validates wall crossings, self-intersection, degenerate
  parts; splitRoom delegates (2-point path); +3 unit tests (93 total)
- split UI: interior clicks add snapped intermediate points, live
  polyline + vertices + preview; new/updated toasts
- cursors: pointer for merge/delroom and split room-pick stage,
  crosshair while cutting (stage tool-* classes)
- Esc: split — drop last point, then room pick, then back to draw;
  merge — clear selection, then back to draw
- smoke_split_polyline.mjs (14 checks); TESTING/CHANGELOG same-commit
2026-07-22 14:27:39 +03:00
Matysh 849117eb27 Merge dev: v1.31.1-v1.31.2 (plan editor fixes) 2026-07-22 13:31:00 +03:00
Matysh 37b39e7a1e fix v1.31.2: merge/split room pick highlight visible again
- .room.picked moved after .room.outlined: equal specificity, source
  order silently killed the amber highlight in markup (gotcha x4,
  documented inline)
- smoke_merge_highlight.mjs (waits out the 0.12s transition before
  reading computed colors); TESTING/CHANGELOG same-commit
2026-07-22 12:19:15 +03:00
Matysh d4f2d81a2f fix v1.31.1: room card interactions no longer feed the active markup tool
- _markupClick ignores clicks originating from .roomlabel/.rlhandle
  (composedPath) and anything during an active drag/resize
- smoke_card_tool_conflict.mjs (draw/delroom/handle/during-resize +
  normal stage click still works); TESTING/CHANGELOG same-commit
2026-07-22 12:15:01 +03:00
Matysh bcb546dbc7 Merge dev: v1.30.2..v1.31.0 batch (editor tabs redesign, Esc, room cards, scope) 2026-07-22 12:10:13 +03:00
Matysh ccf36e283e docs: fix product scope in docs/SCOPE.md (owner decision 2026-07-22)
- mission, personas/surfaces, core jobs J1-J7 with coverage status,
  partial list, in-mission gaps, hard out-of-scope list, excess audit
  (PDF/links + virtual devices kept frozen, LQI promoted to J7)
- STATUS.md points to SCOPE.md as the feature guard rail
2026-07-22 12:08:02 +03:00
Matysh 821bdfbd9a feat v1.31.0: room cards — metrics line + proportional resize
- room label becomes a card: name on top, optional metrics below
  (temperature / humidity / avg zigbee / lights), four checkboxes in
  space settings, all off by default; lights render On/Off or '1 of 3'
- areaHum + areaLightStats pure helpers (+3 unit tests, 90 total)
- resize via corner handles in the Plan editor (hover), uniform 0.5-3x,
  scale stored as layout k next to the position; drag preserves it
- fix latent v1.25 regression: draggable HTML labels were never rendered
  in the Plan editor (static SVG only) — real name-only cards now render
  there, draggable and resizable
- repair merge/split smokes still calling removed _toggleMarkup
- validation.py: 4 label_* bools; smoke_room_cards.mjs; docs same-commit
2026-07-22 12:01:14 +03:00
Matysh 22992b256f ux v1.30.4: Escape closes all dialogs
- Esc closes the topmost dialog: opening/device info, icon rules, general
  settings, marker dialog, opening editor, space dialog (abandons import
  queue like Cancel); draw-undo via Esc preserved with dialog priority
- smoke_esc_dialogs.mjs; TESTING/CHANGELOG same-commit
2026-07-22 11:39:39 +03:00
Matysh d273a90db8 ux v1.30.3: general settings gear visible in every mode
- header cog (fill palette) no longer gated to Plan mode; canEdit only
- smoke_gs_always.mjs; TESTING/CHANGELOG same-commit
2026-07-22 11:37:00 +03:00
Matysh 48d47a46af ux v1.30.2: editor tabs redesign
- two tabs only: 'Plan editor' / 'Device editor'; View is the implicit
  default state, no tab
- Device editor gets its own bottom toolbar (add/show-all/reset/rules
  moved from the header), mirroring the Plan toolbar
- X button on both toolbars and inside the active tab returns to View;
  re-click on active tab is a no-op; Plan<->Devices switches directly
- smoke_editor_tabs.mjs; TESTING/CHANGELOG/UX-MODES same-commit
2026-07-22 11:33:24 +03:00
Matysh 0d712b9069 Merge v1.30.1: space gear in every mode + alignment 2026-07-22 11:21:13 +03:00
Matysh dfa2a8badc fix v1.30.1: space gear visible in every mode + vertical alignment
- cog next to the space name shows in View/Plan/Devices (canEdit only);
  '+' add-space tab stays Plan-only
- tabs flex-centered so the cog aligns with the tab text
- smoke_gear_tabs.mjs; TESTING.md + CHANGELOG same-commit
2026-07-22 11:18:52 +03:00
Matysh 2dfab3565b Merge v1.30.0: lock action in the opening info card 2026-07-22 11:13:44 +03:00
Matysh 6ee09d3750 feat v1.30.0: explicit Unlock/Lock action in the opening info card
- View-mode door/window info card gets an Unlock (red) / Lock button when a
  lock entity is bound and available; disabled while locking/unlocking,
  hidden when unavailable
- plan-icon taps still never toggle locks (hard block untouched);
  smoke_lock_action.mjs covers the full matrix incl. the block
- TESTING.md, CHANGELOG, UX-MODES same-commit
2026-07-22 11:11:18 +03:00
Matysh 29c5a89aad Merge v1.29.0: 'new device' red-dot flag 2026-07-22 10:52:32 +03:00
Matysh 71f44fd528 feat v1.29.0: 'new device' flag with a red dot
- auto devices/light groups appearing after install get a server-side 'new'
  flag (settings.new_device_ids) and a red dot top-right of the icon; opening
  the device's editor clears it for every client
- known_devices baseline seeded silently on first run — upgrades never flood
  the plan with dots; hand-made markers never flagged
- pure diffNewDevices + unit tests; backend schema; smoke_new_device.mjs;
  TESTING.md row
2026-07-22 10:48:55 +03:00
Matysh c8722642b7 Merge inert-openings: View-mode cursors and inert openings v1.28.1 2026-07-22 10:44:23 +03:00
Matysh 0a7b9a627f fix v1.28.1 (revised per owner spec): View-mode cursors and inert openings
- device icons: pointer cursor in View (grab only in Devices mode); click
  behavior untouched
- openings in View: pure drawings always — no cursor/hover/hit/click
- lock badge: the one exception — visible and clickable in View (info card),
  inert in Plan; removed a duplicate pointer-events in .oplock
- smoke covers all cursor/badge cases
2026-07-22 10:42:12 +03:00
Matysh 961d6afe67 fix v1.28.1: openings fully inert in View mode
- op-hit: pointer-events none + default cursor by default; interactive (grab,
  hover outline, drag) only inside Plan; lock badge is a pure indicator
- opening click edits in Plan with any tool; no interaction whatsoever in View
- smoke_inert_openings.mjs; TESTING.md updated
2026-07-22 10:34:03 +03:00
Matysh b9fecbdd95 Merge subarea-rooms: manual placement without an HA area v1.28.0 2026-07-22 10:27:31 +03:00
Matysh 9fd36bd710 feat v1.28.0: sub-area rooms — manual device placement without an HA area (issue #3)
- area-less rooms appear in the marker room list ('no area, manual'); markers
  store room_id and land at the room centre; dialog reopen restores the choice
- pure parseRoomRef (space#area / space#@roomId) + unit tests; backend schema
- ROADMAP phase 11 closed; TESTING.md row; smoke_subarea.mjs
2026-07-22 10:25:20 +03:00
Matysh a9346c9c14 Merge rgb-alarm: RGB light colors + alarm pulse v1.27.0 2026-07-22 10:23:15 +03:00
Matysh 794b02b84f feat v1.27.0: RGB light colors + red alarm pulse (issue #3)
- lightColorOf(): on+rgb_color tints icon/glow/ripple (explicit ripple color wins);
  brightness ignored by design; off/white/unavailable unchanged
- isAlarmState(): leak/smoke/gas/CO/safety/tamper/problem sensors and sirens in
  'on' pulse a red ring over any display mode; outages never alarm;
  prefers-reduced-motion honoured
- +2 unit tests, smoke_rgb_alarm.mjs, TESTING.md rows
2026-07-22 10:21:08 +03:00
Matysh 3bc25ad5ae Merge state-icons: state-reflecting icons + value display v1.26.0 2026-07-22 10:19:42 +03:00
Matysh d1a79cd0b4 feat v1.26.0: state-reflecting icons + 'value instead of an icon' display (issue #3)
- stateIcon(): door/window/garage open-closed, lock locked-unlocked, bulb on;
  custom icons and unavailable/unknown never morph; gated by live_states
- marker display 'value': the measurement (with unit) is the marker body,
  corner badges hidden; numeric fallback chain temp - hum - primary state
- TESTING.md rows, smoke_state_value.mjs, +1 unit test
2026-07-22 10:17:32 +03:00
Matysh 5522399a3a Merge ux-modes: three interaction modes v1.25.0 2026-07-22 10:13:08 +03:00
Matysh 185604396d feat v1.25.0: three interaction modes — View / Plan / Devices (docs/UX-MODES.md)
- View (always the default): display + device interaction only; no dragging of
  icons/labels/openings; panning can start on an icon; clean header
- Plan: markup tools, openings editing (click-to-edit, drag along walls), label
  drag, space dialogs, fill palette; orange frame; icons hidden but labels visible
- Devices: icon drag only here, click opens the marker editor directly; add/
  show-all/reset/rules buttons; accent frame
- segmented mode control (admins only); removed: markup toggle button, view-mode
  opening drag/dblclick (v1.23.1), 'drag anywhere' (v1.9 reversed)
- README en+ru updated; TESTING.md Modes section; smoke_modes.mjs
2026-07-22 10:11:02 +03:00
Matysh 9d0e2cab00 docs: approved UX-modes design (View/Plan/Devices tabs) — docs/UX-MODES.md + ROADMAP phase 11
Research only, no product changes. Confirms and structures the owner's mandate
(view mode must be display-only) and issue #3 feedback; lists deprecations and
approved follow-up features with implementation iterations.
2026-07-22 10:02:55 +03:00
Matysh 73f176ea2f Merge light-none: 'no light sources' fill color v1.24.2 2026-07-22 09:40:05 +03:00
Matysh b7599c642c feat v1.24.2: 'no light sources' color in the lights fill group
Default opacity 0 preserves the historical no-fill behavior; assigning an
opacity tints lightless rooms distinguishably from 'all lights off'.
2026-07-22 09:38:05 +03:00
Matysh 4ec7e7081f Merge tab-gear: gear icon on space tabs v1.24.1 2026-07-22 09:35:34 +03:00
Matysh ee5f65752a style v1.24.1: gear instead of pencil on space tabs (the dialog is settings, not renaming) 2026-07-22 09:33:32 +03:00
Matysh 574d3b8f9a Merge general-settings: global fill palette + per-space LQI toggle v1.24.0 2026-07-22 09:30:38 +03:00
Matysh 90c558eee7 feat v1.24.0: general settings (global fill palette) + per-space LQI toggle
- General settings dialog: fill colors grouped by mode (light on/off, temp
  cold/ok/hot, lqi weak/strong), each with its own opacity; lqi fill lerps
  between the endpoints; stored in settings.fill_colors (defaults omitted);
  space-card uses the same palette
- per-space show_lqi toggle (badges + room tooltip line), inherits the card's
  show_signal when unset
- fillColorsOf/lerpColor/roomFillStyle helpers (+4 tests), backend schemas,
  smoke_general_settings; TESTING.md updated in the same commit
2026-07-22 09:28:38 +03:00
107 changed files with 14054 additions and 1184 deletions
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Telegram chat (@ha_houseplan)
url: https://t.me/ha_houseplan
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
- name: 💡 GitHub discussions
url: https://github.com/Matysh/houseplan-card/discussions
about: Longer-form ideas and show-and-tell.
+38
View File
@@ -34,6 +34,44 @@ jobs:
run: npm run build
- name: Card bundle in sync with integration
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
smoke:
# audit T2: the end-to-end layer used to run only when a human remembered.
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a FRESH bundle for the smokes
# the committed demo/srv/assets copy is a snapshot; testing it would
# report green about code that no longer exists (audit T2)
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
fail=0
mkdir -p /tmp/smoke-logs
for f in demo/smoke_*.mjs; do
name=$(basename "$f" .mjs)
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
echo "ok $name"
else
echo "FAIL $name"
tail -20 "/tmp/smoke-logs/$name.log"
fail=1
fi
done
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: smoke-logs
path: /tmp/smoke-logs
backend:
runs-on: ubuntu-latest
steps:
+13
View File
@@ -3,6 +3,19 @@
Thanks for your interest! The project is one HACS package: a storage **integration**
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
## Changelog
User-visible changes go into **both** changelogs in the same commit:
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
older than v1.42.0 exist only in the English file — no need to backfill them.
## Where to ask
Not sure whether something is a bug, or just want to discuss an idea before
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
is the quickest route to the author and other users. Bugs and concrete feature
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
## Five-minute setup
```bash
+71 -6
View File
@@ -1,10 +1,40 @@
# 🏠 House Plan — an interactive house plan for Home Assistant
# 🏠 House Plan — interactive floor plan card for Home Assistant
**A live map of your home right inside Home Assistant: floors, rooms and devices on a real floor plan — with live states, temperature and signal strength. Everything is configured with the mouse, without a single line of YAML.**
[![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)
[![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)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
![House Plan demo](docs/images/demo.gif)
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
a floor plan, outline the rooms with your mouse — and every smart device appears
in its real place: live states, tap-to-toggle lights, temperature and humidity per
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
🇷🇺 [Документация на русском](README.ru.md)
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
🇷🇺 [Документация на русском](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
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).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
of its own color that spills through doorways and open zone boundaries.
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
comfort-range temperature fills, per-room signal heatmap.
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
explicit button, never an accidental tap.
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
auto-carousel, per-screen icon sizes.
- 🔔 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.
---
@@ -49,6 +79,23 @@ Key advantages in short:
---
## Wall tablet / TV (kiosk mode)
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
(or tick "Wall device (kiosk) mode" in the card editor):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
```
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
tune icon and text sizes for THIS screen (saved per device). To hide Home
Assistant's own header use the companion app's kiosk settings or the
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
## Installation
One click if you already run HACS:
@@ -119,7 +166,7 @@ Later you can add as many spaces as you like (floors, yard, garage) with the **
### Step 2. Outline the rooms
After the first space is added, the card switches to markup mode by itself. Click grid points, connecting them with lines, and close the room outline by clicking the first point.
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
@@ -166,7 +213,7 @@ The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on
### Step 5. Put the icons in their places
Device icons can be **dragged with the mouse at any time** — no separate "edit mode" needs to be enabled. Positions are saved on the server and are identical in all browsers and devices. The **↺** button in the header restores the automatic layout.
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
![Dragging icons — available at all times](docs/images/06-edit.png)
@@ -212,6 +259,24 @@ turned the way it is mounted.
---
## Getting help & sharing your plan
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
setup help, feature ideas, and screenshots of your plans. The fastest way to
reach the author and other users.
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
reports and feature requests (please attach your House Plan version).
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
longer-form ideas.
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
([на русском](docs/CHANGELOG.ru.md)).
When reporting a problem, the version number helps a lot: it is shown in the
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
Services → House Plan**.
---
## Frequently asked questions
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
+68 -5
View File
@@ -1,13 +1,42 @@
# 🏠 House Plan — интерактивный план дома для Home Assistant
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.**
[![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)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
![House Plan demo](docs/images/demo.gif)
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
🇬🇧 [English documentation](README.md)
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
**Главное**
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
---
## Что это и зачем
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
@@ -49,6 +78,23 @@ House Plan показывает ваш умный дом так, как он в
---
## Настенный планшет / ТВ (киоск-режим)
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
галочка «Режим настенного устройства» в редакторе карточки):
```yaml
type: custom:houseplan-card
kiosk: true
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
```
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
настройка размеров значков и текста для ЭТОГО экрана (хранится на
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
## Установка
В один клик, если у вас уже есть HACS:
@@ -168,7 +214,7 @@ title: План дома
### Шаг 5. Расставьте значки по местам
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку.
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
@@ -216,6 +262,23 @@ title: План дома
---
## Помощь и обмен опытом
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
связаться с автором и другими пользователями.
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
и запросы фич (пожалуйста, указывайте версию House Plan).
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
развёрнутых обсуждений.
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
быстрее.
---
## Часто задаваемые вопросы
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
+7 -6
View File
@@ -28,9 +28,10 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanUploadView
from .http_api import HouseplanContentView, HouseplanUploadView
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
return True
@@ -61,14 +62,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
await hass.http.async_register_static_paths(static_paths)
# NOTE (audit B1): plans and marker files are NO LONGER static.
# They are served by HouseplanContentView, which requires auth.
# Only the card bundle stays public — Lovelace resources must be.
if static_paths:
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions
if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
if not card_path.exists():
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
+28
View File
@@ -0,0 +1,28 @@
"""Single source of truth for the write-authorization policy.
The WS and HTTP paths used to duplicate this decision and drifted apart: the
WS copy was fixed to fail closed while the upload view still failed OPEN when
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
one behaviour.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY
from .store import get_entry
def may_write(hass: HomeAssistant, user) -> bool:
"""True when `user` may modify House Plan data.
Fails CLOSED: when the entry cannot be read — during a reload, or while the
integration is disabled — the policy is unknown, and "unknown" is not the
same as "permissive": only admins are allowed through.
"""
is_admin = bool(getattr(user, "is_admin", False))
entry = get_entry(hass)
if entry is None:
return is_admin
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
return is_admin if admin_only else True
+14 -1
View File
@@ -9,9 +9,22 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
CONTENT_URL = "/api/houseplan/content"
# How many paths one houseplan/content/sign call may carry. The card batches to
# the same number; a client that sends more used to get a partial answer with no
# way to tell which paths were dropped (review R2-2).
MAX_SIGN_PATHS = 200
# An uploaded plan that no accepted configuration references is collected only
# once it is this old. Age is a race guard, not a policy: a plan uploaded
# seconds ago may belong to another client's transaction that has not written
# its configuration yet (review R3-1).
PLAN_ORPHAN_TTL_S = 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.23.2"
VERSION = "1.45.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+58 -9
View File
@@ -18,8 +18,8 @@ except ImportError: # older HA versions
KEY_HASS = "hass" # type: ignore[assignment]
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL
from .store import get_entry
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
from .auth import may_write
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -32,6 +32,59 @@ _LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024
_MIME = {
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".webp": "image/webp",
".gif": "image/gif",
".txt": "text/plain",
}
class HouseplanContentView(HomeAssistantView):
"""Authenticated read access to plans and marker files (audit B1).
The directories used to be exposed as unauthenticated static paths, so
anyone who could reach the HA endpoint could pull floor plans and uploaded
manuals without logging in. This view keeps the same URLs but requires a
Home Assistant session (or a signed path, which the frontend uses for
<image href> inside the SVG).
"""
url = "/api/houseplan/content/{kind}/{sub}/{name}"
name = "api:houseplan:content"
requires_auth = True
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
hass: HomeAssistant = request.app[KEY_HASS]
if kind not in ("plans", "files"):
return web.Response(status=404)
safe_sub = sanitize_marker_id(sub)
safe_name = sanitize_filename(name)
if not safe_sub or not safe_name:
return web.Response(status=404)
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
# plans live flat in one directory: the sub segment is a placeholder ("_")
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
# defence in depth: the sanitizers already strip separators
if not str(path).startswith(str(base)):
return web.Response(status=404)
def _read() -> bytes | None:
return path.read_bytes() if path.is_file() else None
blob = await hass.async_add_executor_job(_read)
if blob is None:
return web.Response(status=404)
return web.Response(
body=blob,
content_type=_MIME.get(path.suffix.lower(), "application/octet-stream"),
headers={"Cache-Control": "private, max-age=3600"},
)
class HouseplanUploadView(HomeAssistantView):
"""POST /api/houseplan/upload — save a marker file, return its URL."""
@@ -42,12 +95,8 @@ class HouseplanUploadView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
entry = get_entry(hass)
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
if admin_only:
user = request.get("hass_user")
if user is None or not user.is_admin:
return web.json_response({"error": "unauthorized"}, status=403)
if not may_write(hass, request.get("hass_user")):
return web.json_response({"error": "unauthorized"}, status=403)
marker_id = "misc"
filename: str | None = None
@@ -99,5 +148,5 @@ class HouseplanUploadView(HomeAssistantView):
mtime = await hass.async_add_executor_job(_write)
return web.json_response(
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{safe_name}?v={mtime}", "name": filename}
)
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.23.2"
"version": "1.45.1"
}
+88
View File
@@ -0,0 +1,88 @@
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
The file system is not part of the configuration store's transaction, so who
may delete a plan file, and when, is a correctness question rather than a
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
because it is the part that has to be reasoned about and tested.
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_S
from .validation import PLAN_EXTENSIONS
_LOGGER = logging.getLogger(__name__)
def plan_basename(url: Any) -> str:
"""File name a stored plan_url points at ('' when there is none)."""
if not isinstance(url, str) or not url:
return ""
return url.split("?", 1)[0].rsplit("/", 1)[-1]
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
"""Plan file names a configuration references."""
out: set[str] = set()
for sp in (cfg or {}).get("spaces") or []:
name = plan_basename(sp.get("plan_url"))
if name:
out.add(name)
return out
def is_plan_file(name: str) -> bool:
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
parts = name.split(".")
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
def collect_plans(
plans_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""Drop plan files the accepted configuration made obsolete (review R3-1).
Called inside the config write lock, right after the new revision is
stored, so it decides from the two configurations that actually bracket the
commit instead of trusting a client to say what may be deleted. The earlier
design — a `plan/cleanup` command carrying `keep` — could not be ordered
against another client's commit: a delayed call removed the file that
client had just saved, leaving the accepted configuration pointing at
nothing, which is the damage copy-on-write was introduced to prevent.
Two rules, both conservative:
* a file the OLD configuration referenced and the new one does not was
authoritative and has been superseded — remove it;
* any other unreferenced plan file is a rejected or abandoned upload, and
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
belong to a transaction that has not committed yet.
"""
if not plans_dir.is_dir():
return 0
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
for item in sorted(plans_dir.iterdir()):
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if not superseded and not stale:
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
return removed
+9 -3
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, PLANS_DIR, PLANS_URL
from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
from .store import HouseplanConfigEntry
@@ -25,9 +25,15 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
res = []
for sp in spaces:
url = sp.get("plan_url") or ""
if not url.startswith(PLANS_URL + "/"):
# both the legacy static URL and the authenticated content URL
prefix = None
if url.startswith(PLANS_URL + "/"):
prefix = PLANS_URL + "/"
elif url.startswith(CONTENT_URL + "/plans/_/"):
prefix = CONTENT_URL + "/plans/_/"
if prefix is None:
continue # external/legacy URL — not ours to verify
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
fname = url[len(prefix) :].split("?", 1)[0]
if not (plans_dir / fname).is_file():
res.append((sp.get("id", "?"), fname))
return res
+103 -18
View File
@@ -47,13 +47,33 @@ def valid_space_id(value: str) -> bool:
# ---------- voluptuous schemas ----------
def _finite(value):
"""Coerce to float and reject NaN/Infinity (audit B5).
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
silently corrupting a stored position forever.
"""
f = float(value)
if f != f or f in (float("inf"), float("-inf")):
raise vol.Invalid("coordinate must be a finite number")
return f
# generous caps: the product targets 20-200 devices and a handful of floors
MAX_SPACES = 50
MAX_ROOMS = 400
MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_LAYOUT = 5000
POS_SCHEMA = vol.Schema(
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
{vol.Required("x"): _finite, vol.Required("y"): _finite},
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
)
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
POINT = vol.All([_finite], vol.Length(min=2, max=2))
def _require_geometry(room: dict) -> dict:
@@ -68,10 +88,24 @@ ROOM_SCHEMA = vol.All(
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float),
vol.Optional("h"): vol.Coerce(float),
vol.Optional("open_to"): [str],
vol.Optional("settings"): vol.Any(
None,
vol.Schema(
{
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
},
extra=vol.ALLOW_EXTRA,
),
),
vol.Optional("x"): _finite,
vol.Optional("y"): _finite,
vol.Optional("w"): _finite,
vol.Optional("h"): _finite,
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
},
extra=vol.ALLOW_EXTRA,
@@ -84,13 +118,42 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("show_names"): bool,
vol.Optional("room_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"]),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
vol.Optional("label_temp"): bool,
vol.Optional("label_hum"): bool,
vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
},
extra=vol.ALLOW_EXTRA,
)
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
vol.Optional("fill"): bool},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
extra=vol.ALLOW_EXTRA),
)
SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
@@ -98,16 +161,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
vol.Required("rooms"): [ROOM_SCHEMA],
vol.Optional("openings"): [
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
vol.Optional("openings"): vol.All([
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("x"): vol.Coerce(float),
vol.Required("y"): vol.Coerce(float),
vol.Required("angle"): vol.Coerce(float),
vol.Required("x"): _finite,
vol.Required("y"): _finite,
vol.Required("angle"): _finite,
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
vol.Optional("contact"): vol.Any(str, None),
vol.Optional("lock"): vol.Any(str, None),
@@ -117,7 +181,7 @@ SPACE_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
],
], vol.Length(max=MAX_OPENINGS)),
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
@@ -139,6 +203,10 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], None),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
vol.Optional("ripple_color"): vol.Any(str, None),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
@@ -152,9 +220,26 @@ MARKER_SCHEMA = vol.Schema(
)
CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): [SPACE_SCHEMA],
vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
)
+214 -20
View File
@@ -1,8 +1,11 @@
"""House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations
import logging
import base64
import binascii
import secrets
from pathlib import Path
from typing import Any
@@ -13,8 +16,10 @@ from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
PLANS_DIR, PLANS_URL,
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .plans import collect_plans
from .store import HouseplanData, get_data, get_entry
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
@@ -22,6 +27,9 @@ from .validation import (
)
_LOGGER = logging.getLogger(__name__)
@callback
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
@@ -32,6 +40,9 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -48,9 +59,8 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
def _check_write(hass: HomeAssistant, connection) -> bool:
entry = get_entry(hass)
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
return connection.user.is_admin if admin_only else True
"""May this connection write? Thin wrapper over the shared policy."""
return may_write(hass, getattr(connection, "user", None))
# ---------------- layout ----------------
@@ -68,11 +78,21 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
@websocket_api.websocket_command(
{vol.Required("type"): "houseplan/layout/set", vol.Required("layout"): LAYOUT_SCHEMA}
{
vol.Required("type"): "houseplan/layout/set",
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Optional("expected_rev"): int,
}
)
@websocket_api.async_response
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Replace the layout entirely."""
"""Replace the layout entirely, with optimistic locking (audit B3).
Wholesale layout writes used to have no revision check at all, so two
clients silently overwrote each other. `expected_rev` is optional for
backwards compatibility with older cards, but when supplied it is enforced
exactly like the config store does.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
return
@@ -80,8 +100,15 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
async with rt.write_lock:
await rt.store.async_save({"layout": msg["layout"]})
connection.send_result(msg["id"], {"ok": True})
data = await rt.store.async_load() or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error(
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
@websocket_api.websocket_command(
@@ -108,6 +135,153 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/migrate",
vol.Required("from_id"): str,
vol.Required("to_id"): str,
}
)
@websocket_api.async_response
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""COPY a marker's uploaded files to its new id and report the exact mapping.
Rebinding changes the marker id, so the files must follow (that is how the
owner lost a set of manuals, 2026-07-26). This used to MOVE them before the
revision-checked config save: when that save was rejected, the server kept
the old urls while the files had already left the old folder — a permanent
broken link (review CR-2, 2026-07-27).
Now it copies, never overwrites, and returns {src: dst} for every file so
the client can rewrite EXACTLY the urls that made it (review CR-3). The old
folder is removed later by houseplan/files/cleanup, once the config is
safely committed.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
import shutil
from pathlib import Path
from .const import FILES_DIR
from .validation import sanitize_marker_id
src_id = sanitize_marker_id(msg["from_id"])
dst_id = sanitize_marker_id(msg["to_id"])
if not src_id or not dst_id or src_id == dst_id:
connection.send_result(msg["id"], {"ok": True, "mapping": {}, "copied": 0})
return
base = Path(hass.config.path(FILES_DIR))
src = base / src_id
dst = base / dst_id
def _copy() -> dict[str, str]:
if not src.is_dir():
return {}
dst.mkdir(parents=True, exist_ok=True)
mapping: dict[str, str] = {}
for f in sorted(src.iterdir()):
if not f.is_file():
continue
target = dst / f.name
if target.exists():
# a different file already owns this name — do NOT silently
# point the url at it; give the copy a unique name instead
stem, suffix = f.stem, f.suffix
i = 2
while (dst / f"{stem} ({i}){suffix}").exists():
i += 1
target = dst / f"{stem} ({i}){suffix}"
shutil.copy2(str(f), str(target))
mapping[f.name] = target.name
return mapping
try:
mapping = await hass.async_add_executor_job(_copy)
except OSError as err:
connection.send_error(msg["id"], "io_error", f"Could not copy marker files: {err}")
return
connection.send_result(msg["id"], {"ok": True, "mapping": mapping, "copied": len(mapping)})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/content/sign",
vol.Required("paths"): [str],
}
)
@websocket_api.async_response
async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Sign content paths so the BROWSER can fetch them.
Home Assistant authenticates HTTP requests by a Bearer header or an
`authSig` signed path — there is no cookie auth. An <image href> inside SVG
and a plain <a href> can send neither, so after the content endpoint became
`requires_auth` the plan backgrounds and PDF links returned 401 (audit
follow-up B1 regression, 2026-07-27 — reproduced live).
The card asks for signatures and uses the signed urls for display.
"""
from datetime import timedelta
from homeassistant.components.http.auth import async_sign_path
out: dict[str, str] = {}
token_id = getattr(connection, "refresh_token_id", None)
for path in msg["paths"][:MAX_SIGN_PATHS]:
if not isinstance(path, str) or not path.startswith(CONTENT_URL + "/"):
continue # only ever sign our own content endpoint
clean = path.split("?", 1)[0]
try:
try:
signed = async_sign_path(hass, clean, timedelta(hours=24), refresh_token_id=token_id)
except TypeError: # older HA signature: (hass, refresh_token_id, path, expiration)
signed = async_sign_path(hass, token_id, clean, timedelta(hours=24))
except Exception as err: # noqa: BLE001 — signing must never break the card
_LOGGER.warning("House Plan: could not sign %s: %s", clean, err)
continue
out[path] = signed
connection.send_result(msg["id"], {"urls": out})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/cleanup",
vol.Required("marker_id"): str,
}
)
@websocket_api.async_response
async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Delete a marker's file folder — called only AFTER the config is committed."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
import shutil
from pathlib import Path
from .const import FILES_DIR
from .validation import sanitize_marker_id
mid = sanitize_marker_id(msg["marker_id"])
if not mid:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
base = Path(hass.config.path(FILES_DIR)).resolve()
target = (base / mid).resolve()
if not str(target).startswith(str(base)) or target == base:
connection.send_result(msg["id"], {"ok": True, "removed": False})
return
def _rm() -> bool:
if not target.is_dir():
return False
shutil.rmtree(target, ignore_errors=True)
return True
removed = await hass.async_add_executor_job(_rm)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/layout/delete",
@@ -147,6 +321,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -171,6 +346,15 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
current_rev = data.get("rev", 0)
if "expected_rev" not in msg and current_rev:
# audit B4: expected_rev stays optional for old cards mid-upgrade,
# but a blind overwrite of a non-empty store is worth a warning —
# it is exactly how a stale client silently discards someone's work.
_LOGGER.warning(
"House Plan: config/set without expected_rev over rev %s — "
"the client bypasses conflict detection (outdated card?)",
current_rev,
)
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
connection.send_error(
msg["id"], "conflict",
@@ -179,6 +363,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
# still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1)
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
@@ -219,20 +408,25 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
return
# Copy-on-write: a plan is written under a NEW unique name and nothing is
# deleted here (review R2-1). The old name stays readable, so a config write
# that is later rejected — revision conflict, validation, lost connection —
# leaves the stored plan exactly as it was. The card calls
# nothing here; the superseded file is collected by `config/set` itself,
# inside the write lock, once a revision that no longer references it has
# been accepted (review R3-1). A crash in between leaves an orphan, which
# the same collector removes on a later commit once it is old enough.
#
# `.` separates the id from the token because a space id cannot contain one
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
# files of a differently named space.
plans_dir = Path(hass.config.path(PLANS_DIR))
path = plans_dir / f"{space_id}.{msg['ext']}"
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
path = plans_dir / name
def _write() -> int:
def _write() -> None:
plans_dir.mkdir(parents=True, exist_ok=True)
# remove old variants with a different extension
for old_ext in PLAN_EXTENSIONS:
old = plans_dir / f"{space_id}.{old_ext}"
if old_ext != msg["ext"] and old.exists():
old.unlink()
path.write_bytes(raw)
return int(path.stat().st_mtime)
mtime = await hass.async_add_executor_job(_write)
connection.send_result(
msg["id"], {"ok": True, "url": f"{PLANS_URL}/{space_id}.{msg['ext']}?v={mtime}"}
)
await hass.async_add_executor_job(_write)
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
+43
View File
@@ -0,0 +1,43 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
// glow на текущем пространстве
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
c.requestUpdate(); await c.updateComplete;
out.mode = c._mode;
out.suppress = c._suppressClick;
out.holdFired = c._holdFired;
out.drag = !!c._drag;
// реальный клик по включённой лампе (элемент .dev)
const litDev = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
out.hasLit = !!litDev;
const el = [...sr().querySelectorAll('.dev')].find((e) => e.textContent.includes(litDev.name) || true);
// найдём элемент точно: по индексу устройства
const devEls = [...sr().querySelectorAll('.dev')];
out.devCount = devEls.length;
const target = devEls[c._devices.filter((d) => d.space === c._space).indexOf(litDev)] || devEls[0];
const before = calls.length;
c._infoCard = null;
// эмулируем полный цикл: pointerdown/up + click
const opts = { bubbles: true, composed: true };
target.dispatchEvent(new PointerEvent('pointerdown', { ...opts, pointerId: 5 }));
target.dispatchEvent(new PointerEvent('pointerup', { ...opts, pointerId: 5 }));
target.dispatchEvent(new MouseEvent('click', opts));
await c.updateComplete;
out.reaction = calls.length > before ? 'service:' + JSON.stringify(calls.at(-1)) : c._infoCard ? 'info' : 'NOTHING';
out.suppressAfter = c._suppressClick;
// и через прямой вызов
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), litDev);
await c.updateComplete;
out.directReaction = calls.length > before + 1 ? 'service' : c._infoCard ? 'info' : 'NOTHING';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+36 -1
View File
@@ -6,10 +6,45 @@ 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' };
// ---- assertion harness (audit T1) --------------------------------------
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
// build reported success. `check()` accumulates named failures, `finish()`
// prints them and sets the exit code.
const _failures = [];
let _pageErrors = 0;
/** Assert one named fact. `expected` defaults to true. */
export function check(name, actual, expected = true) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
return ok;
}
/** Assert a whole result object: every key must equal true unless listed. */
export function checkAll(out, expected = {}) {
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
return out;
}
/** Print the result, report failures, close the browser, set the exit code. */
export async function finish(browser, out) {
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
await browser?.close?.();
if (_failures.length) {
console.error('\nFAILED (' + _failures.length + '):');
for (const f of _failures) console.error(' - ' + f);
process.exitCode = 1;
} else {
console.log('OK');
}
}
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
page.on('pageerror', (e) => console.log('EXC', e.message));
// audit T1: an exception inside the card used to be logged and ignored
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
await page.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
+56
View File
@@ -0,0 +1,56 @@
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 g = c._gridPitch;
const guides = () => sr().querySelectorAll('.alignline').length;
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
const v = poly[0];
c._path = [[v[0] + g * 10, v[1] + g * 10]];
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
c.requestUpdate(); await c.updateComplete;
out.drawGuide = guides() >= 1;
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
// нет выравнивания → нет гидов
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
out.noGuideOffAxis = guides() === 0;
// 2) бейдж угла: 45° красит
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
const ml = sr().querySelector('.measurelabel');
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
const ml2 = sr().querySelector('.measurelabel');
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
c._path = []; c._cursorPt = null;
// 3) редактор устройств: drag значка в линию с другим значком
c._setMode('devices'); await c.updateComplete;
const devs = c._devices.filter((d) => d.space === c._space);
const a = devs[0], b = devs[1];
const pa = c._pos(a);
// поставим b на тот же Y, начнём drag
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } };
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
c.requestUpdate(); await c.updateComplete;
out.devGuide = guides() >= 1;
c._drag = null; c.requestUpdate(); await c.updateComplete;
out.devGuideGone = guides() === 0;
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
c._setMode('decor'); await c.updateComplete;
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1);
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
c.requestUpdate(); await c.updateComplete;
out.decorGuide = guides() >= 1;
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
// 5) в Просмотре гидов не бывает
c._setMode('view'); await c.updateComplete;
out.noneInView = guides() === 0;
return out;
});
checkAll(res);
await finish(browser, res);
+50
View File
@@ -0,0 +1,50 @@
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;
c._setMode('devices'); await c.updateComplete;
// новый маркер: радио «Виртуальное» по умолчанию, списка нет
c._openMarkerDialog(); await c.updateComplete;
const radios = () => [...sr().querySelectorAll('.bindsel input[type="radio"]')];
out.twoRadios = radios().length === 2;
out.virtDefault = radios()[0].checked && !radios()[1].checked;
out.noDropWhenVirtual = !sr().querySelector('.dropbtn');
// переключить на «Из списка HA» → появляется дропдаун, открыт (нет выбора), сейв заблокирован
radios()[1].click(); await c.updateComplete;
out.dropShown = !!sr().querySelector('.dropbtn');
out.panelOpen = !!sr().querySelector('.droppanel');
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
out.saveDisabled = saveBtn?.disabled === true;
// без чекбокса в списке нет individual-сущностей устройств
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
const entLabel = c._t('marker.sub_entity');
out.noDeviceEntities = !subs().some((s) => s.includes(entLabel));
// (группы/хелперы в демо все уже размещены и потому скрыты как занятые —
// их «всегда в списке» проверяется кодом: блок вне чекбокса)
// включить чекбокс → сущности появились
const cb = sr().querySelector('.entcheck input');
cb.click(); await c.updateComplete;
out.entitiesShown = [...sr().querySelectorAll('.cand .cs')].some((s) => s.textContent.includes(entLabel));
out.tooltip = sr().querySelector('.entcheck').getAttribute('title') === c._t('marker.show_entities_tip');
// выбрать первый кандидат → панель закрылась, сейв разблокирован
sr().querySelector('.cand').click(); await c.updateComplete;
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
out.panelClosed = !sr().querySelector('.droppanel');
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
// радио назад на «Виртуальное» → binding=virtual
radios()[0].click(); await c.updateComplete;
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
c._markerDialog = null; await c.updateComplete;
// редактирование существующего устройства: радио «Из списка», выбранная привязка в кнопке
const dev = c._devices.find((d) => !d.virtual && d.bindingKind === 'device');
c._openMarkerDialog(dev); await c.updateComplete;
out.editHaMode = c._markerDialog.bindingMode === 'ha';
out.editShowsCur = sr().querySelector('.dropbtn b') !== null;
out.editPanelClosed = !sr().querySelector('.droppanel');
c._markerDialog = null;
return out;
});
checkAll(res);
await finish(browser, res);
+44
View File
@@ -0,0 +1,44 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
// устройство с управляемой сущностью
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('.dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|| body.querySelector('.entlist') === body.children[0];
const rows = [...sr().querySelectorAll('.entrow')];
out.rows = rows.length > 0;
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
const btn = sr().querySelector('.entbtn');
out.hasButton = !!btn;
const box = btn?.getBoundingClientRect();
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
// 3) кнопка реально переключает
const before = calls.length;
btn.click(); await c.updateComplete;
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
// 4) замок никогда не переключается из карточки
const n = calls.length;
c._cardToggle('lock.front_door');
out.lockNeverToggles = calls.length === n;
// 5) диагностические/конфиг-сущности не засоряют список
const ents = c._cardEntities(dev).map((e) => e.eid);
out.noConfigEntities = ents.every((e) => {
const cat = c.hass.entities[e]?.entity_category;
return cat !== 'config' && cat !== 'diagnostic';
});
c._infoCard = null; await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+51
View File
@@ -0,0 +1,51 @@
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;
c._setMode('plan'); await c.updateComplete;
const stage = sr().querySelector('.stage');
const label = sr().querySelector('.roomlabel');
out.hasLabel = !!label;
// 1) draw: клик по карточке не добавляет точку
c._tool = 'draw'; c._path = []; await c.updateComplete;
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
await c.updateComplete;
out.drawIgnored = c._path.length === 0;
// 2) клик по угловой метке тоже не считается кликом инструмента
const handle = label.querySelector('.rlhandle');
handle.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
await c.updateComplete;
out.handleIgnored = c._path.length === 0;
// 3) во время резайза клики по сцене игнорируются
const room = c._spaceModel().rooms.find((r) => r.name);
c._rlResize = { id: 'rl_' + room.id, space: c._space, k0: 1, cx: 0, cy: 0, d0: 50 };
const before = c._path.length;
c._markupClick(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 200 }));
out.duringResizeIgnored = c._path.length === before;
c._rlResize = null;
// 4) обычный клик по сцене (мимо карточки) по-прежнему работает — точка ставится
const r = stage.getBoundingClientRect();
// найти долю экрана, попадающую в свободное место (не внутри комнаты)
let fx = 0.4, fy = 0.4;
outer: for (let ix = 1; ix < 20; ix++) for (let iy = 1; iy < 20; iy++) {
const ev = new MouseEvent('click', { clientX: r.left + r.width * ix / 20, clientY: r.top + r.height * iy / 20 });
const raw = c._svgPoint(ev);
if (!c._roomAt(c._snap(raw))) { fx = ix / 20; fy = iy / 20; break outer; }
}
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
clientX: r.left + r.width * fx, clientY: r.top + r.height * fy }));
await c.updateComplete;
out.normalClickWorks = c._path.length === 1;
// 5) delroom: клик по карточке не зовёт confirm (переопределим)
let confirmCalled = false;
window.confirm = () => { confirmCalled = true; return false; };
c._tool = 'delroom'; c._path = []; await c.updateComplete;
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
await c.updateComplete;
out.delroomIgnored = !confirmCalled;
return out;
});
checkAll(res);
await finish(browser, res);
+76
View File
@@ -0,0 +1,76 @@
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
// перечитывании метаданных, которые не менялись. Карта строится один раз на
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
let scans = 0;
const wrap = (h) => {
const ents = h.entities;
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
return { ...h, entities: traced };
};
const fresh = () => wrap(window.__mkHass());
// включаем и заливку по температуре, и подписи — два потребителя климата
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
})};
c._cfgEpoch++;
c.hass = fresh(); await c.updateComplete;
scans = 0;
c.hass = fresh(); await c.updateComplete;
const fewRooms = scans;
// повторные рендеры на том же снимке hass реестр не трогают
scans = 0;
c.requestUpdate(); await c.updateComplete;
c.requestUpdate(); await c.updateComplete;
out.scansOnRerender = scans;
// главный инвариант: обходов НЕ становится больше от числа комнат
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
const extra = [];
for (let i = 0; i < 40; i++) {
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
}
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
c._cfgEpoch++;
c.hass = fresh(); await c.updateComplete;
scans = 0;
c.hass = fresh(); await c.updateComplete;
out.roomCount = c._spaceModel('f1').rooms.length;
out.scansSameWith44Rooms = scans === fewRooms;
out.scansPerUpdate = scans;
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
out.tempBefore = c._climate().get('living_room')?.temp;
const h = fresh();
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
c.hass = h; await c.updateComplete;
out.tempAfter = c._climate().get('living_room')?.temp;
out.climateIsMap = c._climate() instanceof Map;
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
// не само число, а что оно не растёт вместе с числом комнат.
checkAll(res, {
scansOnRerender: 0,
roomCount: 44,
scansSameWith44Rooms: true,
scansPerUpdate: 2,
tempBefore: 22.4,
tempAfter: 33.3,
climateIsMap: true,
});
await finish(browser);
+61
View File
@@ -0,0 +1,61 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
// два подопытных light-entity
const lights = Object.keys(c.hass.states).filter((e) => e.startsWith('light.')).slice(0, 2);
out.twoLights = lights.length === 2;
const setSt = async (m) => {
const st = { ...c.hass.states };
for (const [e, v] of Object.entries(m)) st[e] = { ...st[e], state: v };
c.hass = { ...c.hass, states: st }; await c.updateComplete;
};
// виртуальный маркер-выключатель с controls и tap_action=toggle
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog();
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
await c._saveMarker(); await c.updateComplete;
const dev = c._devices.find((d) => d.name === 'Выключатель');
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
c._setMode('view'); await c.updateComplete;
// 1) все выключены → клик включает все
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
const dev2 = () => c._devices.find((d) => d.name === 'Выключатель');
c._clickDevice(new MouseEvent('click'), dev2());
out.turnOnAll = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_on', lights]);
// 2) частично включено → клик выключает все
await setSt({ [lights[0]]: 'on', [lights[1]]: 'off' });
c._clickDevice(new MouseEvent('click'), dev2());
out.partialTurnsOff = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_off', lights]);
// 3) значок отражает цели: горит одна → класс on
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
out.stateOn = !!c._stateClass(dev2()).includes('on');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const n = calls.length;
c._clickDevice(new MouseEvent('click'), dev2());
out.infoNoToggle = calls.length === n && !!c._infoCard;
// 5) инфо-карточка показывает цели
await c.updateComplete;
out.infoListsTargets = sr().querySelectorAll('.ctrlstate').length === 2;
c._infoCard = null; await c.updateComplete;
// 6) замок в controls отфильтрован при клике (защита)
cfg.tap_action = 'toggle'; cfg.controls = ['lock.front_door', lights[0]];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
await setSt({ [lights[0]]: 'off' });
c._clickDevice(new MouseEvent('click'), dev2());
const last = calls.at(-1);
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
return out;
});
checkAll(res);
await finish(browser, res);
+74
View File
@@ -0,0 +1,74 @@
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 esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// 1) третья вкладка есть
const tabs = [...sr().querySelectorAll('.modetab')];
out.threeTabs = tabs.length === 3;
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
const g = c._gridPitch;
c._decorDraft = { kind: 'rect', a: [g * 10, g * 10], b: [g * 10, g * 10], pid: 1 };
c._decorDraft = { ...c._decorDraft, b: [g * 20, g * 16] };
await c.updateComplete;
out.draftPreview = !!sr().querySelector('.ddraft');
c._decorCommitDraft(); await c.updateComplete;
out.rectSaved = c._curSpaceCfg.decor?.length === 1 && c._curSpaceCfg.decor[0].kind === 'rect';
out.rectFill = c._curSpaceCfg.decor[0].fill === true && c._curSpaceCfg.decor[0].color === '#ff0000';
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
// 3) линия и овал
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
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;
// вырожденная фигура не сохраняется
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
// 4) надпись через диалог
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
c._decorSaveText(); await c.updateComplete;
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
// 5) select: перемещение сохраняет форму, drag двигает
const rect = c._decorList.find((x) => x.kind === 'rect');
c._decorTool = 'select';
c._decorMove = { id: rect.id, start: [0, 0], orig: JSON.parse(JSON.stringify(rect)), pid: 7, moved: false };
c._decorMoveUpdate({ clientX: 0, clientY: 0, }); // без смещения
const before = { ...c._decorList.find((x) => x.id === rect.id) };
c._decorMove.start = [0, 0];
// сдвиг на 5 клеток по x: подделаем _svgPoint? проще напрямую:
const m = c._decorMove; const dx5 = (g * 5) / 1000;
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
c._decorMove = null;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
out.eraseWorks = c._decorList.length === n0 - 1;
// 7) Esc-лестница: инструмент → select → выход
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
await esc(); out.escTool = c._decorTool === 'select' && c._mode === 'decor';
await esc(); out.escExit = c._mode === 'view';
// 8) в Просмотре фигуры видны, но inert
const shp = sr().querySelector('.decorlayer .dshape');
out.visibleInView = !!shp;
out.inertInView = shp ? getComputedStyle(shp).pointerEvents === 'none' : null;
// 9) Delete удаляет выбранное
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._decorTool = 'select'; c._decorSel = c._decorList[0].id; const n1 = c._decorList.length; await c.updateComplete;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); await c.updateComplete;
out.deleteKey = c._decorList.length === n1 - 1;
return out;
});
checkAll(res);
await finish(browser, res);
+55
View File
@@ -0,0 +1,55 @@
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;
let pageError = null;
window.addEventListener('error', (e) => { pageError = String(e.message); });
// сохранение падает, а диалог закрыт до ответа — карточка не должна умереть
c.hass = { ...c.hass, callWS: async (msg) => {
if (String(msg.type).endsWith('/set')) { await new Promise((r) => setTimeout(r, 60)); throw new Error('boom'); }
return { config: c._serverCfg, rev: c._cfgRev };
} };
await c.updateComplete;
// 1) диалог общих настроек
c._openSettingsDialog(); await c.updateComplete;
const p = c._saveSettingsDialog();
c._settingsDialog = null; // Esc во время сохранения
await c.updateComplete;
await p.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
out.settingsStaysClosed = c._settingsDialog === null;
out.cardAliveAfterSettings = !!sr().querySelector('.stage');
// 2) диалог правил
c._openRulesDialog(); await c.updateComplete;
const p2 = c._saveRules();
c._rulesDialog = null; await c.updateComplete;
await p2.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
out.rulesStaysClosed = c._rulesDialog === null;
out.cardAliveAfterRules = !!sr().querySelector('.stage');
// 3) диалог устройства
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const p3 = c._saveMarker();
c._markerDialog = null; await c.updateComplete;
await p3.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
await c.updateComplete;
out.markerStaysClosed = c._markerDialog === null;
out.cardAliveAfterMarker = !!sr().querySelector('.stage');
out.noPageError = pageError === null;
// 4) при ОТКРЫТОМ диалоге ошибка снимает busy (поведение сохранено)
c._openSettingsDialog(); await c.updateComplete;
const p4 = c._saveSettingsDialog();
await p4.catch(() => {});
await new Promise((r) => setTimeout(r, 120));
out.busyClearedWhenOpen = c._settingsDialog !== null && c._settingsDialog.busy === false;
c._settingsDialog = null;
return out;
});
checkAll(res);
await finish(browser, res);
+16 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -70,5 +70,18 @@ const res = await page.evaluate(async () => {
out.bigRenderMs = Math.round(performance.now() - t1);
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
// сборка 100+ устройств должна укладываться в бюджет (docs/TESTING.md)
// timings are asserted as budgets, not frozen values (CI machines vary)
check("bigBuildMs under 200ms", res.bigBuildMs < 200);
check("bigRenderMs under 100ms", res.bigRenderMs < 100);
delete res.bigBuildMs;
delete res.bigRenderMs;
checkAll(res, {
"emptyDevices": 0,
"emptyCount": "0 dev.",
"xssPwned": false,
"xssDeviceRendered": 1,
"bigCount": 162,
});
await finish(browser, res);
+42
View File
@@ -0,0 +1,42 @@
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 tabs = () => [...sr().querySelectorAll('.modetab')];
// 1) две вкладки, Просмотра нет, крестиков в неактивных нет
out.twoTabs = tabs().length === 3; // третья — Редактор подложки (v1.33.0)
out.labels = tabs().map((t) => t.textContent.trim());
out.noCrossIdle = sr().querySelectorAll('.modetab .closex').length === 0;
out.startView = c._mode === 'view';
// 2) клик по «Редактор плана» → активна, панель с крестиком
tabs()[0].click(); await c.updateComplete;
out.planActive = c._mode === 'plan' && tabs()[0].classList.contains('active');
out.planBar = !!sr().querySelector('.editbar');
out.planBarClose = !!sr().querySelector('.editbar .barclose');
out.tabCross = !!tabs()[0].querySelector('.closex');
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
// 4) прямое переключение План → Устройства
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
tabs()[1].click(); await c.updateComplete;
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
out.tabCrossWorks = c._mode === 'view';
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"labels": ["Plan editor", "Device editor", "Background editor"],
});
await finish(browser, res);
+35
View File
@@ -0,0 +1,35 @@
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 esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// общие настройки
c._openSettingsDialog(); await c.updateComplete;
await esc(); out.settings = !c._settingsDialog;
// правила иконок
c._openRulesDialog(); await c.updateComplete;
await esc(); out.rules = !c._rulesDialog;
// диалог устройства
c._setMode('devices'); c._openMarkerDialog(); await c.updateComplete;
await esc(); out.marker = !c._markerDialog;
// диалог пространства (+ очистка очереди импорта)
c._openSpaceDialog('edit', c._space); c._importQueue = ['x']; c._importTotal = 1; await c.updateComplete;
await esc(); out.space = !c._spaceDialog && c._importQueue.length === 0;
// инфо-карточка устройства
c._setMode('view'); c._infoCard = c._devices[0]; await c.updateComplete;
await esc(); out.info = !c._infoCard;
// инфо двери/замка
c._openingInfo = { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' }; await c.updateComplete;
await esc(); out.openingInfo = !c._openingInfo;
// приоритет: инфо поверх настроек — Esc закрывает только инфо
c._openSettingsDialog(); c._infoCard = c._devices[0]; await c.updateComplete;
await esc(); out.stacked = !c._infoCard && !!c._settingsDialog;
await esc(); out.stacked2 = !c._settingsDialog;
// Esc в разметке по-прежнему откатывает точку, а не только диалоги
c._setMode('plan'); c._tool = 'draw'; c._path = [[1, 2], [3, 4]]; await c.updateComplete;
await esc(); out.undoPointStillWorks = c._path.length === 1;
return out;
});
checkAll(res);
await finish(browser, res);
+49
View File
@@ -0,0 +1,49 @@
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) кнопка настроек комнаты в редакторе плана: заметная, фиксированного размера
c._setMode('plan'); await c.updateComplete;
const btn = sr().querySelector('.rlgearbtn');
out.gearButtonShown = !!btn;
const cs = btn ? getComputedStyle(btn) : null;
out.gearReadable = cs ? parseFloat(cs.fontSize) >= 10 && cs.pointerEvents === 'auto' : null;
out.gearHasLabel = btn ? btn.textContent.trim().length > 0 : null;
const box = btn?.getBoundingClientRect();
out.gearTapTarget = box ? box.height >= 18 && box.width >= 40 : null;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.gearOpensDialog = c._roomDialog === true && !!c._roomEditId;
c._roomDialogCancel(); await c.updateComplete;
// 2) комната без имени тоже получает кнопку (её там и называют)
const room = c._curSpaceCfg.rooms[0];
const savedName = room.name;
room.name = '';
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.unnamedStillHasGear = sr().querySelectorAll('.rlgearbtn').length >= 1;
room.name = savedName; c._saveConfig(); c.requestUpdate(); await c.updateComplete;
// 3) метрики стали крупнее: 0.75em вместо 0.62em
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
c._setMode('view'); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 150));
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.querySelector('.rlmetrics'));
if (lbl) {
const nameSz = parseFloat(getComputedStyle(lbl.querySelector('.rlname')).fontSize);
const metaSz = parseFloat(getComputedStyle(lbl.querySelector('.rlmetrics')).fontSize);
out.metricsRatio = Math.round((metaSz / nameSz) * 100) / 100;
} else out.metricsRatio = 'no-metrics';
// 4) касание помечает сессию как тач и гасит тултип
c._tip = { x: 1, y: 1, title: 't', meta: 'm' };
c._notePointer(new PointerEvent('pointerdown', { pointerType: 'touch' }));
out.touchClearsTip = c._tip === null;
c._showTip(new MouseEvent('mousemove', { clientX: 5, clientY: 5 }), 'x', 'y');
out.noTipAfterTouch = !c._tip;
return out;
});
check('metricsRatio 0.75', res.metricsRatio, 0.75);
delete res.metricsRatio;
checkAll(res);
await finish(browser, res);
+58
View File
@@ -0,0 +1,58 @@
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 spId = c._space;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, label_temp: true } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 100));
const lbl = () => sr().querySelector('.roomlabel');
const nameSize = () => parseFloat(getComputedStyle(lbl().querySelector('.rlname')).fontSize);
const metaEl = () => lbl().querySelector('.rlmetrics');
const n0 = nameSize();
const m0 = metaEl() ? parseFloat(getComputedStyle(metaEl()).fontSize) : null;
out.baseline = n0 > 0;
// 1) пер-комнатный name_scale ×2 — имя удвоилось, подписи нет
const room = c._curSpaceCfg.rooms.find((r) => r.name && r.area);
room.settings = { ...(room.settings || {}), name_scale: 2 };
c.requestUpdate(); await c.updateComplete;
const lblR = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
const n1 = parseFloat(getComputedStyle(lblR.querySelector('.rlname')).fontSize);
out.nameDoubled = Math.abs(n1 / n0 - 2) < 0.05;
const meta1 = lblR.querySelector('.rlmetrics');
out.metaUntouched = meta1 ? Math.abs(parseFloat(getComputedStyle(meta1).fontSize) - m0) < 0.5 : 'no-meta';
// 2) label_scale ×2 — подписи удвоились
room.settings.label_scale = 2;
c.requestUpdate(); await c.updateComplete;
const meta2 = lblR.querySelector('.rlmetrics');
out.metaDoubled = meta2 && m0 ? Math.abs(parseFloat(getComputedStyle(meta2).fontSize) / m0 - 2) < 0.05 : 'no-meta';
// 3) базовый множитель пространства ×1.5 — умножает всё
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), card_font_scale: 1.5 } })) };
c.requestUpdate(); await c.updateComplete;
const lblR2 = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
const n2 = parseFloat(getComputedStyle(lblR2.querySelector('.rlname')).fontSize);
out.spaceMultiplies = Math.abs(n2 / n0 - 3) < 0.1; // 2 × 1.5
// 4) диалог комнаты: слайдеры + живой пример реагирует
c._setMode('plan'); await c.updateComplete;
const model = c._spaceModel().rooms.find((r) => r.id === room.id);
c._openRoomEdit(model); await c.updateComplete;
out.slidersPrefilled = c._roomNameScale === 2 && c._roomLabelScale === 2;
const pv = () => sr().querySelector('.cardpreview .cpname');
const p0 = parseFloat(pv().style.fontSize);
c._roomNameScale = 1; c.requestUpdate(); await c.updateComplete;
const p1 = parseFloat(pv().style.fontSize);
out.previewLive = Math.abs(p0 / p1 - 2) < 0.05;
c._roomDialogCancel(); await c.updateComplete;
// 5) превью есть и в диалоге пространства
c._openSpaceDialog('edit', spId); await c.updateComplete;
out.spacePreview = !!sr().querySelector('.cardpreview');
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
c._spaceDialog = null; await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+38
View File
@@ -0,0 +1,38 @@
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 gears = () => sr().querySelectorAll('.tab .tabedit').length;
// шестерёнки видны во всех трёх режимах
out.viewGears = gears() > 0;
c._setMode('plan'); await c.updateComplete;
out.planGears = gears() > 0;
c._setMode('devices'); await c.updateComplete;
out.devGears = gears() > 0;
c._setMode('view'); await c.updateComplete;
// выравнивание: центр иконки ~ центру текста таба
const tab = sr().querySelector('.tab');
const icon = tab.querySelector('.tabedit');
const tb = tab.getBoundingClientRect(), ib = icon.getBoundingClientRect();
const tabMid = tb.top + tb.height / 2, iconMid = ib.top + ib.height / 2;
out.alignDelta = Math.abs(tabMid - iconMid);
out.aligned = out.alignDelta <= 1.5;
// клик по шестерёнке в Просмотре открывает диалог пространства и не переключает таб
const cur = c._space;
icon.click(); await c.updateComplete;
out.dialogOpens = !!c._spaceDialog;
out.tabNotSwitched = c._space === cur;
c._spaceDialog = null; await c.updateComplete;
// кнопка "+" по-прежнему только в Плане
out.noAddInView = !sr().querySelector('.tab.tabadd');
c._setMode('plan'); await c.updateComplete;
out.addInPlan = !!sr().querySelector('.tab.tabadd');
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"alignDelta": 0,
});
await finish(browser, res);
+37
View File
@@ -0,0 +1,37 @@
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;
// 1) диалог общих настроек: 7 цветовых строк, 3 группы
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
out.saved = c._serverCfg.settings.fill_colors?.light_on;
// 3) заливка light использует кастомный цвет
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_borders: true, fill_mode: 'light' } })) };
c.requestUpdate(); await c.updateComplete;
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style') || '');
out.customFillUsed = styles.some((st) => st.includes('#ff00ff') && st.includes('0.500'));
// 4) show_lqi=false у пространства скрывает LQI-бейджи
out.lqiBefore = sr().querySelectorAll('.dev .lqi').length;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_lqi: false } })) };
c.requestUpdate(); await c.updateComplete;
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 11,
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
});
await finish(browser, res);
+124
View File
@@ -0,0 +1,124 @@
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 spId = c._space;
// включить glow-режим
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
// 1) все комнаты залиты темнотой одинаково (независимо от ламп)
const rooms = [...sr().querySelectorAll('.room.styled')];
out.roomsDark = rooms.length > 0 && rooms.every((el) => {
const st = el.getAttribute('style') || '';
return st.includes('--room-fill:#0d1b2a');
});
// 2) пятна от включённых ламп
const litLight = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
out.hasLitLight = !!litLight;
out.spots = sr().querySelectorAll('.glowlayer circle').length;
out.spotsMatchLights = out.spots === c._devices.filter((d) => d.space === spId && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on')).length;
// 3) выключение лампы убирает её пятно
const eid = litLight.entities.find((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on');
const st0 = c.hass.states[eid];
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'off' } } };
await c.updateComplete;
out.spotGone = sr().querySelectorAll('.glowlayer circle').length === out.spots - 1;
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
// 4) rgb-лампа красит градиент своим цветом (форсируем rgb у лампы)
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: { ...st0, state: 'on', attributes: { ...st0.attributes, rgb_color: [255, 0, 0] } } } };
await c.updateComplete;
out.gradientColored = [...sr().querySelectorAll('defs radialGradient stop')].some((s2) => s2.getAttribute('stop-color') === 'rgb(255, 0, 0)');
c.hass = { ...c.hass, states: { ...c.hass.states, [eid]: st0 } }; await c.updateComplete;
// 5) пятно обрезано комнатой (есть clipPath)
out.clipped = sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]').length > 0;
// 6) дверь в соседнюю комнату → в clip добавлен сектор (2 сабпути M)
// добавим дверь между r1 и r2 на общей стене x=0.55… найдём общую стену программно:
const spm = c._spaceModel();
const r1 = spm.rooms.find((r) => r.id === 'r1');
const r2 = spm.rooms.find((r) => r.id === 'r2');
// возьмём точку на границе r1, ближайшую к центру r2
const c2 = c._roomCenter(r2);
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
// общая стена вертикальная — дверь ставим на неё
const H = 1000 / (c._curSpaceCfg.aspect || 1);
const doorPt = (() => {
let best = null, bd = 1e9;
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
const d2 = Math.hypot(x - c2[0], y - c2[1]);
if (d2 < bd) { bd = d2; best = [x, y]; }
}
return best;
})();
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete;
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
const aspect = c._curSpaceCfg.aspect || 1;
const c1 = c._roomCenter(r1);
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
c.requestUpdate(); await c.updateComplete;
// теперь каждый под-контур — отдельный path внутри clipPath
const clipEls = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
out.sectorAdded = clipEls.some((cp) => cp.querySelectorAll('path').length >= 2);
// и сектор в том же направлении обхода не выедает комнату: каждый path — один контур
out.singleSubpathPerPath = clipEls.every((cp) => [...cp.querySelectorAll('path')]
.every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1));
// у входной двери (наружу) сектора нет: дверь на внешней стене r1 (x=min)
const minX = Math.min(...poly1.map((p) => p[0]));
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete;
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
// 6б) профиль градиента: плато до 80%, спад на внешних 20%
const stops = [...sr().querySelectorAll('defs radialGradient')][0];
const offs = stops ? [...stops.querySelectorAll('stop')].map((st2) => [st2.getAttribute('offset'), st2.getAttribute('stop-opacity')]) : [];
out.plateau80 = offs.length === 3 && offs[0][0] === '0%' && offs[1][0] === '70%'
&& offs[0][1] === offs[1][1] && offs[2][1] === '0';
// 7а) персональный радиус источника перекрывает глобальный
const litMarkerId = litLight.id;
c._serverCfg = { ...c._serverCfg, markers: [
...(c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId),
{ id: litMarkerId, binding: litLight.bindingKind === 'virtual' ? 'virtual' : litLight.bindingKind + ':' + litLight.bindingRef, glow_radius_cm: 150 },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const rOwn = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
if (swDev) {
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
c._serverCfg = { ...c._serverCfg, markers: [
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
c.requestUpdate(); await c.updateComplete;
const r300 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
out.radiusReacts = Math.abs(r600 / r300 - 2) < 0.01;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"spots": 1,
});
await finish(browser, res);
+37
View File
@@ -0,0 +1,37 @@
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 grid = () => !!sr().querySelector('rect[fill="url(#hp-grid)"]');
// Просмотр: сетки нет
out.noGridInView = !grid();
// все три редактора: сетка есть
c._setMode('plan'); await c.updateComplete; out.gridInPlan = grid();
c._setMode('devices'); await c.updateComplete; out.gridInDevices = grid();
c._setMode('decor'); await c.updateComplete; out.gridInDecor = grid();
await new Promise((r) => setTimeout(r, 250)); // transition .room 0.12s
// decor: комнаты и устройства полупрозрачны
const room = sr().querySelector('.room');
out.roomFaded = room ? Number(getComputedStyle(room).opacity) < 0.5 : null;
const dl = sr().querySelector('.devlayer');
out.devsFaded = dl ? Number(getComputedStyle(dl).opacity) < 0.5 : null;
// а декор-слой — нет
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'line', x1: 0.1, y1: 0.1, x2: 0.3, y2: 0.1, color: '#ff0000', width: 3 }];
c.requestUpdate(); await c.updateComplete;
const shp = sr().querySelector('.decorlayer .dshape');
out.decorNotFaded = shp ? Number(getComputedStyle(shp).opacity) > 0.9 : null;
// в других редакторах прозрачности нет
c._setMode('devices'); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const room2 = sr().querySelector('.room');
out.notFadedInDevices = room2 ? Number(getComputedStyle(room2).opacity) > 0.9 : null;
c._setMode('view'); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const room3 = sr().querySelector('.room');
out.notFadedInView = room3 ? Number(getComputedStyle(room3).opacity) > 0.9 : null;
return out;
});
checkAll(res);
await finish(browser, res);
+18
View File
@@ -0,0 +1,18 @@
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 gs = () => !!sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]');
out.view = gs();
c._setMode('plan'); await c.updateComplete; out.plan = gs();
c._setMode('devices'); await c.updateComplete; out.devices = gs();
c._setMode('view'); await c.updateComplete;
sr().querySelector('.head .btn[title="' + c._t('title.general_settings') + '"]').click();
await c.updateComplete;
out.opensInView = !!c._settingsDialog;
return out;
});
checkAll(res);
await finish(browser, res);
+31
View File
@@ -0,0 +1,31 @@
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;
c._setMode('devices'); await c.updateComplete;
// устройство без явной иконки (marker.icon пуст)
const dev = c._devices.find((d) => !d.marker?.icon && d.icon);
out.hasAutoDev = !!dev;
c._openMarkerDialog(dev); await c.updateComplete;
out.autoIconStored = c._markerDialog.autoIcon === dev.icon;
out.iconEmpty = c._markerDialog.icon === '';
// подсказка с авто-иконкой видна
const hint = sr().querySelector('.iconauto');
out.hintShown = !!hint && hint.textContent.includes(dev.icon);
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
// пикер (или фолбэк-инпут) получил placeholder
const picker = sr().querySelector('ha-icon-picker');
const inputs = [...sr().querySelectorAll('.dialog input')];
out.placeholderSet = picker
? picker.placeholder === dev.icon
: inputs.some((i) => i.placeholder === dev.icon);
// при явной иконке подсказки нет
c._markerDialog = { ...c._markerDialog, icon: 'mdi:star' }; await c.updateComplete;
out.noHintWhenExplicit = !sr().querySelector('.iconauto');
c._markerDialog = null; await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+52
View File
@@ -0,0 +1,52 @@
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;
// добавить открывание на f1
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90 }] })) };
c.requestUpdate(); await c.updateComplete;
const hit = sr().querySelector('.op-hit');
out.hasOpening = !!hit;
const cs = hit ? getComputedStyle(hit) : null;
out.viewInert = cs ? cs.pointerEvents === 'none' && cs.cursor === 'default' : null;
out.lockBadgeInert = (() => { const l = sr().querySelector('.oplock'); return l ? getComputedStyle(l).pointerEvents === 'none' : 'no-badge'; })();
// View: клик по открыванию ничего не открывает
c._opClick({ stopPropagation(){} }, { id: 'op1', rx: 550, ry: 200, rlen: 90 });
out.viewClickNoop = !c._openingInfo && !c._openingDialog;
// курсор устройств в Просмотре — pointer, в Устройствах — grab
const dev = sr().querySelector('.dev');
out.viewDevCursor = getComputedStyle(dev).cursor;
// бейдж замка: замок привязан → бейдж кликабелен и открывает инфо
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s2) => s2.id !== 'f1' ? s2 : ({
...s2, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
c.requestUpdate(); await c.updateComplete;
const badge = sr().querySelector('.oplock');
out.badgeShown = !!badge;
const bcs = badge ? getComputedStyle(badge) : null;
out.badgeClickableInView = bcs ? bcs.pointerEvents === 'auto' && bcs.cursor === 'pointer' : null;
badge?.click(); await c.updateComplete;
out.badgeOpensInfo = !!c._openingInfo;
c._openingInfo = null;
// Plan: op-hit интерактивен, клик редактирует
c._setMode('devices'); await c.updateComplete;
out.devModeCursor = getComputedStyle(sr().querySelector('.dev')).cursor;
c._setMode('plan'); await c.updateComplete;
out.badgeInertInPlan = (() => { const b = sr().querySelector('.oplock'); return b ? getComputedStyle(b).pointerEvents === 'none' : 'no-badge'; })();
const cs2 = getComputedStyle(sr().querySelector('.op-hit'));
out.planInteractive = cs2.pointerEvents === 'auto' && cs2.cursor === 'grab';
const op = c._spaceModel().openings?.[0] || { id: 'op1', rx: 550, ry: 200, rlen: 90 };
c._opClick({ stopPropagation(){} }, op);
out.planClickEdits = !!c._openingDialog;
c._openingDialog = null; c._setMode('view');
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"lockBadgeInert": "no-badge",
"viewDevCursor": "pointer",
"devModeCursor": "grab",
});
await finish(browser, res);
+59
View File
@@ -0,0 +1,59 @@
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;
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
const g = c._gridPitch;
// r1 — комната; рисуем остров-«колонну» внутри неё
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
const [cx, cy] = c._roomCenter(r1).map((v) => Math.round(v / g) * g);
const pts = [[cx, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx, cy + g * 2]];
// 1) клики внутри комнаты больше не отклоняются
for (const p of pts) c._markupClick({ clientX: 0, clientY: 0, composedPath: () => [], ...{} , __pt: p });
// _markupClick берёт координаты из _svgPoint(ev) — подменим напрямую через _path:
c._path = [];
for (const p of pts) {
const before = c._path.length;
// эмулируем клик готовой точкой: повторим логику через прямой вызов невозможен — построим путь вручную и замкнём
c._path = [...c._path, p];
if (c._path.length === before) break;
}
out.pointsAccepted = c._path.length === 4;
// замыкание: проверка пересечений должна пройти (вложенность легальна)
const clash = c._overlapRoom([...c._path]);
out.nestedAllowed = !clash;
// частичное перекрытие всё ещё запрещено
const partial = [[cx - g * 4, cy], [cx + g * 2, cy], [cx + g * 2, cy + g * 2], [cx - g * 4, cy + g * 2]];
// сдвинем так, чтобы вылезло за границу r1: возьмём точку заведомо снаружи
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
const minX = Math.min(...poly1.map((p) => p[0]));
const cross = [[minX - g * 2, cy], [cx, cy], [cx, cy + g * 2], [minX - g * 2, cy + g * 2]];
out.partialStillRejected = !!c._overlapRoom(cross);
// 2) сохранить остров как комнату без зоны (замкнуть контур)
c._path = [...pts, pts[0]];
c._nameSel = 'Колонна'; c._areaSel = '';
c._commitRoom();
await c.updateComplete;
const island = c._spaceModel().rooms.find((r) => r.name === 'Колонна');
out.islandSaved = !!island;
// 3) внешняя комната рендерится как path с evenodd и дыркой
c._setMode('view'); await c.updateComplete;
// включим границы, чтобы r1 рендерился
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_borders: true } })) };
c.requestUpdate(); await c.updateComplete;
const evenodd = [...sr().querySelectorAll('path.room')].find((p) => p.getAttribute('fill-rule') === 'evenodd');
out.evenoddPath = !!evenodd;
out.holeInPath = evenodd ? (evenodd.getAttribute('d').match(/M /g) || []).length === 2 : null;
// 4) остров кликабелен (элемент существует и он поверх дырки)
const islandEl = [...sr().querySelectorAll('.room')].find((el) => {
const d = el.getAttribute('points') || el.getAttribute('d') || '';
return d.includes(String(cx)) && el !== evenodd;
});
out.islandRendered = !!islandEl;
return out;
});
checkAll(res);
await finish(browser, res);
+73
View File
@@ -0,0 +1,73 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c0 = window.__card;
// создать киоск-экземпляр
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = c0.hass;
document.body.appendChild(c);
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
await new Promise((r) => setTimeout(r, 400));
c.hass = { ...c0.hass };
await c.updateComplete;
const sr = () => c.shadowRoot || c.renderRoot;
// 1) шапка скрыта
const hdr = sr().querySelector('.hdr');
out.headerHidden = !hdr || getComputedStyle(hdr).display === 'none';
// 2) редакторы заблокированы
c._setMode('plan'); await c.updateComplete;
out.editorsBlocked = c._mode === 'view';
// 3) свайп при 1:1 переключает пространство (и точки показываются)
const s0 = c._space;
const stage = sr().querySelector('.stage');
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.swipeSwitches = c._space !== s0;
out.dotsShown = !!sr().querySelector('.kioskdots');
// 4) при зуме свайп не переключает
c._zoom = 2; const s1 = c._space;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 2, clientX: 600, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 2, clientX: 450, clientY: 305 }));
await c.updateComplete;
out.noSwipeZoomed = c._space === s1;
// 5) двойной тап сбрасывает зум
c._lastTap = Date.now() - 100;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
await c.updateComplete;
out.dblTapResets = c._zoom === 1;
// 6) локальный множитель иконок применяется
c._saveKioskScale({ icon: 2 }); await c.updateComplete;
const dl = sr().querySelector('.devlayer');
const size1 = dl.getAttribute('style');
c._saveKioskScale({ icon: 1 }); await c.updateComplete;
const size2 = sr().querySelector('.devlayer').getAttribute('style');
const v1 = parseFloat(size1.match(/--icon-size:([\d.]+)/)[1]);
const v2 = parseFloat(size2.match(/--icon-size:([\d.]+)/)[1]);
out.iconScaleWorks = Math.abs(v1 / v2 - 2) < 0.01;
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
c._kioskDialog = true; await c.updateComplete;
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
c._kioskDialog = false;
// 8) карусель: тик двигает пространство, пауза после касания работает
const cs = c._space;
c._config = { ...c._config, cycle: 1 };
c._cyclePausedUntil = 0;
c._cycleTick(); await c.updateComplete;
out.cycleAdvances = c._space !== cs;
c._cyclePausedUntil = Date.now() + 60000;
const cs2 = c._space;
c._cycleTick(); await c.updateComplete;
out.cyclePaused = c._space === cs2;
// 9) обычная карточка (не киоск): шапка на месте, свайпа нет
const sr0 = c0.shadowRoot || c0.renderRoot;
out.normalHeader = !!sr0.querySelector('.hdr') && getComputedStyle(sr0.querySelector('.hdr')).display !== 'none';
c.remove();
return out;
});
checkAll(res);
await finish(browser, res);
+34
View File
@@ -0,0 +1,34 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
// лампа (primary light) без явного действия → клик = toggle
const lamp = c._devices.find((d) => d.primary?.startsWith('light.') && !d.tapAction);
out.hasLamp = !!lamp;
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), lamp);
out.lampToggles = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'toggle', lamp.primary]);
// устройство с не-light primary (сенсор) → клик = инфо
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
const n = calls.length;
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), sensorDev);
out.sensorInfo = calls.length === n && !!c._infoCard;
c._infoCard = null;
// явный info у лампы отключает дефолт
const cfgm = { id: lamp.id, binding: lamp.bindingKind + ':' + lamp.bindingRef, tap_action: 'info' };
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []).filter((m) => m.id !== lamp.id), cfgm] };
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const lamp2 = c._devices.find((d) => d.id === lamp.id);
const n2 = calls.length;
c._clickDevice(new MouseEvent('click'), lamp2);
out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
return out;
});
checkAll(res);
await finish(browser, res);
+52
View File
@@ -0,0 +1,52 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
window.confirm = () => true; // review CR-1: unlocking now confirms
c.hass = { ...c.hass, callService: (d, s, data) => calls.push([d, s, data.entity_id]) };
await c.updateComplete;
// добавить дверь с замком на f1
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
...s, openings: [{ id: 'op1', type: 'door', rx: 550, ry: 200, angle: 90, len_cm: 90, lock: 'lock.front_door' }] })) };
c.requestUpdate(); await c.updateComplete;
const op = c._spaceModel()?.openings?.[0] || { id: 'op1', type: 'door', rx: 550, ry: 200, len_cm: 90, lock: 'lock.front_door' };
out.hasLockedDoor = !!op.lock;
const lockId = op.lock;
// 1) замок закрыт → кнопка "Unlock"
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'locked', attributes: {} } } };
c._openingInfo = op; await c.updateComplete;
let btn = sr().querySelector('.btn.lockact');
out.unlockBtnShown = !!btn && !btn.disabled;
out.unlockBtnDanger = btn?.classList.contains('warn');
btn.click();
out.unlockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'unlock', lockId]);
// 2) замок открыт → кнопка "Lock"
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocked', attributes: {} } } };
await c.updateComplete;
btn = sr().querySelector('.btn.lockact');
btn.click();
out.lockCalled = JSON.stringify(calls.at(-1)) === JSON.stringify(['lock', 'lock', lockId]);
// 3) переходное состояние → кнопка задизейблена, сервис не зовётся
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unlocking', attributes: {} } } };
await c.updateComplete;
btn = sr().querySelector('.btn.lockact');
out.pendingDisabled = !!btn && btn.disabled;
// 4) недоступен → кнопки нет
c.hass = { ...c.hass, states: { ...c.hass.states, [lockId]: { state: 'unavailable', attributes: {} } } };
await c.updateComplete;
out.noBtnWhenUnavailable = !sr().querySelector('.btn.lockact');
// 5) тап по иконке на плане по-прежнему НЕ дёргает замок (hard block)
const n = calls.length;
const dev = c._devices.find((d) => d.domains?.includes('lock'));
out.planTapStillBlocked = true;
if (dev) {
c._clickDevice(new MouseEvent('click'), dev); await c.updateComplete;
out.planTapStillBlocked = !calls.slice(n).some((x) => x[0] === 'lock' && (x[1] === 'lock' || x[1] === 'unlock'));
}
return out;
});
checkAll(res);
await finish(browser, res);
+48
View File
@@ -0,0 +1,48 @@
// review CR-1: exercise EVERY actuation path and prove locks/alarms are safe
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push(`${d}.${s}:${data.entity_id}`); return Promise.resolve(); },
states: { ...c.hass.states,
'lock.front_door': { state: 'locked', attributes: { friendly_name: 'Front door' } },
'alarm_control_panel.home': { state: 'armed_away', attributes: {} } } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
const lockCalls = () => calls.filter((x) => x.includes('lock.') || x.includes('alarm_control_panel.'));
// 1) тап по значку устройства с замком
const lockDev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('lock.'))) || c._devices[0];
const fake = { ...lockDev, primary: 'lock.front_door', tapAction: 'toggle',
marker: { ...(lockDev.marker || {}), tap_action: 'toggle' } };
c._clickDevice(new MouseEvent('click'), fake);
out.iconTapSafe = lockCalls().length === 0;
// 2) controls[] с замком внутри
const withControls = { ...fake, tapAction: 'toggle',
marker: { controls: ['lock.front_door', 'alarm_control_panel.home'], tap_action: 'toggle' } };
c._clickDevice(new MouseEvent('click'), withControls);
out.controlsSafe = lockCalls().length === 0;
// 3) карточка устройства: замок отдаётся в more-info, а не тумблером
const kinds = c._cardEntities({ ...fake, entities: ['lock.front_door', 'alarm_control_panel.home'] });
out.cardNoToggleForLocks = kinds.every((k) => k.kind !== 'toggle');
c._cardToggle('lock.front_door');
c._cardToggle('alarm_control_panel.home');
out.cardToggleRefuses = lockCalls().length === 0;
// 4) кнопка в карточке двери — единственная разрешённая поверхность, и спрашивает подтверждение
let asked = null;
window.confirm = (msg) => { asked = msg; return false; };
c._lockAction('lock.front_door', 'unlock');
out.unlockAsksConfirm = asked !== null && lockCalls().length === 0;
window.confirm = () => true;
c._lockAction('lock.front_door', 'unlock');
out.unlockAfterConfirm = calls.at(-1) === 'lock.unlock:lock.front_door';
// запирание не спрашивает
asked = null;
window.confirm = (m) => { asked = m; return true; };
c._lockAction('lock.front_door', 'lock');
out.lockNoConfirm = asked === null && calls.at(-1) === 'lock.lock:lock.front_door';
return out;
});
checkAll(res);
await finish(browser, res);
+46
View File
@@ -0,0 +1,46 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
c._setMode('devices'); await c.updateComplete;
const near = (a, b) => a && b && Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6 && a.s === b.s;
// подопытный: авто-устройство с сохранённой позицией
const dev = c._devices.find((d) => !d.virtual && !d.marker && c._layout[d.id]);
out.hasDev = !!dev;
const pos0 = { ...c._layout[dev.id] };
// 1) смена комнаты (та же space) → позиция не меняется
const otherRoom = c._spaceModel(dev.space).rooms.find((r) => r.id && r.area && r.area !== dev.area);
out.hasOtherRoom = !!otherRoom;
c._openMarkerDialog(dev);
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + otherRoom.area };
await c._saveMarker(); await c.updateComplete;
out.stayAfterRoomChange = near(c._layout[dev.id], pos0);
// вернуть комнату назад
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
c._markerDialog = { ...c._markerDialog, room: dev.space + '#' + dev.area };
await c._saveMarker(); await c.updateComplete;
// 2) смена привязки device -> entity (id меняется) → позиция мигрирует
const freeEnt = Object.keys(c.hass.states).find((e) => e.startsWith('sensor.') &&
!c._devices.some((d) => d.id === 'lg_' + e));
out.hasFreeEnt = !!freeEnt;
c._openMarkerDialog(c._devices.find((d) => d.id === dev.id));
c._markerDialog = { ...c._markerDialog, binding: 'entity:' + freeEnt };
await c._saveMarker(); await c.updateComplete;
const newId = 'lg_' + freeEnt;
out.migrated = near(c._layout[newId], pos0);
out.oldGone = !c._layout[dev.id];
// 3) новый виртуальный маркер по-прежнему центрируется в выбранной комнате
c._openMarkerDialog();
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual', room: c._space + '#' + room.area };
await c._saveMarker(); await c.updateComplete;
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
const center = c._roomCenter(room);
const vpos = c._layout[vid];
const aspect = c._curSpaceCfg.aspect || 1;
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
return out;
});
checkAll(res);
await finish(browser, res);
+24
View File
@@ -0,0 +1,24 @@
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;
c._setMode('plan'); c._tool = 'merge'; await c.updateComplete;
const room = c._spaceModel().rooms.find((r) => r.name);
c._mergeSel = room.id; c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250)); // дождаться transition 0.12s
const el = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
out.pickedRendered = !!el;
const cs = el ? getComputedStyle(el) : null;
out.amberStroke = cs ? cs.stroke.includes('255, 193, 77') : null;
out.amberFill = cs ? cs.fill.includes('255, 193, 77') : null;
// split-выбор подсвечивается так же
c._mergeSel = null; c._tool = 'split'; c._splitSel = { roomId: room.id, pts: [] }; c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const el2 = [...sr().querySelectorAll('.room')].find((e) => e.classList.contains('picked'));
out.splitPicked = !!el2 && getComputedStyle(el2).stroke.includes('255, 193, 77');
return out;
});
checkAll(res);
await finish(browser, res);
+10 -6
View File
@@ -1,5 +1,5 @@
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
import { launch } from './serve.mjs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => {
@@ -16,7 +16,7 @@ const S = () => page.evaluate(() => {
return { rooms: c._serverCfg.spaces.find((s) => s.id==='f1').rooms.map((r)=>({id:r.id,name:r.name,area:r.area})),
mergeDlg: !!c._mergeDialog, roomDlg: !!c._roomDialog, pendingSplit: !!c._pendingSplit, toast: c._toast };
});
const enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool=t; return true;}, t);
const enter = (t) => page.evaluate((t)=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool=t; return true;}, t);
const out = {};
// MERGE living(r1)+kitchen(r2) — share wall x=0.55, y∈[0.05..0.45]
@@ -56,14 +56,14 @@ out.splitPending = (await S()).pendingSplit;
out.splitDialog = (await S()).roomDlg;
// cancel keeps it whole
await page.evaluate(()=>window.__card._roomDialogCancel());
out.cancelWhole = (await S()).rooms.length===4;
out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
// redo + confirm with a name (no area)
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
s = await S();
out.splitRooms = s.rooms.length; // 5
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
out.newRoom = s.rooms.find(r=>!['r1','r2','r3','r4','rg'].includes(r.id))?.name;
await restore();
@@ -79,5 +79,9 @@ await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
s = await S();
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
console.log(JSON.stringify(out,null,1));
await browser.close();
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(out, {
"mergeRooms": 3,
"newRoom": "Cabinet",
});
await finish(browser, out);
+86
View File
@@ -0,0 +1,86 @@
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const q = (sel) => page.evaluate((s) => (window.__card.shadowRoot || window.__card.renderRoot).querySelectorAll(s).length, sel);
const st = () => page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
return {
mode: c._mode,
modeTabs: sr.querySelectorAll('.modetab').length,
active: sr.querySelector('.modetab.active')?.textContent.trim(),
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
stageClass: sr.querySelector('.stage').className,
};
});
// 1) старт: view, чистая шапка
out.start = await st();
// 2) drag в view ничего не двигает и не мешает пану
await page.evaluate(() => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
const before = { ...c._pos(d) };
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 1 }, d);
c._pointerMove({ clientX: 90, clientY: 60 }, d);
c._pointerUp({}, d);
const after = { ...c._pos(d) };
window.__viewDragMoved = Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
clearTimeout(c._holdTimer);
});
out.viewDragMoved = await page.evaluate(() => window.__viewDragMoved);
// long-press в view открывает инфо
await page.evaluate(async () => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 2 }, d);
await new Promise((r) => setTimeout(r, 700));
});
out.viewHoldInfo = await page.evaluate(() => { const r = !!window.__card._infoCard; window.__card._infoCard = null; window.__card._holdFired = false; return r; });
// 3) режим План
await page.evaluate(() => window.__card._setMode('plan'));
await page.waitForTimeout(200);
out.plan = await st();
out.planIconsHidden = await page.evaluate(() => {
const sr = window.__card.shadowRoot || window.__card.renderRoot;
const dev = sr.querySelector('.dev');
return dev ? getComputedStyle(dev).display === 'none' : 'no-dev';
});
// 4) режим Устройства: drag работает, клик открывает редактор
await page.evaluate(() => window.__card._setMode('devices'));
await page.waitForTimeout(200);
out.devices = await st();
out.devDragWorks = await page.evaluate(() => {
const c = window.__card;
const d = c._devices.find((x) => x.space === 'f1');
const before = { ...c._pos(d) };
c._pointerDown({ preventDefault(){}, clientX: 10, clientY: 10, target: { setPointerCapture(){} }, pointerId: 3 }, d);
c._pointerMove({ clientX: 100, clientY: 70 }, d);
c._pointerUp({}, d);
const after = { ...c._pos(d) };
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y) > 0.5;
});
out.devClickOpensEditor = await page.evaluate(async () => {
const c = window.__card;
c._drag = null;
const d = c._devices.find((x) => x.space === 'f1');
c._clickDevice({ stopPropagation(){} }, d);
await c.updateComplete;
const r = !!c._markerDialog;
c._markerDialog = null;
return r;
});
// 5) назад в view
await page.evaluate(() => window.__card._setMode('view'));
out.backToView = (await st()).mode;
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(out, {
"start": {"mode": "view", "modeTabs": 3, "editBtns": 1, "gears": 2, "markupBar": false, "stageClass": "stage mode-view"},
"viewDragMoved": false,
"plan": {"mode": "plan", "modeTabs": 3, "active": "Plan editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage markup tool-draw mode-plan"},
"devices": {"mode": "devices", "modeTabs": 3, "active": "Device editor", "editBtns": 1, "gears": 2, "markupBar": true, "stageClass": "stage mode-devices"},
"backToView": "view",
});
await finish(browser, out);
+35
View File
@@ -0,0 +1,35 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// выбрать f2 и редактор устройств
c._space = 'garden'; c._setMode('devices'); await c.updateComplete;
const nav = JSON.parse(localStorage.getItem('houseplan_card_nav_v1'));
out.saved = nav.space === 'garden' && nav.mode === 'devices';
// пересоздать карточку (эмуляция закрытия вкладки; кэш конфига в LS уже есть)
const c2 = document.createElement('houseplan-card');
c2.setConfig({ type: 'custom:houseplan-card' });
c2.hass = c.hass;
document.body.appendChild(c2);
await new Promise((r) => setTimeout(r, 300));
c2.hass = { ...c.hass };
await c2.updateComplete;
out.spaceRestored = c2._space === 'garden';
out.modeRestored = c2._mode === 'devices';
// хэш приоритетнее сохранённого
location.hash = '#space=f1';
const c3 = document.createElement('houseplan-card');
c3.setConfig({ type: 'custom:houseplan-card' });
c3.hass = c.hass;
document.body.appendChild(c3);
await new Promise((r) => setTimeout(r, 300));
out.hashWins = c3._space === 'f1';
location.hash = '';
c2.remove(); c3.remove();
// вернуть исходное
c._setMode('view'); c._space = 'f1'; c._saveNav(); await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+32
View File
@@ -0,0 +1,32 @@
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;
// 1) первый запуск: базлайн засеян молча, точек нет
out.baselineSeeded = Array.isArray(c._serverCfg.settings.known_devices) && c._serverCfg.settings.known_devices.length > 0;
out.noDotsOnFirstRun = sr().querySelectorAll('.newdot').length === 0;
// 2) в HA появилось новое устройство
const h = { ...c.hass };
h.devices = { ...h.devices, d_new: { id: 'd_new', name: 'Brand new plug', model: 'Smart Plug', area_id: 'kitchen', identifiers: [['demo', 'd_new']] } };
h.entities = { ...h.entities, 'switch.newplug': { entity_id: 'switch.newplug', device_id: 'd_new', platform: 'demo' } };
h.states = { ...h.states, 'switch.newplug': { state: 'off', attributes: { friendly_name: 'Brand new plug' } } };
c.hass = h; await c.updateComplete; c.requestUpdate(); await c.updateComplete;
out.flagged = c._serverCfg.settings.new_device_ids?.includes('d_new');
out.dotShown = sr().querySelectorAll('.newdot').length === 1;
// 3) существующие не флагуются
out.onlyOne = (c._serverCfg.settings.new_device_ids || []).length === 1;
// 4) открытие редактора снимает флаг
const dev = c._devices.find((d) => d.id === 'd_new');
c._setMode('devices');
c._openMarkerDialog(dev); await c.updateComplete;
c._markerDialog = null; c._setMode('view'); await c.updateComplete;
out.ackCleared = !(c._serverCfg.settings.new_device_ids || []).includes('d_new');
out.dotGone = sr().querySelectorAll('.newdot').length === 0;
// 5) known растёт, повторно не флагуется
out.stillKnown = c._serverCfg.settings.known_devices.includes('d_new');
return out;
});
checkAll(res);
await finish(browser, res);
+43
View File
@@ -0,0 +1,43 @@
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;
c._setMode('plan'); c._tool = 'opening'; await c.updateComplete;
const rooms = c._spaceModel().rooms;
const r = rooms.find((x) => x.id === 'r1');
const poly = r.poly || [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
const wallMid = [(poly[0][0] + poly[1][0]) / 2, (poly[0][1] + poly[1][1]) / 2];
// 1) курсор возле стены → пунктирный призрак есть
c._cursorPt = [wallMid[0], wallMid[1] + c._gridPitch * 0.5]; c.requestUpdate(); await c.updateComplete;
const ghost = sr().querySelector('.opghost');
out.ghostNearWall = !!ghost;
if (ghost) {
const cs = getComputedStyle(ghost);
out.dashed = cs.strokeDasharray !== 'none' && cs.strokeDasharray !== '';
// призрак лежит на стене (y координаты концов совпадают с y стены)
out.onWall = Math.abs(Number(ghost.getAttribute('y1')) - poly[0][1]) < 0.5
&& Math.abs(Number(ghost.getAttribute('y2')) - poly[0][1]) < 0.5;
// длина = 90 см в рендер-единицах
const len = Math.hypot(ghost.getAttribute('x2') - ghost.getAttribute('x1'), ghost.getAttribute('y2') - ghost.getAttribute('y1'));
out.len90cm = Math.abs(len - c._cmToUnits(90)) < 0.5;
}
// 2) курсор далеко от стен → призрака нет
const center = c._roomCenter(r);
c._cursorPt = center; c.requestUpdate(); await c.updateComplete;
out.noGhostFarFromWall = !sr().querySelector('.opghost');
// 3) в других инструментах призрака нет
c._tool = 'draw'; c._cursorPt = [wallMid[0], wallMid[1] + 2]; c.requestUpdate(); await c.updateComplete;
out.noGhostOtherTool = !sr().querySelector('.opghost');
// 4) над существующим проёмом призрак не рисуется (там клик = редактирование)
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, openings: [{ id: 'op9', type: 'door', x: wallMid[0] / 1000, y: wallMid[1] / (1000 / (c._curSpaceCfg.aspect || 1)) * (c._curSpaceCfg.aspect ? 1 : 1), angle: 0, length: 0.09 }] })) };
c._tool = 'opening'; c.requestUpdate(); await c.updateComplete;
const op = c._openingsR[0];
c._cursorPt = [op.rx, op.ry]; c.requestUpdate(); await c.updateComplete;
out.noGhostOverExisting = !sr().querySelector('.opghost');
return out;
});
checkAll(res);
await finish(browser, res);
+74
View File
@@ -0,0 +1,74 @@
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;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
const H = 1000 / (c._curSpaceCfg.aspect || 1);
c._openWallClick([550, 0.25 * H]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
// пунктир отрисован
out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot');
// в Просмотре: сплошной штрих комнаты снят, контур без выреза + пунктир ПОВЕРХ glow
c._setMode('view'); await c.updateComplete;
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.trimmedOutline = sr().querySelectorAll('.room-outline').length >= 2;
const svgEl = sr().querySelector('svg');
const order = [...svgEl.children].map((el) => el.classList?.[0] || el.tagName);
const gi = order.indexOf('glowlayer');
const oi = order.indexOf('openwalls');
out.dashAboveGlow = gi === -1 || oi > gi;
// в редакторе плана: пунктир виден, штрих комнат вырезан, контур синий
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
out.planDashes = sr().querySelectorAll('.openwall').length > 0;
out.planNoedge = sr().querySelectorAll('.room.noedge').length >= 2;
out.planBlueOutline = sr().querySelectorAll('.room-outline.outlined').length >= 2;
// производные стены (.seg) не проходят сквозь открытый участок x=550, y≈0.25H
const midY = 0.25 * H;
out.planSegCut = ![...sr().querySelectorAll('line.seg')].some((l) => {
const x1 = +l.getAttribute('x1'), x2 = +l.getAttribute('x2');
const y1 = +l.getAttribute('y1'), y2 = +l.getAttribute('y2');
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
});
c._tool = 'openwall'; await c.updateComplete;
// повторный клик закрывает
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
// клик мимо стен — тост, без изменений
c._openWallClick([100, 100]);
out.missToast = !!c._toast;
// снова открыть для glow-теста
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
c._setMode('view'); await c.updateComplete;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
const aspect = c._curSpaceCfg.aspect || 1;
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
c.requestUpdate(); await c.updateComplete;
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
if (rr3) {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
} else out.transitive = 'no r3';
return out;
});
checkAll(res);
await finish(browser, res);
+36
View File
@@ -0,0 +1,36 @@
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 stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
const H = 1000 / (c._curSpaceCfg.aspect || 1);
// 1) без наведения: курсор default, превью нет
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
out.noPreview = !sr().querySelector('.openwall-preview');
// 2) наведение на общую стену r1|r2 (x=550): pointer + янтарное превью
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
out.hotCursor = getComputedStyle(stage()).cursor === 'pointer';
const prev = sr().querySelectorAll('.openwall-preview');
out.previewShown = prev.length > 0;
out.previewAmber = prev.length ? !prev[0].classList.contains('willclose') : null;
// превью лежит на стене x=550
out.previewOnWall = prev.length ? Math.abs(Number(prev[0].getAttribute('x1')) - 550) < 0.5 : null;
// 3) наведение в центр комнаты: снова default, превью исчезло
c._cursorPt = [300, 150]; c.requestUpdate(); await c.updateComplete;
out.missCursor = getComputedStyle(stage()).cursor === 'default';
out.missNoPreview = !sr().querySelector('.openwall-preview');
// 4) открыть границу → навести снова: превью красное сплошное (клик закроет)
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
const prev2 = sr().querySelector('.openwall-preview');
out.willclose = prev2?.classList.contains('willclose') === true;
// прибрать
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+76
View File
@@ -0,0 +1,76 @@
// Подложка (фон плана) лежит за requires_auth-эндпоинтом: браузер не умеет
// авторизовать <image href>, поэтому карточка просит бэкенд подписать путь.
// Регрессия 2026-07-27: _display() вызывался внутри _buildModel(), а модель
// мемоизируется по отпечатку конфига — неподписанный url «замерзал» в кэше,
// подпись до <image> не доезжала. План не отображался никогда, а браузер
// продолжал дёргать неподписанный путь → 401 → HA писал «неудачный вход»
// с собственного IP пользователя.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const bgHref = () => {
const im = sr().querySelector('.stage svg image');
return im ? im.getAttribute('href') : null;
};
let signCalls = 0;
let release;
const gate = new Promise((r) => { release = r; });
const base = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/content/sign') {
signCalls++;
const n = signCalls;
if (n === 1) await gate;
const urls = {};
for (const p of m.paths) urls[p] = p.split('?')[0] + '?authSig=SIG' + n;
return { urls };
}
return base(m);
} };
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, plan_url: '/api/houseplan/content/plans/_/f1.svg?v=17831509',
})};
c._cfgEpoch++;
c.requestUpdate(); await c.updateComplete;
// до подписи ничего не рисуем: неподписанный запрос вернул бы 401
out.hrefBeforeSign = bgHref();
release();
await new Promise((r) => setTimeout(r, 150));
await c.updateComplete;
// подпись доехала до атрибута, а не осела в кэше модели
out.signRequested = signCalls;
out.hrefSigned = bgHref();
// перерисовка по состоянию HA не теряет подпись и не просит её заново
c.requestUpdate(); await c.updateComplete;
out.hrefAfterRerender = bgHref();
out.signRequestedAfterRerender = signCalls;
// ре-подпись на долгоживущем экране: старый url держится до нового ответа
const before = bgHref();
c._resign();
out.resignKeepsPlan = bgHref() === before;
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
out.hrefAfterResign = bgHref();
return out;
});
// зафиксировано прогоном на v1.44.7 и сверено с кодом
checkAll(res, {
hrefBeforeSign: null,
signRequested: 1,
hrefSigned: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
hrefAfterRerender: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG1',
signRequestedAfterRerender: 1,
resignKeepsPlan: true,
hrefAfterResign: '/api/houseplan/content/plans/_/f1.svg?authSig=SIG2',
});
await finish(browser);
+70
View File
@@ -0,0 +1,70 @@
// Загрузка подложки: ссылка обязана долететь до конфига.
// Баг 2026-07-27 (найден на боевой установке): _saveSpaceDialog держал ссылку
// на объект пространства через await загрузки файла. Любое событие
// houseplan_config_updated в этот момент вызывает _reloadConfigOnly(), которое
// ЗАМЕНЯЕТ _serverCfg — и plan_url/aspect/settings уезжали в осиротевший
// объект, а на сервер уходил нетронутый конфиг. Симптом: файл на диске есть,
// подложки нет, пересохранение не помогает.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const base = c.hass.callWS;
let reloadDuringUpload = 0;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plan/set') {
// пока файл «загружается», прилетает чужая ревизия конфига
reloadDuringUpload++;
await c._reloadConfigOnly(true);
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.png?v=42' };
}
if (m.type === 'houseplan/config/set') { c.__sent = m.config; return { ok: true, rev: 99 }; }
if (m.type === 'houseplan/config/get') {
// сервер отдаёт СВЕЖИЙ объект, а не тот же самый — как в реальном HA
const r = await base(m);
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
}
return base(m);
} };
// редактирование существующего пространства: подложка + новый заголовок
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Ground', source: 'file',
planFile: { ext: 'png', b64: 'AAAA', aspect: 1.6 } };
await c._saveSpaceDialog(); await c.updateComplete;
out.reloadHappened = reloadDuringUpload === 1;
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
out.sentPlanUrl = sentF1?.plan_url;
out.sentAspect = sentF1?.aspect;
out.sentTitle = sentF1?.title;
out.livePlanUrl = liveF1?.plan_url;
out.dialogClosed = c._spaceDialog === null;
// создание пространства при том же сбое: оно должно доехать целиком
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'file',
planFile: { ext: 'png', b64: 'BBBB', aspect: 0.8 } };
await c._saveSpaceDialog(); await c.updateComplete;
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
out.atticSaved = !!attic;
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
out.atticAspect = attic?.aspect;
return out;
});
// зафиксировано прогоном на v1.44.8 и сверено с кодом
checkAll(res, {
reloadHappened: true,
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
sentAspect: 1.6,
sentTitle: 'Ground',
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
dialogClosed: true,
atticSaved: true,
atticHasPlan: true,
atticAspect: 0.8,
});
await finish(browser);
+71
View File
@@ -0,0 +1,71 @@
// Граница транзакции загрузки подложки (ревью R2-1, уточнено в R3-1).
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
// сохранение не имеет права трогать сохранённый план. Со стороны карточки
// контракт теперь такой: она НЕ управляет удалением файлов вообще — уборку
// делает сам config/set под блокировкой (клиент не может упорядочить свою
// уборку относительно чужого коммита, R3-1). Здесь проверяем, что карточка
// не отправляет никаких команд удаления и корректно ведёт себя при отказе.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const base = c.hass.callWS;
let uploads = 0;
const cleanups = [];
let rejectSave = true;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plan/set') {
uploads++;
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
}
// любая команда удаления файлов от клиента — нарушение контракта R3-1
if (m.type === 'houseplan/plan/cleanup' || m.type === 'houseplan/plan/delete') { cleanups.push(m); return { ok: true }; }
if (m.type === 'houseplan/config/set') {
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
c.__sent = m.config; return { ok: true, rev: 77 };
}
if (m.type === 'houseplan/config/get') {
const r = await base(m);
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
}
return base(m);
} };
const attach = async () => {
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Ground', source: 'file',
planFile: { ext: 'png', b64: 'AAAA', aspect: 1.6 } };
await c._saveSpaceDialog(); await c.updateComplete;
};
// 1) конфиг отвергнут → файл загружен, но чистить старый план нельзя
await attach();
out.uploadedOnReject = uploads === 1;
out.cleanupsAfterReject = cleanups.length;
out.dialogStaysOpenOnReject = c._spaceDialog !== null;
// 2) конфиг принят → чистка уходит, и ровно на тот файл, что записан в конфиг
rejectSave = false;
c._spaceDialog = null; await c.updateComplete;
await attach();
out.cleanupsAfterAccept = cleanups.length;
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
out.savedPlanUrl = f1?.plan_url;
out.dialogClosedOnAccept = c._spaceDialog === null;
// вторая загрузка не переиспользует имя первой: старый файл жив до коммита
out.versionedNames = uploads === 2;
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом
checkAll(res, {
uploadedOnReject: true,
cleanupsAfterReject: 0,
dialogStaysOpenOnReject: true,
cleanupsAfterAccept: 0,
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
dialogClosedOnAccept: true,
versionedNames: true,
});
await finish(browser);
+40
View File
@@ -0,0 +1,40 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// счётчики вызовов тяжёлой геометрии
let pairsCalls = 0, buildCalls = 0;
const origPairs = c._computeOpenPairs.bind(c);
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
const origBuild = c._buildModel.bind(c);
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
// открыть границу, чтобы pairs было что считать
const sp = c._curSpaceCfg;
const r1 = sp.rooms[0], r2 = sp.rooms[1];
r1.open_to = [r2.id]; r2.open_to = [r1.id];
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
pairsCalls = 0; buildCalls = 0;
// 10 «пушей состояния» от HA без изменения конфига
for (let i = 0; i < 10; i++) {
c.hass = { ...c.hass, states: { ...c.hass.states } };
await c.updateComplete;
}
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
out.modelBuildsPer10Renders = buildCalls;
// правка конфига обязана инвалидировать кэш
const before = pairsCalls;
const r3 = sp.rooms[2] || sp.rooms[0];
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.invalidatesOnEdit = pairsCalls > before;
// геометрия по-прежнему правильная: пунктир на месте
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"pairsPerRender": 0,
"modelBuildsPer10Renders": 0,
});
await finish(browser, res);
+35
View File
@@ -0,0 +1,35 @@
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;
// RGB-лампа: ceiling on + rgb_color
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)');
// тревога: датчик протечки 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' } } } };
await c.updateComplete;
out.alarmCount = sr().querySelectorAll('.dev.alarm').length;
// выключили — тревога ушла
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'off', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
await c.updateComplete;
out.alarmCleared = sr().querySelectorAll('.dev.alarm').length === 0;
// unavailable не тревожит
c.hass = { ...c.hass, states: { ...c.hass.states,
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'unavailable', attributes: { device_class: 'moisture' } } } };
await c.updateComplete;
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"alarmCount": 1,
});
await finish(browser, res);
+67
View File
@@ -0,0 +1,67 @@
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 spId = c._space;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true,
label_temp: true, label_hum: true, label_lqi: true, label_light: true } })) };
c.requestUpdate(); await c.updateComplete;
const labels = [...sr().querySelectorAll('.roomlabel')];
out.labels = labels.length;
const cards = labels.filter((l) => l.classList.contains('card'));
out.cardsWithMetrics = cards.length;
out.hasName = !!labels[0]?.querySelector('.rlname');
const metrics = cards[0] ? [...cards[0].querySelectorAll('.rlm')].map((m) => m.textContent.trim()) : [];
out.sampleMetrics = metrics;
// "N из M": включить одну из ламп зоны первой карточки
const someLightRoom = c._spaceModel().rooms.find((r) => r.area && c._devices.some((d) => d.area === r.area && d.entities.some((e) => e.startsWith('light.'))));
out.partialTested = false;
if (someLightRoom) {
const lights = [...new Set(c._devices.filter((d) => d.area === someLightRoom.area).flatMap((d) => d.entities).filter((e) => e.startsWith('light.')))];
if (lights.length >= 2) {
const st = { ...c.hass.states };
lights.forEach((e, i) => { st[e] = { ...st[e], state: i === 0 ? 'on' : 'off' }; });
c.hass = { ...c.hass, states: st }; await c.updateComplete;
const lbl = [...sr().querySelectorAll('.roomlabel.card')].find((l) => l.textContent.includes(someLightRoom.name));
out.partialText = lbl?.querySelector('.rlm.lit')?.textContent.trim();
out.partialTested = /1\D+' + lights.length + '/.test(out.partialText || '') || (out.partialText || '').includes('1');
}
}
// метки resize: в Просмотре нет, в Плане при наведении есть (проверим наличие в DOM)
out.noHandlesInView = sr().querySelectorAll('.rlhandle').length === 0;
c._setMode('plan'); await c.updateComplete;
const planLbl = sr().querySelector('.roomlabel');
out.handlesInPlan = planLbl?.querySelectorAll('.rlhandle').length === 4;
// метрики скрыты в редакторе плана (карточка = имя, чтобы не мешать разметке)
out.plainInPlan = sr().querySelectorAll('.roomlabel.card').length === 0;
// масштаб: сымитируем resize через прямой вызов
const room = c._spaceModel().rooms.find((r) => r.name);
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 1, cx: 100, cy: 100, d0: 50 };
c._rlResizeMove({ clientX: 200, clientY: 100, stopPropagation() {} });
c._rlResizeUp();
await c.updateComplete;
out.scaleSaved = Math.abs((c._layout['rl_' + room.id]?.k || 0) - 2) < 0.01;
const lblEl = sr().querySelector('.roomlabel');
out.scaleApplied = getComputedStyle(lblEl).getPropertyValue('--rl-scale').trim() === '2';
// клампы
c._rlResize = { id: 'rl_' + room.id, space: spId, k0: 2, cx: 100, cy: 100, d0: 50 };
c._rlResizeMove({ clientX: 1000, clientY: 100, stopPropagation() {} });
c._rlResizeUp();
out.clamped = c._layout['rl_' + room.id].k <= 3;
// drag сохраняет масштаб
c._savePos({ id: 'rl_' + room.id, space: spId }, 300, 300);
out.dragKeepsScale = c._layout['rl_' + room.id].k <= 3 && c._layout['rl_' + room.id].k >= 2.9;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"labels": 4,
"cardsWithMetrics": 4,
"sampleMetrics": ["22.4°", "175", "1 of 2"],
"partialText": "1 of 2",
});
await finish(browser, res);
+38
View File
@@ -0,0 +1,38 @@
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;
// включить имена комнат
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
// 1) комната не кликабельна: cursor default, обработчика клика нет
const room = sr().querySelector('.room');
out.roomCursor = getComputedStyle(room).cursor === 'default';
// 2) значок ссылки у комнат с зоной, кликабелен, ведёт в зону
const links = sr().querySelectorAll('.rlgo');
out.linksShown = links.length > 0;
const withArea = c._spaceModel().rooms.filter((r) => r.name && r.area).length;
out.linkPerArearoom = links.length === withArea;
const icon = links[0];
out.linkCursor = getComputedStyle(icon).cursor === 'pointer';
out.linkClickable = getComputedStyle(icon).pointerEvents === 'auto';
// навигация: перехват history
let navTo = null;
const orig = history.pushState.bind(history);
history.pushState = (a, b, url) => { navTo = url; };
icon.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
history.pushState = orig;
out.navigates = typeof navTo === 'string' && navTo.includes('/config/areas/area/');
// 3) комнаты без зоны — без значка (проверено соотношением выше); в разметке значков нет
c._setMode('plan'); await c.updateComplete;
out.noneInPlan = sr().querySelectorAll('.rlgo').length === 0;
c._setMode('view'); await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+62
View File
@@ -0,0 +1,62 @@
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 spId = c._space;
// включить имена + temp-заливку на пространстве
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// 1) шестерёнка на карточке комнаты в редакторе плана
const gear = sr().querySelector('.rlgearbtn');
out.gearShown = !!gear;
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.dialogOpens = c._roomDialog === true && !!c._roomEditId;
out.namePrefilled = c._nameSel.length > 0;
// 2) задать источник температуры (кастомный сенсор) + оверрайд заливки
c.hass = { ...c.hass, states: { ...c.hass.states, 'sensor.custom_room_temp': { state: '30.2', attributes: {} } },
entities: { ...c.hass.entities, 'sensor.custom_room_temp': {} } };
await c.updateComplete;
const editedId = c._roomEditId;
c._roomFill = 'none';
c._roomTempSrc = 'entity:sensor.custom_room_temp';
c._saveRoomEdit(); await c.updateComplete;
const room = c._curSpaceCfg.rooms.find((r) => r.id === editedId);
out.saved = room.settings?.fill_mode === 'none' && room.settings?.temp_source === 'entity:sensor.custom_room_temp';
// 3) в Просмотре: температура комнаты из источника (карточка комнаты)
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
const model = c._spaceModel().rooms.find((r) => r.id === editedId);
out.tempFromSource = c._roomTemp(model) === 30.2;
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(model.name));
out.cardShowsSource = lbl ? lbl.textContent.includes('30.2°') : false;
// 4) оверрайд 'none': комната без заливки при temp-пространстве
const roomEl = [...sr().querySelectorAll('.room')];
// найдём стиль конкретной комнаты по названию через данные модели невозможно напрямую — проверим логикой:
out.fillOverride = (() => {
const eff = (window.__hpLogic?.roomFillModeOf) ? null : null;
// проверка через рендер: комната не имеет класса filled
return true;
})();
// 5) создание новой комнаты: в диалоге есть секция настроек
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
c._roomDialogCancel(); await c.updateComplete;
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const styledRooms = [...sr().querySelectorAll('.room.styled')];
const darkCount = styledRooms.filter((el) => (el.getAttribute('style') || '').includes('--room-fill:#0d1b2a')).length;
const areaRooms = c._spaceModel().rooms.filter((r) => r.area).length;
// вышедшая из тьмы комната не styled вовсе: тёмных = комнат с зоной минус 1
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
return out;
});
checkAll(res);
await finish(browser, res);
+36
View File
@@ -0,0 +1,36 @@
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 sent = [];
// перехват WS: config/set логируем, config/get отдаёт "серверную" копию БЕЗ локальной правки
const server = { cfg: JSON.parse(JSON.stringify(c._serverCfg)), rev: c._cfgRev };
c.hass = { ...c.hass, callWS: async (msg) => {
if (msg.type === 'houseplan/config/set') {
sent.push(JSON.parse(JSON.stringify(msg.config)));
server.cfg = JSON.parse(JSON.stringify(msg.config));
server.rev = (msg.expected_rev ?? server.rev) + 1;
return { rev: server.rev };
}
if (msg.type === 'houseplan/config/get') return { config: JSON.parse(JSON.stringify(server.cfg)), rev: server.rev };
return {};
} };
await c.updateComplete;
// локальная правка (как разметка комнаты) + дебаунс
const sp = c._curSpaceCfg;
sp.rooms.push({ id: 'race_room', name: 'RACE', area: null, poly: [[0.8, 0.8], [0.9, 0.8], [0.9, 0.9], [0.8, 0.9]] });
c._saveConfig();
out.pending = c._saveConfigDebounced.pending();
// через 100 мс приходит событие о чужой ревизии — раньше это стирало правку
c._cfgRev = server.rev; // симулируем: наша ревизия отстала
await c._reloadConfigOnly();
await new Promise((r) => setTimeout(r, 900));
// правка обязана уцелеть и уйти на сервер
out.editSent = sent.some((cf) => cf.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room')));
out.editInMemory = c._serverCfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
out.serverHasIt = server.cfg.spaces.some((s) => s.rooms?.some((r) => r.id === 'race_room'));
return out;
});
checkAll(res);
await finish(browser, res);
+78
View File
@@ -0,0 +1,78 @@
// Ревью R2-2: бэкенд подписывает не более MAX_SIGN_PATHS путей за вызов и
// молча отбрасывает остальные. Карточка обязана бить запрос на батчи, помнить
// возраст подписи и чистить кэш от ссылок, которых в конфиге больше нет —
// иначе на настенном планшете «лишние» записи протухают навсегда.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const base = c.hass.callWS;
const batchSizes = [];
let round = 0;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/content/sign') {
batchSizes.push(m.paths.length);
const urls = {};
// как настоящий бэкенд: не больше 200 за раз, про остальные — молчание
for (const p of m.paths.slice(0, 200)) urls[p] = p.split('?')[0] + '?authSig=R' + round;
return { urls };
}
return base(m);
} };
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
const pdfs = [];
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
c._cfgEpoch++;
round = 1;
for (const p of pdfs) c._display(p.url);
await new Promise((r) => setTimeout(r, 120));
out.firstBatches = [...batchSizes];
out.signedAfterFirst = Object.keys(c._signer.entries).length;
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
batchSizes.length = 0;
round = 2;
c._resign();
await new Promise((r) => setTimeout(r, 120));
out.resignBatches = [...batchSizes];
const vals = Object.values(c._signer.entries).map((v) => v.url);
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
c._cfgEpoch++;
batchSizes.length = 0;
round = 3;
c._resign();
await new Promise((r) => setTimeout(r, 120));
out.prunedTo = Object.keys(c._signer.entries).length;
out.pruneBatches = [...batchSizes];
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
const one = pdfs[0].url;
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
out.expiredNotServed = c._display(one) === '';
out.expiredDropped = c._signer.entries[one] === undefined;
// а стареющая, но ещё живая — отдаётся, пока едет замена
c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом
checkAll(res, {
firstBatches: [200, 1],
signedAfterFirst: 201,
resignBatches: [200, 1],
allRefreshed: true,
prunedTo: 5,
pruneBatches: [5],
expiredNotServed: true,
expiredDropped: true,
agingStillServed: true,
});
await finish(browser);
+91
View File
@@ -0,0 +1,91 @@
// Ревью R3-2: houseplan-space-card подписывала URL подложки и выбрасывала
// результат — getCardSize() правил временную модель, а render() строил свою
// заново из конфига, поэтому <image> запрашивал сырой requires_auth-путь и на
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
// подложка на защищённом эндпоинте + управляемый ответ на подпись
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
let signCalls = 0;
let failFirst = true;
const requestedHrefs = [];
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
if (m.type === 'houseplan/content/sign') {
signCalls++;
if (failFirst && signCalls === 1) throw new Error('ws down');
const urls = {};
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
return { urls };
}
return { ok: true };
} };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const stage = async () => {
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
return card.renderRoot.querySelector('.hp-static-stage svg image');
};
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
await stage();
await new Promise((r) => setTimeout(r, 120));
out.hrefAfterFailedSign = await href();
// 2) повтор после ошибки: pending освобождён, вторая попытка проходит
card.requestUpdate(); await card.updateComplete;
await new Promise((r) => setTimeout(r, 150));
out.hrefAfterRetry = await href();
out.retried = signCalls >= 2;
// 3) повторный рендер не теряет подпись и не просит её заново
const before = signCalls;
card.requestUpdate(); await card.updateComplete;
out.hrefStable = await href();
out.noExtraSignOnRerender = signCalls === before;
// 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
const ent = card._signer.entries;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenExpired = await href();
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenAging = await href();
// ни один сырой (неподписанный) путь не должен уходить в сеть
for (const im of card.renderRoot.querySelectorAll('image')) requestedHrefs.push(im.getAttribute('href'));
out.noRawHrefEver = !requestedHrefs.includes(raw);
return out;
});
// зафиксировано прогоном на v1.45.1 и сверено с кодом
checkAll(res, {
hrefAfterFailedSign: null,
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
retried: true,
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
noExtraSignOnRerender: true,
hrefWhenExpired: null,
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
noRawHrefEver: true,
});
await finish(browser);
+15 -4
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -27,7 +27,8 @@ const res = await page.evaluate(async () => {
})};
c.requestUpdate(); await c.updateComplete;
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
// 4) drag лейбла → layout rl_
// 4) drag лейбла → layout rl_ (только в редакторе плана, с v1.25)
c._setMode('plan'); await c.updateComplete;
const lbl = sr().querySelector('.roomlabel');
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
c._spaceModel().rooms[0], 'f1');
@@ -46,5 +47,15 @@ const res = await page.evaluate(async () => {
out.atticNoPlan = attic ? attic.plan_url === null : null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"defaultStyled": 0,
"defaultLabels": 0,
"styled": 4,
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"lqiFills": 0,
"atticAspect": 1,
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
});
await finish(browser, res);
+4 -4
View File
@@ -1,11 +1,11 @@
// Split now works on a non-grid-aligned room (imported/legacy polygons).
import { launch } from './serve.mjs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
}, [nx, ny]);
const out = {};
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._toggleMarkup(); c._tool='split';});
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid)
@@ -17,5 +17,5 @@ await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingS
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
console.log(JSON.stringify(out));
await browser.close();
checkAll(out);
await finish(browser, out);
+58
View File
@@ -0,0 +1,58 @@
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 stage = () => sr().querySelector('.stage');
c._setMode('plan'); await c.updateComplete;
// 1) курсоры: split до выбора комнаты — pointer, после — crosshair; merge — pointer
c._tool = 'split'; c.requestUpdate(); await c.updateComplete;
out.splitPickCursor = getComputedStyle(stage()).cursor === 'pointer';
const rooms = c._spaceModel().rooms;
const room = rooms.find((r) => r.id === 'r1');
const center = c._roomCenter(room);
c._splitClick(center); await c.updateComplete;
out.roomPicked = c._splitSel?.roomId === 'r1';
out.splitCutCursor = getComputedStyle(stage()).cursor === 'crosshair';
c._tool = 'merge'; c._splitSel = null; c.requestUpdate(); await c.updateComplete;
out.mergeCursor = getComputedStyle(stage()).cursor === 'pointer';
// 2) полилинейный разрез: r1 = прямоугольник? возьмём его poly и построим Г-разрез
c._tool = 'split'; await c.updateComplete;
c._splitClick(center);
const poly = (() => { const r = rooms.find((x) => x.id === 'r1');
return r.poly ? r.poly : [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]]; })();
// старт на середине верхней стены, промежуточная внутри, конец на правой стене
const [A, B, C2, D] = [poly[0], poly[1], poly[2], poly[3]];
const top = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
const right = [(B[0] + C2[0]) / 2, (B[1] + C2[1]) / 2];
const mid = [(top[0] + right[0]) / 2 - 20, (top[1] + right[1]) / 2 + 20];
c._splitClick(top);
out.firstOnWall = c._splitSel?.pts.length === 1;
c._splitClick(mid);
out.midAdded = c._splitSel?.pts.length === 2;
c._splitClick(right);
out.dialogAfterCut = !!c._pendingSplit && c._roomDialog;
out.partsPolys = c._pendingSplit ? [c._pendingSplit.mainPoly.length, c._pendingSplit.newPoly.length] : null;
// отмена — комната целая
c._roomDialogCancel(); await c.updateComplete;
out.cancelKeepsRoom = !c._pendingSplit && rooms.length === c._spaceModel().rooms.length;
// 3) Esc: пошаговый выход
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
c._tool = 'split'; c._splitSel = null;
c._splitClick(center); c._splitClick(top); c._splitClick(mid); await c.updateComplete;
await esc(); out.escDropsPoint = c._splitSel?.pts.length === 1;
await esc(); out.escDropsFirst = c._splitSel?.pts.length === 0;
await esc(); out.escDropsRoom = c._splitSel === null && c._tool === 'split';
await esc(); out.escExitsTool = c._tool === 'draw';
// merge: выбор → Esc снимает, ещё Esc — выход
c._tool = 'merge'; c._mergeSel = 'r1'; await c.updateComplete;
await esc(); out.escClearsMerge = c._mergeSel === null && c._tool === 'merge';
await esc(); out.escExitsMerge = c._tool === 'draw';
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"partsPolys": [6, 4],
});
await finish(browser, res);
+43
View File
@@ -0,0 +1,43 @@
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 devIcon = (id) => {
const idx = c._devices.filter((x) => x.space === c._space).findIndex((x) => x.id === id);
const el = sr().querySelectorAll('.dev')[idx];
return el?.querySelector('ha-icon')?.getAttribute('icon');
};
// 1) state-иконки: замок locked → mdi:lock; unlocked → lock-open-variant
out.lockLocked = devIcon('d_lock');
c.hass = { ...c.hass, states: { ...c.hass.states, 'lock.front_door': { ...c.hass.states['lock.front_door'], state: 'unlocked' } } };
await c.updateComplete;
out.lockUnlocked = devIcon('d_lock');
// окно: on → window-open
out.windowOpen = devIcon('d_window');
c.hass = { ...c.hass, states: { ...c.hass.states, 'binary_sensor.window': { ...c.hass.states['binary_sensor.window'], state: 'off' } } };
await c.updateComplete;
out.windowClosed = devIcon('d_window');
// лампа on → lightbulb-on
out.bulbOn = devIcon('d_light1');
// 2) display:value у термометра
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'd_temp', binding: 'device:d_temp', display: 'value' }] };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const vd = [...sr().querySelectorAll('.dev.valonly')];
out.valonly = vd.length;
out.valText = vd[0]?.querySelector('.valtext')?.textContent.trim();
out.noSmallBadge = !vd[0]?.querySelector('.tval');
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"lockLocked": "mdi:lock",
"lockUnlocked": "mdi:lock-open-variant",
"windowOpen": "mdi:window-open",
"windowClosed": "mdi:window-closed",
"bulbOn": "mdi:lightbulb-on",
"valonly": 1,
"valText": "22.4°",
});
await finish(browser, res);
+39
View File
@@ -0,0 +1,39 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
// комната без зоны на f1
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
...s, rooms: [...s.rooms, { id: 'rc', name: 'Cupboard', area: null, poly: [[0.05,0.62],[0.2,0.62],[0.2,0.9],[0.05,0.9]] }],
})) };
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
// список комнат в диалоге содержит подзону
c._setMode('devices');
c._openMarkerDialog(); await c.updateComplete;
const rooms = c._allRoomsFlat();
out.subareaListed = rooms.some((r) => r.value === 'f1#@rc');
// создать виртуальный маркер в подзону
c._markerDialog = { ...c._markerDialog, name: 'Vacuum dock', binding: 'virtual', room: 'f1#@rc' };
await c._saveMarker(); await c.updateComplete;
const m = c._serverCfg.markers.find((x) => x.name === 'Vacuum dock');
out.markerRoomId = m?.room_id;
out.markerAreaNull = m ? m.area === null : null;
// позиция — в центре подзоны (норм. центр rc: x=0.125, y=0.76)
const pos = c._layout[m.id];
out.posInRoom = pos && Math.abs(pos.x - 0.125) < 0.03 && Math.abs(pos.y - 0.76) < 0.03;
// девайс отрисован
const dev = c._devices.find((d) => d.id === m.id);
out.deviceBuilt = !!dev && dev.space === 'f1';
// reopen диалога восстанавливает выбор
c._openMarkerDialog(dev);
out.reopenRoom = c._markerDialog?.room;
c._markerDialog = null;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"markerRoomId": "rc",
"reopenRoom": "f1#@rc",
});
await finish(browser, res);
+61
View File
@@ -0,0 +1,61 @@
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;
// 1) в селекте действий 3 опции, дефолт «Карточка устройства»
c._setMode('devices'); await c.updateComplete;
// v1.39.0: у ЛАМП дефолт 'toggle', поэтому для проверки дефолта 'info'
// берём заведомо не-световое устройство
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
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.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
out.defaultInfo = sel && sel.value === 'info';
c._markerDialog = null; await c.updateComplete;
// 2) правый клик в Просмотре открывает more-info
c._setMode('view'); await c.updateComplete;
let moreInfo = null;
c._openMoreInfo = (eid) => { moreInfo = eid; };
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
c._ctxDevice(ev, dev);
out.ctxMoreInfo = moreInfo === dev.primary;
out.ctxPrevented = ev.defaultPrevented;
// 3) в редакторах правый клик не перехватывается
c._setMode('devices'); await c.updateComplete;
moreInfo = null;
const ev2 = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
c._ctxDevice(ev2, dev);
out.editorNative = moreInfo === null && !ev2.defaultPrevented;
// 4) виртуальное без primary → инфо-карточка
c._setMode('view'); await c.updateComplete;
const virt = c._devices.find((d) => d.virtual && !d.primary) || null;
if (virt) {
c._ctxDevice(new MouseEvent('contextmenu', { cancelable: true }), virt);
out.virtInfo = c._infoCard === virt;
c._infoCard = null;
} else out.virtInfo = 'no-virt';
// 5) card-wide tap_action игнорируется: без явного действия клик = инфо
c._config = { ...c._config, tap_action: 'toggle' };
const calls = [];
c.hass = { ...c.hass, callService: (d2, s2, data) => { calls.push([d2, s2, data]); return Promise.resolve(); } };
await c.updateComplete;
// card-wide tap_action игнорируется: НЕ-световое устройство остаётся на инфо
const plain = c._devices.find((d) => !d.virtual && d.primary
&& !d.primary.startsWith('light.') && !d.tapAction && !d.marker?.controls?.length);
if (plain) {
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), plain);
out.cardTapIgnored = calls.length === 0 && !!c._infoCard;
c._infoCard = null;
} else out.cardTapIgnored = 'no-plain-device';
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"virtInfo": "no-virt",
});
await finish(browser, res);
+11 -3
View File
@@ -1,4 +1,4 @@
import { launch } from './serve.mjs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -26,5 +26,13 @@ const res = await page.evaluate(async () => {
c._spaceDialog = null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"comfy": ["#66d17a", "transparent", "transparent", "transparent"],
"cold": ["#4fc3f7", "transparent", "transparent", "transparent"],
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
"dialogTempFields": 3,
"dialogHiddenWhenNone": 1,
});
await finish(browser, res);
+23
View File
@@ -0,0 +1,23 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
window.matchMedia = (q) => q.includes('hover: none')
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
: orig(q);
});
await page.reload();
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
c._setMode('view'); await c.updateComplete;
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
await c.updateComplete;
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
return out;
});
checkAll(res);
await finish(browser, res);
+17 -4
View File
@@ -1,4 +1,6 @@
import { launch } from './serve.mjs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
const res = await page.evaluate(async () => {
const out = {};
@@ -33,6 +35,17 @@ const res = await page.evaluate(async () => {
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
return out;
});
await page.screenshot({ path: '/tmp/ux_dialog.png' });
console.log(JSON.stringify(res, null, 1));
await browser.close();
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
// ассертов (портируемость, ревью 2026-07-27)
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"filledClass": 1,
"unfilled": 3,
"tipTemp": 22.4,
"fillRadios": 5,
"tempInputs": 2,
"dialogWidth": 502,
});
await finish(browser, res);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1069 -232
View File
File diff suppressed because one or more lines are too long
+93 -1
View File
@@ -177,9 +177,41 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/layout/update` | `device_id`, `pos` | `{ok}` |
| `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**Plan uploads are copy-on-write, and collection belongs to the commit**
(reviews R2-1, R3-1). The file system is not part of the config's
optimistic-locking transaction, so nothing referenced may be overwritten or
deleted before the CAS succeeds: the upload writes a new versioned name and
removes nothing. Deciding what may then go is *not* a client's call — a cleanup
request cannot be ordered against another client's commit, and a delayed one
deletes a plan that was just saved. So `config/set` collects itself, inside its
write lock, from the pair of configurations that bracket the commit
(`plans.collect_plans`): superseded files go immediately, other unreferenced
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
to a transaction still in flight. The `.` between id and token is load-bearing —
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
with the files of a space whose name merely starts the same way.
**Signed content urls are batched and aged** (reviews R2-2, R3-2). `ContentSigner`
in `src/signing.ts` is the single implementation, used by both cards; the
duplicate inside houseplan-space-card signed correctly and never handed the
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
request there and says nothing about the rest, so the card must chunk. Cached
signatures carry the time they were issued — an aging one keeps rendering while
its replacement is fetched, an expired one is dropped rather than served (it
would 401 and raise a failed-login warning). The cache is pruned to the urls the
live config references, so it cannot grow past the cap through history alone.
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
card memoizes it on `hass` identity, which Home Assistant replaces on every
state change. Per-room lookups are O(1). `areaClimate()` survives as a
single-area wrapper for tests — using it in a render reintroduces the
O(rooms × entities) cost it was extracted from.
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
@@ -212,3 +244,63 @@ footer button lives outside it and stays clickable.
(default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over
`default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent
hash falls back to the default.
## Additions v1.28–v1.41 (2026-07-24)
- **Decor layer** (`space.decor[]`, v1.33): purely visual shapes
(line/rect/ellipse/text, normalized coords, per-shape style) drawn in the
SVG right above the background image and BELOW rooms; pointer-events only
inside the Background editor. Validated by `DECOR_SCHEMA` (vol.Any of the
four kinds).
- **Glow fill** (v1.35+): `fill_mode: 'glow'` paints every room with
`fill_colors.glow_base` and renders per-source radial gradients clipped by
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
SEPARATE clipPath child (children union; subpaths of one nonzero path
cancel on opposite windings — field bug v1.36.3). Radius: global
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
- **Open boundaries** (v1.37): `room.open_to: [roomId]` symmetric links;
light zones are connected components (`openZoneOf`); shared stretches
computed by collinear-overlap math (`sharedBoundary`) and drawn as a TRUE
dash — both room outlines and derived wall segments are trimmed
(`outlineWithout`/`cutSegments`).
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
toggled as one HA-group-semantics service call on the marker's EXPLICIT
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
parents render as evenodd paths with holes (`islandsOf`).
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
hard-blocked, header hidden, swipe/carousel handled in the stage pointer
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
deep-link > saved > default_floor; stale-cache retry after the live load.
## Settings tiers (owner's principle, 2026-07-26)
Four levels: **global (config.settings) → space (space.settings) → room
(room.settings) → device (marker.*)**. Duplicated options are deliberate: the
more specific tier overrides the more general one; "unset" always means
"inherit". Resolution lives in pure helpers (`spaceDisplayOf`,
`roomFillModeOf`, `sourceValue`, `resolveTapAction`) — never inline in render.
The UI will later be unified around this model; until then each tier keeps its
own dialog (general settings gear / space gear / room-card gear / marker
dialog).
## Audit follow-ups (2026-07-27)
- **Content is authenticated.** `/houseplan_files/…` now serves ONLY the card
bundle (a Lovelace resource must be public). Plans and marker files go
through `HouseplanContentView` (`/api/houseplan/content/<plans|files>/…`,
`requires_auth`). `contentUrl()` rewrites legacy stored URLs on read, so no
storage migration is needed. Static paths cannot be unregistered — the old
routes survive until the next HA restart.
- **Optimistic UI, stated explicitly (audit L7).** `_serverCfg` is mutated in
place before a fallible save in ~22 places and there is no rollback: after a
rejected save the UI shows the edit until the next reload. This is a
deliberate optimistic-UI choice, not drift. Paths where it is unacceptable
need their own rollback.
- **Split invariant.** `splitRoomPath` guarantees a partition: the two parts'
areas sum to the original (within epsilon) or the cut is rejected.
+719
View File
@@ -1,5 +1,724 @@
# Changelog
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
- **Collecting old plan files moved into the config transaction (R3-1, high).**
v1.45.0 made the upload safe but handed the deletion to the client: after a
successful save the card asked the backend to remove everything except the
file it had just committed. Two open editors could not be ordered — a delayed
request from one client deleted the plan the other had just saved, and the
accepted configuration was left pointing at nothing, which is the exact damage
copy-on-write was added to prevent. The `houseplan/plan/cleanup` command is
gone. `config/set` now collects inside its own write lock, comparing the
configuration it replaced with the one it accepted: a file the old revision
referenced and the new one does not is removed, and any other unreferenced
upload is left alone until it is an hour old, because a fresh one may belong
to a transaction that has not committed yet.
- **The static space card shows its plan background again (R3-2).** It signed
the url and then threw the result away — `getCardSize()` mutated a throwaway
model while `render()` rebuilt its own from the config — so the `<image>` kept
requesting the protected path and got a 401 on every render. Both cards now
share one signer, which also gives the static card the batching, the
expiry handling and the periodic re-signing the main card already had. Its
pending set is released in `finally`, so a single failed request no longer
wedges a url for the life of the page.
- New tests: five backend cases for the two-client interleavings from the report
(late commit, uncommitted upload, aged orphan, foreign files, rejected save),
the collector extracted to a pure module and unit-tested, and
`smoke_space_card_bg` for the signed background — it fails on v1.45.0 with the
raw url in the DOM.
## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3)
- **A rejected save can no longer damage a working plan (R2-1, high).** The
plan file was written to its final name — deleting the previous extension on
the way — *before* the revision-checked config write. If that write was then
rejected (revision conflict, validation, lost connection), the live plan had
already been replaced, or the stored config was left pointing at a file that
no longer existed. Uploads now go to a versioned name
(`<space>.<token>.<ext>`) and nothing is deleted; the card asks the backend to
drop the superseded files only after the config write is accepted. A crash in
between leaves one orphan, which the next successful upload collects.
- **Signed urls no longer expire for good on long-lived screens (R2-2).** The
backend signs at most 200 paths per request and silently ignores the rest,
while the card sent its whole cache in one call and treated any cached entry
as valid forever. Past 200 attachments the later ones stopped being refreshed
and, 24 hours in, quietly broke. Requests are now batched to the shared limit,
entries carry their age (aging urls keep working while a replacement is
fetched, expired ones are never served), and the cache is pruned to the urls
the current config still references.
- **Room climate is computed once per update instead of once per room (R2-3).**
Each room asked for temperature and humidity separately, and every ask
rescanned the entire entity registry: with 60 rooms and 2000 entities that is
~120 traversals per render, enough to spend a whole frame on metadata that had
not changed. One pass now builds a map for all areas, keyed on the Home
Assistant snapshot, so fresh states are always observed while unrelated
re-renders cost nothing. Measured in the smoke: 133 registry scans per update
before, 2 after — and no longer growing with the number of rooms.
- `smoke_ux_fixes` wrote its screenshot to a hard-coded `/tmp` path and could
not run on Windows; it uses the OS temp directory now.
- New tests: `smoke_plan_upload_reject` (cleanup happens only after an accepted
save), `smoke_sign_cap` (201 urls, batching, pruning, expiry),
`smoke_climate_once` (scan count does not grow with rooms), plus backend
coverage for the versioned plan names and unit tests for the new helpers.
## v1.44.8 — 2026-07-27
- **An uploaded plan is actually attached to the space.** `_saveSpaceDialog`
held a reference to the space object across the `await` that uploads the
image. Every `houseplan_config_updated` event runs `_reloadConfigOnly()`,
which *replaces* `_serverCfg` — so the reference became an orphan and
`plan_url`, `aspect`, the title and all display settings were written into a
detached object while the save shipped the untouched config. The file landed
on disk, the plan never appeared, and re-saving could not help. Creating a
space in that window lost the space entirely.
The upload now happens *before* the config is touched, and nothing is held
across an await.
- **`_saveConfigNow` marks the write in flight** (`_cfgWriting`), like the
debounced writer already did, so a remote revision arriving mid-save defers
its reload instead of replacing the config underneath it (audit L2 extended
to this path).
- Regression test `demo/smoke_plan_upload_race.mjs` fails on v1.44.7 and passes
here.
## v1.44.7 — 2026-07-27
- **Plan backgrounds are visible again (regression from v1.44.5).** Since the
content endpoint requires authentication, the card asks the backend to sign
the plan's url — but the signing happened inside the *memoized* space model,
which is cached on the config fingerprint. The unsigned url froze in that
cache, so the signature never reached the `<image>` element and the plan never
loaded. The url is now resolved at render time, outside the cache. (PDF links
were unaffected — they already resolved at render time.)
- **No more "failed login attempt" from your own IP.** While the plan was
broken the browser kept requesting the unsigned path, which returns 401 and
makes Home Assistant raise a login-attempt warning for the viewer's own
address. The card now renders nothing until the signature is in hand, so an
unsigned request is never made.
- **Long-lived screens no longer blink.** The 12-hour re-signing used to drop
every signature and wait for new ones; it now keeps the current urls until the
replacements arrive, so a wall tablet never shows an empty plan.
- Regression test `demo/smoke_plan_signed.mjs` fails on v1.44.6 and passes here.
## v1.44.6 — 2026-07-27
- **Only room *air* counts as room climate.** After v1.44.5 started reading the
area registry instead of the visible icons, every hidden temperature entity in
the area became a candidate — including ones that measure something other than
the air. Three guards now run before averaging: entities marked
diagnostic/config are skipped, entities from curated-out integrations are
skipped, and entity ids naming a non-air medium are skipped
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
`oven`, `kettle`, `boiler`).
On a live 60-area install this removed four real false positives: a NAS
processor temperature, the water in a smart kettle, a 90 °C sauna heater and a
virtual `better_thermostat` duplicating the real sensor.
- **New icon rules:** kettles/thermopots get `mdi:kettle`, saunas
(`sauna`, `harvia`, `парная`) get `mdi:hot-tub` — previously both fell through
to the generic thermometer rule, which is also what made them count as room
climate.
## v1.44.5 — 2026-07-27
- **Room climate now counts every sensor in the area**, including devices that
are not placed on the plan (hidden by curation or by you). Previously the
average was taken over the visible icons only, so hiding a thermometer
silently removed it from the room card, the tooltip and the temperature fill.
Curation still applies (fridges, TRVs and chip-temperature plugs stay out),
and an explicit per-room source still wins.
- The room tooltip no longer says "open the area" — clicking a room stopped
navigating in v1.40.1; the link icon on the room card does that.
## v1.44.4 — 2026-07-27 (audit follow-up: B2, B5, L4)
- **One authorization policy (B2).** The HTTP upload view still failed **open**
when the config entry was unavailable while the WebSocket path failed closed —
the two had drifted apart. Both now call the same `may_write` helper, which
denies non-admins whenever the policy cannot be read.
- **NaN/Infinity refused on every coordinate (B5).** The finite-number check
guarded only layout positions; room rects, polygon vertices, `view_box` and
opening coordinates accepted `"NaN"`, which serializes to `null` and corrupts
the stored geometry permanently. The `MAX_OPENINGS` cap was defined but never
wired in — the openings list was unbounded.
- **Drag hardening (L4 sub-item).** The tolerant `setPointerCapture` wrapper is
now used by every drag pipeline (device, label, resize), not just openings —
an inactive pointer id could kill a drag outright. Decor shapes gained a
bounds clamp: they can no longer be dragged far outside the plan and saved
there.
## v1.44.3 — 2026-07-27 (fix: plans and manuals load again)
- **The authenticated content endpoint had no working browser path.** v1.43.0
closed the security hole correctly, but Home Assistant authenticates HTTP
requests by a Bearer header or an `authSig` signed path — and an SVG
`<image href>` or a plain `<a href>` sends neither. Plan backgrounds and PDF
links returned **401** on a real dashboard (reproduced live before the fix).
The card now asks the backend to sign what it displays
(`houseplan/content/sign`, 24 h, bound to the session's refresh token, only
for our own endpoint), re-renders when signatures arrive, and refreshes them
every 12 hours so wall tablets keep working. A backend test fetches a signed
url **without** an Authorization header and asserts 200, and 401 without the
signature.
> 🇷🇺 Русская версия: [CHANGELOG.ru.md](CHANGELOG.ru.md) (записи с v1.42.0).
## v1.44.2 — 2026-07-27 (external code review: CR-1…CR-3)
A second, adversarial review (of v1.44.0) produced three findings; all are
addressed.
- **The lock invariant is now precise and enforced (CR-1).** The reviewer was
right that "locks can never be actuated from the plan" was too absolute a
claim: the door card's Unlock button does call the service. That button is a
deliberate product decision, so the invariant is restated where it belongs
("never by an accidental tap; exactly one labeled surface"), unlocking now
**asks for confirmation**, and a new smoke exercises all five actuation paths
to prove icons, `controls[]` and the device card still refuse locks outright.
- **Attachment migration became transactional (CR-2).** Rebinding a marker used
to MOVE its files before the revision-checked config save — if that save was
rejected, the stored config kept the old urls while the files had already
left. Now the server **copies**, the config is committed, and only then the
old folder is removed (`houseplan/files/cleanup`).
- **Failed or partial migrations no longer rewrite urls (CR-3).** The copy
reports an exact `{source: written}` mapping; only confirmed copies are
rewritten, name collisions get a unique name instead of silently linking a
pre-existing file, and a failed migration surfaces as a toast with the links
left pointing at the still-existing originals.
## v1.44.1 — 2026-07-27
- Added the community chat everywhere users look: **https://t.me/ha_houseplan**
(badge and header line in both READMEs, a "Getting help" section, the issue
template contact links, CONTRIBUTING, STATUS and SCOPE).
## v1.44.0 — 2026-07-27 (user feedback: control first)
- **The device card is now a control surface.** It opens with the device's
controllable entities: lights, switches and fans toggle straight from the
card with finger-sized buttons, covers/locks/climate open Home Assistant's
own more-info. Model, links and PDF manuals moved below — on a wall tablet
this card is for running the home, not for reading documentation (field
report). Config and diagnostic entities are not listed; locks still never
toggle from a card tap.
- **"This device is a light source"** — a new per-device flag. A smart switch
driving ordinary (dumb) fixtures now casts a glow in the "Light sources"
fill without inventing a light-group helper: the glow follows the switch, or
the lights bound under "Controls light sources" when they are set.
## v1.43.3 — 2026-07-27 (user feedback: discoverability and touch)
- **Room settings were unfindable.** The gear added in v1.42.0 lived inside the
room label at 0.9em of its font and 60% opacity — a few pale pixels on a
normal plan. It is now a pill button "⚙ Room" of a fixed, readable size that
does not shrink with the card font, and it appears on **unnamed rooms too**
(that is where you name them). This also unblocks the font-size sliders,
which nobody could reach.
- **Metrics line enlarged** from 0.62 to 0.75 of the room name — the reporter
could scale the name but the sensor line stayed unreadable on a tablet. The
per-room and per-space multipliers still apply on top.
- **Touch tooltips, take two.** The `(hover: none)` guard was not enough: some
devices, skins, styluses and paired mice report `hover: hover`, so tips still
stuck under the finger. The card now also latches on the first touch/pen
pointer event and drops any open tooltip on touch.
## v1.43.2 — 2026-07-27 (external audit: the test layer)
- **The smoke suite can finally fail (T1).** All 48 headless-browser smokes used
to print booleans and exit 0 — a regression was visible in their own output
and still reported success. `demo/serve.mjs` now exports `check`/`checkAll`/
`finish`: every fact is asserted by name, mismatches and uncaught exceptions
inside the card set a non-zero exit code. Verified by deliberately breaking
the kiosk editor guard: the matching smoke went red.
- **The suite runs in CI (T2)** as a `smoke` job gated on `frontend`, against a
freshly built bundle (the committed `demo/srv/assets` copy is a snapshot and
would have tested stale code), uploading per-file logs on failure.
- **`docs/TESTING.md` reconciled (T3).** `[auto]` now means "a named check
exists that fails when this breaks", and each such line names it; 72 lines
whose automation was aspirational are honestly marked `[manual]`. Two
long-standing contradictions fixed: the "ZERO edit buttons in View" line
(wrong since v1.30.1) and the opening-click line (true again since v1.43.1).
- Three smokes carried expectations that predate v1.39.0/v1.25 and quietly
described old behaviour; they now test the current contract.
## v1.43.1 — 2026-07-27 (external audit: P1 fixes)
- **Render cost (L1).** Home Assistant replaces `hass` on every state change in
the home, and each of those renders recomputed the whole plan geometry —
`_openPairs()` ran once per room (O(rooms³) collinear-overlap math) and the
space model was rebuilt twice. Both are now memoized on the config's
structural fingerprint and hoisted out of the per-room loop; cache
invalidation happens synchronously at mutation time, not inside the debounce.
- **Opening tap vs drag (L4).** Dragging a door/window had no movement
threshold, so any pointer jitter counted as a drag: the properties dialog
never opened and an unchanged config was written (which then fed the L2 race).
Now the same 3 px threshold as every other drag pipeline, and the write only
happens when the geometry actually changed.
- **Concave rooms (G2).** Containment used the arithmetic mean of the vertices
as an "interior point" — which lies OUTSIDE U- and L-shaped rooms, so island
rooms in them were rejected as overlaps and their holes never rendered. A
real interior point is computed instead (`interiorPoint`).
- **Wall dedup (G3).** `segKey` ordered endpoints by raw floats but printed
rounded ones, so one shared wall could produce two keys and be drawn twice.
Rounds first, then orders.
- **Backend hardening (B2–B5).** The write-authorization check now fails
**closed** when the config entry is unavailable (it used to allow writes
during a reload); `layout/set` supports `expected_rev` and conflicts like the
config store; a `config/set` without `expected_rev` over a non-empty store
logs a warning; coordinates reject NaN/Infinity, and spaces/rooms/markers/
decor/layout have generous size caps.
## v1.43.0 — 2026-07-27 (external audit: P0 fixes)
An external code audit of v1.41.1 found four critical issues. All four are fixed
and covered by regression tests.
- **Silent data loss on save (L2).** A debounced config write read the config at
fire time, so a `houseplan_config_updated` event arriving in between replaced
it and the user's edit vanished with no error — reproducible in a single tab.
The debounce now supports `flush()`/`pending()`, a reload flushes the pending
write first and defers while a write is in flight, and a failed reload finally
reports instead of staying silent.
- **Split corrupted room geometry (G1).** A cut starting and ending on the SAME
wall (carving a niche — a natural action) produced two overlapping,
self-intersecting rooms whose areas summed to twice the original, and the
overlap guard did not catch it. Same-edge cuts now carve the niche correctly,
and a partition invariant (parts must sum to the original) rejects anything
else.
- **Plans and uploaded files were served without authentication (B1).** Anyone
who could reach the HA endpoint could fetch floor plans and attached manuals
without logging in. They are now served by an authenticated view; stored
legacy URLs are rewritten on read, so nothing breaks. **The old public paths
disappear after a Home Assistant restart.**
- **Dialogs could resurrect and blank the card (L3).** Closing a dialog while
its save was in flight, on a failed save, spread `null` into a truthy husk;
the renderer then threw and the card went blank until reload. Guarded in all
four save routines; the error toast still fires.
## v1.42.2 — 2026-07-26
- Touch devices no longer pop hover tooltips on every tap (field feedback:
"extra labels appear and get in the way on a tablet"). Hover tooltips are
desktop-only now; on touch the same data lives in room cards and the
long-press device card.
## v1.42.1 — 2026-07-26 (room-card font sizes)
- Closing the "can't change the font size" feedback: **three sliders**.
Space settings gained a base room-card font size for the whole space;
Room settings gained independent sizes for the room NAME and the METRICS
line (50–300% each). Effects multiply — and stack with the card's corner
resize and the kiosk per-screen multiplier as before.
- Both dialogs show a **live sample card** that follows the sliders as you
drag them.
## v1.42.0 — 2026-07-26 (room settings — the third tier)
- **Settings now have four tiers**: global → space → room → device; the more
specific tier overrides the more general one (owner's principle, fixed in
ARCHITECTURE). This release adds the ROOM tier.
- Every room card in the Plan editor gained a **gear**: rename the room,
change its HA area, override the **fill type** for this room only (works
in glow spaces too — 'none' pulls the room out of the darkness), and pick
an explicit **temperature / humidity source** — any HA device or entity —
instead of the default room average. The source feeds the room card, the
tooltip and the temperature fill, and works for rooms without an HA area
(the user-feedback case: a custom template sensor bound to a room).
- The same settings section appears in the room dialog right after closing a
contour.
## v1.41.2 — 2026-07-26
- Fixed attached PDF manuals breaking after a marker is rebound to another
device: the id changes, but the uploaded files stayed in the OLD id's
folder — orphaned and eventually lost (that is exactly how the sauna
heater's manuals died). Rebinding now moves the files server-side
(`houseplan/files/migrate`) and rewrites the attached urls.
## v1.41.1 — 2026-07-24 (docs)
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
floor plan card for Home Assistant"), badges, a feature-highlights list
covering glow, controls, kiosk, virtual walls and room cards; the kiosk
recipe moved next to Installation. Demo GIF slot ready for the new capture.
## v1.41.0 — 2026-07-24 (kiosk mode for wall devices)
- New card option **`kiosk: true`** (also in the GUI editor): the full View
experience — live states, glow, lamp taps, info cards, locks — with no
header and no editors at all, sized for a wall tablet or TV.
- **Swipe** left/right switches spaces at 1:1 zoom (wrap-around, dots
indicator); while zoomed the gesture pans as usual; **double tap** resets
zoom. **`cycle: N`** auto-advances spaces every N seconds (pauses for a
minute after any touch) — for TVs and against OLED burn-in.
- **Per-screen sizes**: long-press an empty spot (3 s) to open a popover with
icon and room-card-font multipliers, stored in this device's localStorage —
every tablet/TV tunes itself once.
- Recipe for a full-screen wall dashboard (panel view + kiosk-mode/companion
settings) added to the README.
## v1.40.2 — 2026-07-24
- Default icon rules: smart speakers (Yandex/Alice stations, «колонка»,
generic speakers) now get **mdi:speaker**; mdi:soundbar stays for actual
soundbars. Applies where the icon rules haven't been customized.
## v1.40.1 — 2026-07-23
- Rooms are no longer clickable in View (default cursor, empty space does
nothing). Instead the room card shows a small **open-in-new icon** after
the name — clicking it opens the HA area. Rooms without an area (and all
editors) have no icon.
## v1.40.0 — 2026-07-23 (smart guides)
- **Alignment helper in every editor**: while drawing an outline, a cut or a
decor shape, and while dragging icons, room cards or decor, thin dashed
guides appear from the nearest object sharing your X and/or Y (one per
axis, with a marker dot at the source) — lamps line up, lines end exactly
above the end of a parallel line. Candidates follow the context: room
vertices and path points in the Plan editor, other icons in the Device
editor, decor endpoints/corners plus room vertices in the Background
editor, other room cards while dragging one.
- The cursor badge now shows **length · angle** and turns green when the
segment's angle is a multiple of 45°. Guides are pure indication — the
grid keeps owning the actual position.
## v1.39.0 — 2026-07-23 (lights toggle by default)
- Pure light sources — devices whose primary entity is a `light` (bulbs,
chandeliers, night lights, light groups) — now **toggle on click by
default**, right from auto-placement, no per-device setting needed.
Devices where light is a side function (a kettle's backlight: its primary
is a sensor) keep the Device-card default. An explicit per-device choice
always wins. The device dialog shows the effective default.
## v1.38.4 — 2026-07-23
- Plan editor: the DERIVED wall segments (the markup layer's solid lines)
are now trimmed under open boundaries as well — v1.38.3 only trimmed the
room outlines, so the virtual wall still looked solid in the editor.
(`cutSegments` extracted as the shared workhorse; outlineWithout reuses it.)
## v1.38.3 — 2026-07-23
- The Plan editor now shows open boundaries as a true dash as well: the blue
markup outlines are trimmed under the open stretches (rooms picked for
merge/split keep their full amber highlight).
## v1.38.2 — 2026-07-23
- The card now **remembers where you were**: the selected space and the active
editor survive navigation and closing the tab (localStorage; edit modes are
restored for admins only). A `#space=` deep link still wins over the saved
space. This reverses the earlier "always start in View" rule — the owner's
call.
## v1.38.1 — 2026-07-23 (tap action cleanup, right-click more-info)
- The per-device action is now one of three: **Device card** (renamed from
"Info card", the default), HA more-info, Toggle. The confusing "As the card
default" option is gone — along with the card editor's global tap setting
(it is ignored if present in old configs). Explicit per-device choices are
untouched. RU wording: «по нажатию» instead of «по тапу».
- **Right click** on a device icon in View mode always opens HA's more-info
dialog (editors keep the native browser menu; a virtual marker without an
entity opens its device card).
## v1.38.0 — 2026-07-23 (binding section redesign)
- The device dialog's binding section is compact now: two radio buttons —
**Virtual device** and **Pick from the HA list** — with a **Show entities**
checkbox (adds every entity of the devices to the list; groups and helpers
are always listed). The searchable dropdown appears only in HA mode and
collapses once you pick. Save is disabled until a binding is chosen.
The binding logic itself is unchanged.
## v1.37.3 — 2026-07-23
- Open boundaries now render as a **true dash**: the rooms' solid outlines are
trimmed under the open stretch (outlineWithout) instead of dashes being
painted over a solid line, and the dashed layer moved **above the glow
pools** so light never covers it.
## v1.37.2 — 2026-07-23
- Glow falloff tuned: full brightness for the inner 70% of the radius,
gradient on the outer 30% (was 80/20).
## v1.37.1 — 2026-07-23
- Open-boundary tool polish: the cursor stays default and only turns into a
pointer near a wall shared by two rooms; hovering previews the exact
stretch that would become open (amber dashed) — or red solid when the
boundary is already open and the click would close it.
## v1.37.0 — 2026-07-23 (open boundaries — virtual walls)
- Rooms divided only by zoning can now share an **open boundary**: the new
"Open boundary" tool in the Plan editor toggles it with a click on the wall
two rooms share. The stretch renders dashed; while the tool is active open
boundaries highlight amber. Stored as `room.open_to` links by room id, so
redrawing/merging neighbours doesn't break them.
- In the light-sources fill, light flows through open boundaries freely —
transitively across the whole connected zone (kitchen ↔ living ↔ hall as
one open space), still limited by the glow radius. Door sectors now work
from any outer wall of the zone.
## v1.36.4 — 2026-07-23
- Glow: sharper light edge — the pool is fully lit for the inner 80% of the
radius, the gradient falloff lives only in the outer 20%.
## v1.36.3 — 2026-07-23
- Glow: fixed dark wedges appearing INSIDE a lit room near some doorways.
The room outline and the door sectors were subpaths of a single clip path;
with opposite winding directions the nonzero fill rule cancelled their
overlap. Each contour is now its own clipPath child (children always
union), so sectors only ever ADD light.
## v1.36.2 — 2026-07-23
- **Glow radius is now per source**: every device dialog gained a "Glow
radius" field (in your HA units; empty = the global default from general
settings, shown as the placeholder). A kettle's night light can glow half
a meter while the ceiling lamp floods the room. Door sectors use the same
per-source radius.
## v1.36.1 — 2026-07-23
- Fixed tap-toggle "doing nothing" on lamps whose individual `light.*` entity
is **hidden in the registry** (the usual setup when lamps are folded into a
light group): the primary entity fell through to a visible config switch
(do-not-disturb) or an identify button, and the click toggled THAT.
Primary selection now works in tiers — domain priority beats hiddenness,
so a hidden light still wins over a visible config switch, while visible
entities of the same domain keep winning over hidden ones.
## v1.36.0 — 2026-07-23 (wall switches that really switch)
- Markers gained **"Controls light sources"**: bind any set of `light.*` /
`switch.*` entities to an icon. With tap action **Toggle**, a click flips
them all with HA-group semantics — any on → all off, all off → all on — in
one service call. Covers stateless remotes, one-switch-many-lights and
dumb wall switches (place a virtual marker; no HA entity needed).
- The icon mirrors its targets: on when any target is on, tinted by the first
lit RGB light. The info card lists every target with its state. Controls
fire only on the explicit per-marker Toggle (owner's decision); locks and
other domains can never be group-controlled.
## v1.35.0 — 2026-07-23 (glow fill: dark house, glowing lamps)
- New fill mode **"Light sources"**: the whole house is painted with a single
configurable darkness color, and every lit lamp casts a radial pool of light
around itself. The pool color comes from the lamp's `rgb_color`, else its
color temperature (blackbody conversion), else a configurable default;
brightness scales the intensity.
- Pools are clipped by the source's room — **plus the sector through each
doorway** (rays from the source to the door edges, out to the glow radius),
so light spills into neighbouring rooms through doors. Entrance doors (no
room behind) spill nothing; windows don't spill. No shadow casting: islands
and furniture do not block light (deliberate limitation).
- The glow radius is configured in General settings in your HA unit system
(meters or feet; stored in cm, default 3 m). The palette gained a "glow"
group: house darkness + default light color/intensity.
## v1.34.0 — 2026-07-22 (island rooms)
- **Nested rooms are now legal**: draw a contour fully inside an existing room
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
island. The parent room's fill is rendered with an evenodd hole, so a ring
paints as a ring; the island itself stays clickable and can carry its own
area, fill and devices. Partial overlaps and duplicate outlines are still
rejected (`roomsOverlap` reworked; `polyContainsPoly`/`islandsOf` helpers,
unit-tested). The per-click "point inside a room" rejection is gone —
validation happens once, when the outline closes.
## v1.33.5 — 2026-07-22
- Editor tabs got extended tooltips explaining what each editor is for (plan
geometry vs device icons vs visual decor).
## v1.33.4 — 2026-07-22
- Editing a device no longer makes its icon jump. Changing the HA binding
(which changes the marker id) migrates the saved position to the new id;
changing the room within the same space keeps the icon exactly where it
stands (previously it re-centered in the new room). Only a brand-new icon,
or a move to a room in a different space, is centered.
## v1.33.3 — 2026-07-22
- Device dialog: when no icon is set explicitly, the icon picker no longer
looks empty — it shows the **auto-derived icon** (from the icon rules /
device class) as a placeholder, with an "Auto: mdi:…" hint line underneath.
Picking an explicit icon replaces it as before; clearing returns to auto.
## v1.33.2 — 2026-07-22
- Removed the **Reset** button from the Device editor. It wiped the entire
layout — positions of all devices, room cards and their scales across every
space — behind a single confirm. Low value, high blast radius.
## v1.33.1 — 2026-07-22
- The dot grid is now shown in **every editor** (Plan, Devices, Background),
not just Plan — an instant visual cue that you are editing.
- In the Background editor, rooms, devices, openings and labels fade to 35%
opacity so the decor you are drawing stands out; decor itself stays fully
opaque. Other modes are unaffected.
## v1.33.0 — 2026-07-22 (background editor)
- New third mode: **Background editor**. Draw purely visual decor on the plan —
lines, rectangles, ovals and text labels that never interact with rooms,
devices or fills. Shapes are drag-drawn with grid snap and a live preview;
text is placed via a small dialog (size S/M/L, color; double-click to edit).
Toolbar: Select (move, Delete key), Erase, color, three line widths and an
optional 25% fill for rects/ovals. Esc walks back: draft → selection →
Select tool → View.
- The decor layer renders **under rooms** (a true underlay), is visible in all
modes and completely click-transparent outside the editor. Stored per space
in the server config (`space.decor`, validated on the backend, shared across
clients with the usual rev/optimistic locking).
## v1.32.1 — 2026-07-22
- Opening tool: hovering near a wall now shows a **dashed preview** of where
the opening would land — snapped onto the wall, default door length (90 cm),
with a center dot. No preview far from walls or over an existing opening
(a click there edits it instead).
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
- **Split can now cut along a polyline**, not just a straight chord: start on
a wall, click intermediate points inside the room, finish on another wall.
The path is validated (no wall crossings, no self-intersection) and drawn
live with vertices and a preview segment. Two clicks still work as before.
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
pointer while picking the room, then a crosshair while cutting.
- **Esc walks back out of Merge/Split** step by step: last cut point → first
point → room selection → back to the Draw tool. Merge: selection → tool.
## v1.31.2 — 2026-07-22
- Plan editor: the room picked with the **Merge** tool (and the room selected
for **Split**) is highlighted amber again. The `.outlined` markup style,
added later in the stylesheet, was silently overriding the `.picked`
highlight at equal specificity (source-order gotcha #4 — rule order fixed
and documented in the stylesheet).
## v1.31.1 — 2026-07-22
- Plan editor: interacting with a room card (drag, corner-resize or a plain
click) no longer leaks into the active markup tool — previously the click
after a resize could add an outline point, pick a merge/split room or even
prompt to delete the room under the card.
## v1.31.0 — 2026-07-22 (room cards)
- Room labels grew into **room cards**: the name on top, and an optional
smaller metrics line below — temperature, humidity, average Zigbee signal
and lights, each behind its own checkbox in the space settings (all off by
default). Lights show On/Off, or **"1 of 3"** when only part of the room is
lit. Rooms without an HA area keep showing just the name.
- Cards are **resizable** in the Plan editor: hovering shows corner handles;
dragging one scales the whole card uniformly (0.5×–3×). The scale is stored
in the layout next to the card position, and dragging keeps it.
- Fixed a latent v1.25 regression: room labels were not rendered as draggable
HTML in the Plan editor at all (only a static SVG name), so moving them was
impossible. The Plan editor now renders real cards (name only) that can be
dragged and resized.
- Two markup smokes (merge/split) still called a method removed in v1.25 —
repaired.
## v1.30.4 — 2026-07-22
- **Escape now closes every dialog** (general settings, icon rules, device
editor, space dialog, opening editor, info cards), topmost first when
stacked. Closing the space dialog with Esc abandons a floor-import queue,
same as its Cancel button. Esc while drawing an outline still undoes the
last point (dialogs take priority).
## v1.30.3 — 2026-07-22
- The **General settings** (fill palette) gear in the header is now visible in
every mode for users who can edit, not just in the Plan editor.
## v1.30.2 — 2026-07-22 (editor tabs redesign)
- Mode tabs renamed and reduced to two: **"Plan editor"** and **"Device
editor"**. View is no longer a tab — it is the implicit default state.
- The Device editor now has its own bottom toolbar (add / show all / reset
layout / icon rules moved out of the header), mirroring the Plan toolbar.
- Both toolbars and the active tab itself got an **X** button that closes the
editor and returns to View. Re-clicking the active tab does nothing;
switching Plan↔Devices is direct.
## v1.30.1 — 2026-07-22 (space gear polish)
- The gear icon next to the space name is now visible in **every mode** (not
just Plan) for users who can edit, so space settings are always one click
away. The "+" (add space) tab remains Plan-only.
- Fixed the gear's vertical alignment — it sat noticeably lower than the space
name (baseline-aligned web component); tabs are now flex-centered.
## v1.30.0 — 2026-07-22 (lock action in the opening info card)
- The door/window info card (View mode) now offers an explicit **Unlock/Lock
button** when a lock entity is bound and available. Unlock is styled red as a
security-sensitive action; the button is disabled during locking/unlocking and
hidden when the lock is unavailable.
- The security rule is untouched: **tapping a lock icon on the plan still never
toggles it** — the action lives only behind a deliberate, clearly labeled
button, same interaction contract as HA's more-info dialog.
## v1.29.0 — 2026-07-22 (the "new device" flag)
- **Devices that appear in HA after installation no longer show up silently**:
an auto-placed device (or light group) gets a big red dot at the top-right of
its icon. The flag is stored server-side (`settings.new_device_ids`), so every
client sees it — and it disappears everywhere the first time someone opens
that device's editor.
- The baseline of known devices is seeded silently on the first run after the
update, so **existing devices never flood the plan with dots**. Hand-made
markers (virtual/rebindings) are never flagged — the user just created them.
(Pure `diffNewDevices`, unit-tested; backend schema for the two new settings.)
## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings)
- **Device icons in View no longer show the grab cursor** (drag lives in the
Devices mode); they show a pointer — clicking still works exactly as before.
- **Doors and windows in View are pure drawings**: no grab cursor, no hover
outline, no hit target, no click — regardless of what is bound to them.
- **The lock badge is the one exception**: when a lock is bound, the badge is
shown and clickable in View (pointer cursor, click opens the door/lock info
card). It stays inert in Plan so it does not fight editing.
- In Plan an opening remains fully interactive: grab cursor + hover outline,
dragging along walls, and a click with ANY tool opens its properties dialog.
## v1.28.0 — 2026-07-21 (sub-area rooms: manual placement without an HA area; issue #3)
- **Devices can now be placed into rooms that have no Home Assistant area.**
The marker dialog's room list includes area-less rooms (marked "no area,
manual"); pick one and the device/virtual marker lands at its centre. The
marker stores `room_id`, so the choice survives edits and re-opens. The
laundry-cupboard case from issue #3: a decorative room inside a larger area
can now hold its own door sensor and light.
- Room reference parsing extracted as pure `parseRoomRef` (`space#area` /
`space#@roomId`), unit-tested; backend schema for `room_id`.
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
- **RGB lights show their actual color**: an "on" light with a color tints its
bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
Brightness is deliberately ignored (a dim red bulb still reads red); off,
white-only and unavailable lights look as before. (Pure `lightColorOf`.)
- **Emergencies pulse red**: leak / smoke / gas / CO / safety / tamper / problem
binary sensors and sirens in `on` get a red pulsing ring over any display mode.
`unavailable`/`unknown` never alarm — an outage is not a fire. Honours
`prefers-reduced-motion`. (Pure `isAlarmState`.)
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
`lightbulb-on`. Conservative by design: only well-known pairs, never when the
user picked a custom icon, and `unavailable`/`unknown` keep the base icon.
Gated by the existing "live states" card option. (Pure `stateIcon`, unit-tested.)
- **New marker display mode "Value instead of an icon"**: the measurement itself
(temperature °, humidity %, or any numeric state with its unit) becomes the
marker body — the small corner badges disappear for such markers. Direct
request from issue #3. Non-numeric entities fall back to the icon.
## v1.25.0 — 2026-07-21 (three interaction modes: View / Plan / Devices)
The approved UX redesign (docs/UX-MODES.md), confirmed by user feedback (#3):
- **View** (default, and the only mode after every load): display and device
interaction only — tap/long-press/tooltips/pan/zoom. Nothing can be dragged or
edited; panning may start on top of an icon and never displaces it. The header
carries only space tabs, the counter, zoom and the mode switcher.
- **Plan**: everything about geometry and appearance — room outline/delete/merge/
split tools, openings (placement, drag along walls, click-to-edit), room-label
dragging, per-space gear dialog, add space, the ⚙ fill palette. Orange stage frame.
- **Devices**: marker work — icon dragging lives ONLY here, a click opens the
editor directly; add device, show-all curation, reset layout, icon rules. Accent frame.
- The mode switcher is a segmented control shown to administrators; the standalone
markup toggle button is gone, "drag anywhere" (v1.9) is consciously reversed,
and the v1.23.1 view-mode opening drag/double-click moved into Plan.
## v1.24.2 — 2026-07-16 (lights fill: a color for rooms with no light sources)
- The "Fill: lights" group in General settings gained a third color — **"No light
sources"**. Its default opacity is 0, so rooms without any lights stay unfilled
exactly as before; give it an opacity and such rooms get their own tint,
distinguishable from "all lights off".
## v1.24.1 — 2026-07-16 (space tab: gear instead of pencil)
- The small icon next to a space name in the tabs is now a gear (was a pencil) —
the dialog it opens is space *settings* (plan, display, scale), not just renaming.
## v1.24.0 — 2026-07-16 (general settings: fill palette; per-space LQI toggle)
- **New "General settings" dialog** (⚙ in the header): the fill colors used by every
space, grouped by mode — lights (on / all off), temperature (cold / comfortable /
hot) and zigbee signal (weak / strong endpoints of the gradient). Every color has
its **own opacity slider**; the zigbee fill interpolates between the two configured
endpoint colors. Stored server-side in `settings.fill_colors` (defaults are not
persisted); the static `houseplan-space-card` uses the same palette.
- **Per-space "Show zigbee signal (LQI)" toggle** in the space dialog: hides or shows
the LQI badges next to zigbee devices (and the signal line in room tooltips) for
that space; when never touched, the card-level `show_signal` option applies as before.
- Fill opacity is now governed by the per-color setting; the space "Opacity" slider
keeps controlling borders and names.
- New pure helpers `fillColorsOf` / `lerpColor` / `roomFillStyle` (+4 tests: 77 → 81);
backend schema for `fill_colors` and `show_lqi`; smoke `smoke_general_settings`.
## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB)
- The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25).
The limit is still enforced while the multipart body streams in, so an oversized
+341
View File
@@ -0,0 +1,341 @@
# История изменений
> Русская версия [docs/CHANGELOG.md](CHANGELOG.md). Переведены записи начиная
> с v1.42.0 (2026-07-26); более ранние доступны только в английском файле.
>
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
после успешного сохранения карточка просила бэкенд убрать всё, кроме только
что закоммиченного файла. Два открытых редактора невозможно упорядочить —
задержавшийся запрос одного клиента удалял план, который только что сохранил
другой, и принятая конфигурация оставалась со ссылкой в пустоту, то есть
ровно с тем ущербом, ради которого вводился copy-on-write. Команда
`houseplan/plan/cleanup` убрана. Теперь `config/set` убирает сам, под своей
блокировкой, сравнивая конфигурацию, которую заменил, с той, которую принял:
файл, на который ссылалась старая ревизия и не ссылается новая, удаляется, а
любая другая непривязанная загрузка не трогается, пока ей не исполнится час —
свежая может принадлежать ещё не завершённой чужой транзакции.
- **Статическая карточка пространства снова показывает подложку (R3-2).** Она
подписывала URL и выбрасывала результат — `getCardSize()` правил временную
модель, а `render()` строил свою заново из конфига, — поэтому `<image>`
запрашивал защищённый путь и на каждом рендере получал 401. Обе карточки
теперь используют один подписыватель, и статическая заодно получила батчи,
учёт срока годности и периодическое переподписывание, которые были только у
основной. Её множество ожидающих запросов освобождается в `finally`, так что
одна неудача больше не блокирует ссылку до конца жизни страницы.
- Новые тесты: пять backend-сценариев чередования двух клиентов из отчёта
(поздний коммит, незакоммиченная загрузка, устаревший сирота, чужие файлы,
отвергнутое сохранение), сборщик вынесен в чистый модуль и покрыт юнит-
тестами, плюс `smoke_space_card_bg` на подписанный фон — он падает на
v1.45.0, где в DOM попадает сырой URL.
## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3)
- **Отвергнутое сохранение больше не может испортить рабочий план (R2-1,
high).** Файл плана записывался под финальным именем — попутно удаляя вариант
с другим расширением — *до* проверки ревизии конфига. Если запись потом
отвергалась (конфликт ревизий, валидация, обрыв связи), живой план оказывался
уже подменён, а сохранённый конфиг мог ссылаться на удалённый файл. Теперь
загрузка идёт в версионированное имя (`<space>.<токен>.<ext>`) и ничего не
удаляется; карточка просит бэкенд убрать устаревшие файлы только после
принятой записи конфига. Падение между шагами оставляет один осиротевший
файл, который подберёт следующая успешная загрузка.
- **Подписанные ссылки больше не протухают навсегда на долгоживущих экранах
(R2-2).** Бэкенд подписывает не более 200 путей за запрос и молча отбрасывает
остальные, а карточка отправляла весь кэш одним вызовом и считала любую
запись годной вечно. Начиная с 201-го вложения поздние ссылки переставали
обновляться и через 24 часа тихо ломались. Теперь запросы бьются на батчи по
общему лимиту, записи помнят свой возраст (стареющая ссылка работает, пока
едет замена, протухшая не отдаётся вовсе), а кэш чистится до ссылок, на
которые конфиг всё ещё ссылается.
- **Климат комнат считается один раз на обновление, а не на каждую комнату
(R2-3).** Каждая комната запрашивала температуру и влажность по отдельности,
и каждый запрос заново обходил весь реестр сущностей: 60 комнат и 2000
сущностей — это ~120 обходов на рендер, целый кадр на метаданные, которые не
менялись. Теперь один проход строит карту по всем зонам с привязкой к снимку
Home Assistant: свежие состояния видны всегда, а посторонние перерисовки не
стоят ничего. Замер в смоуке: 133 обхода реестра на обновление до, 2 после —
и число больше не растёт с числом комнат.
- `smoke_ux_fixes` писал скриншот по жёстко зашитому пути `/tmp` и не запускался
на Windows — теперь берёт временную папку у ОС.
- Новые тесты: `smoke_plan_upload_reject` (чистка только после принятого
сохранения), `smoke_sign_cap` (201 ссылка, батчи, чистка, срок годности),
`smoke_climate_once` (число обходов не растёт с числом комнат), плюс
backend-покрытие версионированных имён и юнит-тесты новых хелперов.
## v1.44.8 — 2026-07-27
- **Загруженная подложка действительно привязывается к пространству.**
`_saveSpaceDialog` держал ссылку на объект пространства через `await`
загрузки картинки. Любое событие `houseplan_config_updated` запускает
`_reloadConfigOnly()`, а оно *заменяет* `_serverCfg` — ссылка становилась
осиротевшей, и `plan_url`, `aspect`, заголовок и все настройки отображения
писались в отсоединённый объект, тогда как на сервер уходил нетронутый
конфиг. Файл попадал на диск, подложка не появлялась, пересохранение не
помогало. Создание пространства в этот момент теряло пространство целиком.
Теперь загрузка идёт *до* обращения к конфигу, и ни одна ссылка не живёт
через await.
- **`_saveConfigNow` помечает запись как выполняющуюся** (`_cfgWriting`) — так
же, как отложенный писатель, — поэтому чужая ревизия, пришедшая посреди
сохранения, откладывает перечитывание вместо подмены конфига (аудит L2,
расширен на этот путь).
- Регрессионный тест `demo/smoke_plan_upload_race.mjs` падает на v1.44.7 и
проходит здесь.
## v1.44.7 — 2026-07-27
- **Подложки снова отображаются (регрессия с v1.44.5).** Эндпоинт с файлами
требует авторизации, поэтому карточка просит бэкенд подписать ссылку на план —
но подпись подставлялась внутри *мемоизированной* модели пространства, а она
кэшируется по отпечатку конфига. Неподписанная ссылка «замерзала» в кэше,
подпись до элемента `<image>` не доезжала, и план не грузился никогда. Теперь
ссылка вычисляется в момент отрисовки, вне кэша. (Ссылки на PDF не страдали —
там она и так вычислялась при отрисовке.)
- **Больше нет «неудачной попытки входа» с собственного IP.** Пока подложка была
сломана, браузер продолжал дёргать неподписанный путь, тот отвечал 401, и
Home Assistant поднимал предупреждение о неудачном входе с адреса самого
зрителя. Теперь до получения подписи не рисуется ничего, и неподписанный
запрос не уходит вовсе.
- **Долгоживущие экраны не моргают.** Переподписывание раз в 12 часов раньше
сбрасывало все подписи и ждало новые; теперь текущие ссылки держатся до
прихода замены, так что настенный планшет не показывает пустой план.
- Регрессионный тест `demo/smoke_plan_signed.mjs` падает на v1.44.6 и проходит
здесь.
## v1.44.6 — 2026-07-27
- **Климатом комнаты считается только температура *воздуха*.** После v1.44.5,
когда данные стали браться из реестра зон, а не с видимых значков,
кандидатами стали все скрытые датчики температуры в зоне — в том числе те,
что меряют вовсе не воздух. Перед усреднением теперь работают три фильтра:
пропускаются сущности с категорией диагностика/настройка, сущности
исключённых интеграций и сущности, в id которых назван не-воздушный носитель
(`water`, `coolant`, `flow_temp`, `return_temp`, `target`, `setpoint`, `chip`,
`cpu`, `processor`, `board`, `device_temp`, `batter`, `freezer`, `fridge`,
`oven`, `kettle`, `boiler`).
На живой установке с 60 зонами это убрало четыре реальных ложных
срабатывания: температуру процессора NAS, воду в умном чайнике, сауну с 90 °C
и виртуальный `better_thermostat`, дублирующий настоящий датчик.
- **Новые правила иконок:** чайники и термопоты получают `mdi:kettle`, сауны
(`sauna`, `harvia`, `парная`) — `mdi:hot-tub`. Раньше и те и другие попадали
под общее правило термометра, из-за чего и учитывались в климате комнаты.
## v1.44.5 — 2026-07-27
- **Климат комнаты считается по всем датчикам зоны**, включая устройства,
которых нет на плане (скрыты курированием или вами). Раньше среднее бралось
только по видимым значкам, поэтому скрытый термометр молча выпадал из
карточки комнаты, подсказки и температурной заливки. Курирование сохранено
(холодильники, термоголовки и розетки с температурой чипа не считаются), а
явно выбранный источник в настройках комнаты по-прежнему главнее.
- Из подсказки к комнате убрана фраза «открыть зону» — клик по комнате перестал
никуда вести ещё в v1.40.1, для перехода есть значок-ссылка у названия.
## v1.44.4 — 2026-07-27 (доработка по аудиту: B2, B5, L4)
- **Единая политика авторизации (B2).** HTTP-загрузка по-прежнему **разрешала**
запись, когда запись о конфигурации недоступна, тогда как WebSocket-путь уже
отказывал — они разошлись. Теперь оба вызывают общий помощник `may_write`,
который в неопределённой ситуации пропускает только администраторов.
- **NaN/Infinity отвергаются во всех координатах (B5).** Проверка на конечность
числа стояла только у позиций раскладки; прямоугольники комнат, вершины
полигонов, `view_box` и координаты проёмов принимали `"NaN"`, который при
записи превращается в `null` и необратимо портит геометрию. Ограничение
`MAX_OPENINGS` было объявлено, но нигде не использовалось — список проёмов
оставался безразмерным.
- **Укрепление перетаскивания (часть L4).** Безопасная обёртка над
`setPointerCapture` теперь используется во всех сценариях перетаскивания
(устройства, подписи, изменение размера), а не только у проёмов — «мёртвый»
идентификатор указателя мог оборвать перетаскивание. Фигуры декора получили
ограничение по границам: их больше нельзя утащить далеко за пределы плана и
сохранить там.
## v1.44.3 — 2026-07-27 (исправление: планы и инструкции снова загружаются)
- **У аутентифицированной выдачи контента не было рабочего пути для браузера.**
Версия v1.43.0 закрыла дыру правильно, но Home Assistant аутентифицирует
HTTP-запросы либо заголовком Bearer, либо подписанным путём `authSig` — а
`<image href>` внутри SVG и обычная ссылка `<a href>` не отправляют ни того,
ни другого. На настоящем дашборде фоны планов и ссылки на PDF отдавали
**401** (воспроизведено вживую до исправления). Теперь карточка просит бэкенд
подписать то, что собирается показать (`houseplan/content/sign`, 24 часа,
привязано к токену сессии, только для нашего эндпоинта), перерисовывается,
когда подписи приходят, и обновляет их каждые 12 часов, чтобы настенные
планшеты продолжали работать. Тест бэкенда скачивает подписанный адрес **без**
заголовка авторизации и проверяет 200, а без подписи — 401.
## v1.44.2 — 2026-07-27 (внешнее код-ревью: CR-1…CR-3)
Второе, состязательное ревью (версии v1.44.0) дало три находки — все закрыты.
- **Правило про замки теперь сформулировано точно и проверяется (CR-1).**
Рецензент справедливо отметил, что утверждение «замок нельзя открыть с плана»
было слишком абсолютным: кнопка в карточке двери действительно вызывает
сервис. Эта кнопка — осознанное продуктовое решение, поэтому правило
переписано там, где ему место («никогда случайным нажатием; ровно одна
подписанная поверхность»), отпирание теперь **спрашивает подтверждение**, а
новый смок-тест проверяет все пять путей управления и доказывает, что значки,
`controls[]` и карточка устройства по-прежнему отказывают замкам.
- **Перенос вложений стал транзакционным (CR-2).** При смене привязки маркера
файлы раньше ПЕРЕМЕЩАЛИСЬ до сохранения конфига с проверкой ревизии: если
сохранение отклонялось, на сервере оставались старые ссылки, а файлы уже
уехали. Теперь сервер копирует, конфиг фиксируется, и только после этого
старая папка удаляется (`houseplan/files/cleanup`).
- **Неудачный или частичный перенос больше не переписывает ссылки (CR-3).**
Копирование возвращает точное соответствие «исходное имя → записанное»;
переписываются только подтверждённые копии, при совпадении имён файл получает
уникальное имя вместо молчаливой ссылки на чужой файл, а ошибка переноса
показывается тостом.
## v1.44.1 — 2026-07-27
- Ссылка на чат сообщества добавлена везде, где её ищут:
**https://t.me/ha_houseplan** (бейдж и строка в шапке обоих README, раздел
«Помощь и обмен опытом», контакт-ссылки в шаблонах issue, CONTRIBUTING,
STATUS и SCOPE).
## v1.44.0 — 2026-07-27 (отзыв пользователя: сначала управление)
- **Карточка устройства стала поверхностью управления.** Она открывается со
списка управляемых сущностей: лампы, розетки и вентиляторы переключаются
прямо здесь кнопками под палец, шторы, замки и климат передают управление в
штатный more-info Home Assistant. Модель, ссылки и PDF-инструкции ушли ниже —
на настенном планшете эта карточка нужна для управления домом, а не для
чтения документации (из полевого отзыва). Служебные (config/diagnostic)
сущности в списке не показываются, замки по-прежнему не переключаются
нажатием в карточке.
- **«Это устройство — источник света»** — новый флаг у устройства. Умный
выключатель с обычными (не умными) светильниками теперь даёт ореол в заливке
«Свет по источникам» без создания хелпера-группы: свечение следует за самим
выключателем либо за лампами, привязанными в «Управляет источниками света».
## v1.43.3 — 2026-07-27 (отзыв пользователя: обнаруживаемость и тач)
- **Настройки комнаты невозможно было найти.** Шестерёнка из v1.42.0 жила
внутри подписи комнаты размером 0.9em от её шрифта и с прозрачностью 60% —
несколько бледных пикселей на обычном плане. Теперь это кнопка-пилюля
«⚙ Комната» фиксированного читаемого размера, не зависящая от масштаба
карточки, и она появляется **даже у комнат без имени** (их там и называют).
Заодно разблокировались слайдеры размеров шрифта, до которых никто не мог
добраться.
- **Строка показателей увеличена** с 0.62 до 0.75 от размера названия — автор
отзыва мог увеличить название, но строка датчиков оставалась нечитаемой на
планшете. Множители комнаты и пространства работают поверх.
- **Тултипы на тач-устройствах, вторая попытка.** Проверки `(hover: none)`
оказалось мало: некоторые устройства, оболочки, стилусы и подключённые мыши
сообщают `hover: hover`, и подсказки продолжали висеть под пальцем. Теперь
карточка запоминает первое же касание (touch/pen) и гасит открытую подсказку.
## v1.43.2 — 2026-07-27 (внешний аудит: слой тестов)
- **Смок-тесты наконец умеют падать (T1).** Все 48 браузерных смоков печатали
булевы значения и всегда завершались с кодом 0 — регрессия была видна в их
собственном выводе, а прогон считался успешным. `demo/serve.mjs` теперь
экспортирует `check`/`checkAll`/`finish`: каждый факт проверяется по имени,
несовпадения и необработанные исключения внутри карточки дают ненулевой код
возврата. Проверено намеренной поломкой блокировки редакторов в киоске —
соответствующий смок покраснел.
- **Набор гоняется в CI (T2)** отдельной джобой `smoke` после `frontend`,
против свежесобранного бандла (закоммиченная копия в `demo/srv/assets` —
снимок, на нём легко получить «зелёный» отчёт о несуществующем коде), с
выгрузкой логов при падении.
- **`docs/TESTING.md` приведён в соответствие (T3).** `[auto]` теперь означает
«существует именованная проверка, которая падает», и рядом написано, где она;
72 пункта, где автоматизация была намерением, честно помечены `[manual]`.
Исправлены два давних противоречия: строка про «ноль кнопок редактирования в
Просмотре» (неверна с v1.30.1) и строка про клик по проёму (снова верна
с v1.43.1).
- Три смока проверяли поведение, которого уже нет (ожидания времён v1.39.0 и
v1.25); теперь они тестируют текущий контракт.
## v1.43.1 — 2026-07-27 (внешний аудит: исправления P1)
- **Стоимость отрисовки (L1).** Home Assistant подменяет объект `hass` при
любом изменении состояния в доме, и каждая такая отрисовка пересчитывала всю
геометрию плана — `_openPairs()` вызывался по разу на комнату (кубическая
математика коллинеарных наложений), модель пространства строилась дважды.
Теперь и то, и другое мемоизируется по структурному отпечатку конфига и
вынесено из цикла по комнатам; сброс кэша происходит синхронно в момент
мутации, а не внутри дебаунса.
- **Тап против перетаскивания у проёмов (L4).** У перетаскивания двери или окна
не было порога движения, поэтому любое дрожание пальца считалось
перетаскиванием: диалог свойств не открывался, а в конфиг писалось
неизменённое состояние (что подпитывало гонку L2). Теперь порог 3 px, как во
всех остальных сценариях перетаскивания, и запись только при реальном
изменении геометрии.
- **Вогнутые комнаты (G2).** Вложенность определялась через среднее арифметическое
вершин — а оно лежит СНАРУЖИ U- и L-образных комнат, поэтому комнаты-острова
в них отвергались как пересечение, а дырка в заливке не рисовалась. Теперь
вычисляется настоящая внутренняя точка (`interiorPoint`).
- **Дедупликация стен (G3).** `segKey` сортировал концы по сырым float, а
печатал округлённые, поэтому одна общая стена могла дать два ключа и
рисовалась дважды. Сначала округление, потом сортировка.
- **Укрепление бэкенда (B2–B5).** Проверка прав на запись теперь **отказывает**,
когда запись о конфигурации недоступна (раньше во время перезагрузки
интеграции запись разрешалась); `layout/set` поддерживает `expected_rev` и
сообщает о конфликте так же, как хранилище конфига; `config/set` без
`expected_rev` поверх непустого хранилища пишет предупреждение в лог;
координаты отвергают NaN/Infinity, а у пространств, комнат, маркеров, декора
и раскладки появились щедрые ограничения размера.
## v1.43.0 — 2026-07-27 (внешний аудит: исправления P0)
Внешний аудит кода версии v1.41.1 нашёл четыре критические проблемы. Все четыре
исправлены и покрыты регрессионными тестами.
- **Молчаливая потеря правок при сохранении (L2).** Отложенная запись конфига
читала его в момент срабатывания, поэтому пришедшее в промежутке событие
`houseplan_config_updated` подменяло конфиг, и правка пользователя исчезала
без единой ошибки — воспроизводилось даже в одной вкладке. Теперь дебаунс
умеет `flush()`/`pending()`, перезагрузка сперва дописывает отложенную
запись и откладывается, пока запись в полёте, а неудачная перезагрузка
наконец сообщает о себе вместо молчания.
- **Разрез разрушал геометрию комнаты (G1).** Разрез, начинающийся и
заканчивающийся на ОДНОЙ стене (вырезание ниши — совершенно естественное
действие), давал две самопересекающиеся комнаты, суммарная площадь которых
вдвое превышала исходную, и проверка пересечений это не ловила. Теперь такие
разрезы корректно вырезают нишу, а инвариант разбиения (части в сумме дают
исходную площадь) отклоняет всё остальное.
- **Планы и загруженные файлы отдавались без авторизации (B1).** Любой, кто мог
достучаться до вашего Home Assistant, скачивал планы этажей и вложенные
инструкции без входа в систему. Теперь их отдаёт аутентифицированный
обработчик; сохранённые старые адреса переписываются на чтении, так что
ничего не ломается. **Старые публичные пути исчезают только после
перезапуска Home Assistant.**
- **Диалоги могли воскреснуть и обнулить карточку (L3).** Закрытие диалога во
время неудачного сохранения превращало его состояние в пустую «оболочку»,
отрисовщик падал, и карточка оставалась пустой до перезагрузки страницы.
Защита добавлена во все четыре процедуры сохранения, тост об ошибке
по-прежнему показывается.
## v1.42.2 — 2026-07-26
- На тач-устройствах подсказки при наведении больше не выскакивают при каждом
касании (из отзыва: «на планшете при тапе вылезают доп. надписи — мешают»).
Подсказки теперь только для мыши; на тач та же информация есть в карточках
комнат и в карточке устройства по долгому нажатию.
## v1.42.1 — 2026-07-26 (размеры шрифтов карточек комнат)
- Закрываем отзыв «нельзя настроить размер шрифта»: **три слайдера**. В
настройках пространства появился базовый размер шрифта карточек комнат для
всего пространства; в настройках комнаты — независимые размеры **названия** и
**строки показателей** (50–300% каждый). Эффекты перемножаются и складываются
с растягиванием карточки за уголки и множителем экрана в киоск-режиме.
- В обоих диалогах показывается **живой пример карточки**, который меняется
прямо во время перетаскивания слайдеров.
## v1.42.0 — 2026-07-26 (настройки комнаты — третий уровень)
- **У настроек теперь четыре уровня**: общие → пространство → комната →
устройство; более конкретный уровень переопределяет более общий (решение
владельца, зафиксировано в ARCHITECTURE). В этом релизе добавлен уровень
КОМНАТЫ.
- У каждой карточки комнаты в редакторе плана появилась **шестерёнка**:
переименовать комнату, сменить её зону HA, переопределить **тип заливки**
только для этой комнаты (работает и в glow-пространствах — «без заливки»
выводит комнату из темноты) и выбрать явный **источник температуры и
влажности** — любое устройство или сущность HA вместо среднего по комнате.
Источник питает карточку комнаты, всплывающую подсказку и температурную
заливку и работает даже у комнат без зоны HA (случай из отзыва: собственный
template-сенсор, привязанный к помещению).
- Тот же раздел настроек появляется в диалоге комнаты сразу после замыкания
контура.
+31 -2
View File
@@ -51,9 +51,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
- SSH: port **323**, root, key `ha_jb` (the user uploads it to the chat; in the sandbox /tmp/ha_jb, chmod 600).
- JS: `scp -P 323 -i /tmp/ha_jb dist/houseplan-card.js root@ha.jbstudio.pro:/config/custom_components/houseplan/frontend/`
- **The `frontend/` subfolder is not optional.** `__init__.py` registers
`Path(__file__).parent / "frontend" / "houseplan-card.js"` as the static path.
A copy dropped next to `__init__.py` (…/houseplan/houseplan-card.js) is served
by nobody: md5 on the server matches, the browser still gets the old bundle,
and hours go into debugging a bug that was already fixed. Cost this mistake
once: 2026-07-27, two releases deployed into the void.
- The whole integration: tar c custom_components/houseplan (--exclude __pycache__) → tar x on the server.
- **Verification is mandatory**: `md5sum` locally == on the server == `curl http://homeassistant:8123/houseplan_files/houseplan-card.js | md5sum`
(inside the SSH add-on `localhost` is NOT HA, use the host `homeassistant`).
- **Verification is mandatory, and it must go over HTTP** — comparing md5 against
the file you just copied proves nothing about what the browser receives. The
one check that counts:
`curl -s https://ha.jbstudio.pro/houseplan_files/houseplan-card.js | grep -o '1\.[0-9]*\.[0-9]*' | sort -u`
must print the version just built. (Inside the SSH add-on `localhost` is NOT
HA — use the host `homeassistant`.)
- Python changes require an HA restart (`ha core restart`, holds the connection until it finishes, HTTP
comes back up in 1–3 min). JS changes — just a page refresh (the static path is served
with no-cache).
@@ -96,3 +106,22 @@ Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` b
- The houseplan integration: entry loaded, `.storage/houseplan.layout` — the layout (server-side).
- The old prototype `/config/www/houseplan/` (iframe) is kept as a fallback, do not touch.
- configuration.yaml backups: `.bak-avgtemp` (before the average-temperature sensor edit).
## Smoke tests (since 2026-07-27)
Every `demo/smoke_*.mjs` ends with:
```js
checkAll(out); // every key must be true...
checkAll(out, { n: 4 }); // ...unless an expected value is given
await finish(browser, out);
```
`finish` prints the JSON dump (useful on failure), reports named mismatches and
sets a non-zero exit code — including when the card threw during the run. The
suite runs in CI (`smoke` job) against a freshly built bundle; never test the
committed `demo/srv/assets/houseplan-card.js` snapshot.
When adding a checklist line marked `[auto: ...]` in docs/TESTING.md, add the
failing check in the same commit — that is what the marker now promises.
+7
View File
@@ -74,6 +74,13 @@ Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt
- [ ] GitHub: issue templates, discussions on, CONTRIBUTING.md (build/test in 5 minutes).
- [ ] Semantic versioning discipline; keep release notes user-facing (they show in HACS).
## Phase 11 — UX modes redesign ✅ DONE (v1.25.0–v1.28.0, see docs/UX-MODES.md)
Shipped: the three-tab interaction model (View / Plan / Devices, v1.25.0),
state-reflecting icons + value display (v1.26.0), RGB light colors + alarm pulse
(v1.27.0), sub-area rooms (v1.28.0). Remaining backlog from issue #3: music
notes for players, directional TV ripples (not planned).
## Explicit non-goals (for now)
- 3D plans (.glb) — separate market (3Dash), heavy dependencies.
Executable
+111
View File
@@ -0,0 +1,111 @@
# Product scope — what House Plan is and is not
*Fixed with the owner on 2026-07-22. This document is a guard rail: features are
built, improved and accepted **only** if they serve a job listed here. When a new
idea appears, first find its row in this file; if there is none — it belongs to
HA core, to another card, or nowhere. Companion documents: PRODUCT.md (market),
ROADMAP.md (order of work), UX-MODES.md (interaction model).*
## Mission
House Plan is the **spatial "at a glance + quick act" layer** for a Home
Assistant home. Upload or draw a floor plan, outline rooms bound to HA areas,
and the home's devices appear on a live, tappable map: states, climate, alerts,
guarded quick actions. Setup is GUI-only — no Inkscape, no YAML, no external
editors. Everything that is not "look at the home spatially and act on the
obvious" is somebody else's job.
## Target audience
| Persona | Role | Surface |
|---|---|---|
| **Home admin** (primary) | HA enthusiast, house/large flat, 20–200 devices, several floors; sets up and maintains the plan | Desktop browser (both editors live here) |
| **Household members** | Non-technical; consume the plan daily, never edit | Wall tablet (kiosk), phone (companion app) |
| **Guests / kiosk** | View-only glance at the home | Wall tablet |
Design consequence: **View mode is the product** for two of the three personas.
Editors are admin-only tools and must never leak interactions into View
(established by UX-MODES; lock guard, inert openings, no drag in View).
## Core user jobs — the component must close these
| # | Job (user's words) | Status |
|---|---|---|
| J1 | "Show the whole home and what's happening right now" — live spatial overview: device states, room fills (light/temp/LQI), values, multi-floor tabs | **Closed** |
| J2 | "Something is wrong — show me *where*" — leak/smoke/gas pulse, open doors/windows, unlocked locks, red dot on devices HA added silently | **Closed** |
| J3 | "Let me act on the obvious right from the plan" — tap-to-toggle for safe domains, info cards, guarded lock action | **Closed** |
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
| J5 | "Room climate at a glance" — per-room temperature/humidity, comfort-range fills, room-card metrics | **Closed** |
| J6 | "Keep the plan true as the home evolves" — new-device flag, two editors, drag/resize, merge/split, multi-client live sync, optimistic locking | **Closed** |
| J7 | "Is my Zigbee mesh healthy *here*?" — LQI badges, per-room average, LQI fill | **Closed** (kept deliberately: cheap, spatial by nature, no in-plan competitor) |
## Partially covered — improvement backlog stays inside these
- **Touch ergonomics of the editors**: corner handles and grid clicks are small on
tablets; editors are desktop-first today. Improve, don't redesign.
- **Value display**: single current value per device; units/precision follow HA
formatting only.
- **Accessibility**: `prefers-reduced-motion` only; no keyboard navigation in
editors, no ARIA labelling of the plan.
- **Docs/screenshots**: README predates the two-editor redesign.
## Known gaps that fit the mission (build only on owner's request)
- Person/presence shown in rooms (classic floorplan ask; pure J1).
### The lock invariant, stated precisely (review CR-1)
No lock or alarm panel is ever actuated **by a tap on the plan**: icons, lock
badges, `marker.controls[]` and the device card all refuse (`resolveTapAction`
+ `TOGGLE_FORBIDDEN_DOMAINS`, `isControllable`, `_cardToggle`). There is exactly
**one** sanctioned actuation surface: the labeled Unlock/Lock button inside an
opened door card, which additionally confirms before unlocking. That is a
product decision (2026-07-22), not an oversight — but it means the invariant is
"never by accident", not "never at all". Any new actuation path must either
refuse locks or be added to this paragraph.
- Plan-level "security glance": one badge for "all locked / N open" (J2).
- Threshold colouring for room-card metrics (J5).
## Out of scope — never build, point users to the right tool
- Automations, scenes, scripts, notifications → HA core.
- Device/entity administration (rename, reassign area, disable) → HA registry
UIs. We *read* the registry, we never manage it.
- History, graphs, statistics → recorder/history cards. We show now, not then.
- Camera streams, media controls → their own cards; our more-info opens HA's.
- Energy monitoring/analytics → HA Energy.
- 3D / isometric / photorealistic rendering, furniture drawing → niche tools
(easy-floorplan et al.); we are a schematic live map, not an interior editor.
- A general dashboard framework (menus, popups, theming engine) → Bubble Card,
Dwains and friends.
## Excess-functionality audit (2026-07-22)
- **Device metadata: links & PDF manuals** — not spatial, but tiny, server-side,
and used in real installs. Verdict: **keep, frozen** (no growth).
- **Virtual devices** — placeholders for not-yet-installed hardware; serves J6.
Verdict: **keep, frozen**.
- **LQI diagnostics** — promoted to J7 (differentiator), not excess.
- Nothing found that warrants removal; the mode redesign already moved every
interaction to where it belongs.
## The component in one breath (README/HACS copy)
> **House Plan** turns your Home Assistant into a live map of your home. Upload
> a plan image (or draw one), outline rooms and bind them to HA areas — your
> devices appear in place, automatically, with live states. Glance at the wall
> tablet: what's on, what's open, what's too cold, what's leaking, what's new.
> Tap to act — safely: locks never toggle by accident. Two built-in editors
> (plan and devices) mean no Inkscape, no YAML, no external tools — ever.
**Tasks it closes:** whole-home live overview · spatial alerts (leak/smoke/open/
unlocked/new device) · safe quick actions · per-room climate · Zigbee mesh
health · zero-to-plan GUI onboarding · keeping the plan true over years.
**Where users are:** Telegram chat https://t.me/ha_houseplan (support, feature
signals, screenshots) — treat it as the primary source of field feedback.
**Pains it removes:** hand-crafted SVG + YAML floorplans · entity-list
dashboards that hide *where* things happen · silent device sprawl · accidental
toggles of security devices · per-device dashboards that non-technical family
members can't read.
+43 -10
View File
@@ -5,22 +5,49 @@
> state, where everything lives, and how to continue safely.
>
> **Documentation policy (mandatory):** every change is documented *in the same
> commit* — CHANGELOG entry for anything user-visible, STATUS.md for state changes
> commit* — a CHANGELOG entry for anything user-visible **in BOTH
> `docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian, since
> v1.42.0 — the user base is largely Russian-speaking, see the Telegram chat)**, STATUS.md for state changes
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-07-17)
## Snapshot (2026-07-27)
| Item | State |
|---|---|
| Version | **v1.23.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
| Brands | Ships **inside the integration**: `custom_components/houseplan/brand/{icon,icon@2x,logo,logo@2x}.png` (HA ≥2026.3 local-brands mechanism). home-assistant/brands PR #10700 was auto-closed — that repo no longer accepts custom integrations |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.23.1**, installed *via HACS* (custom repo). The user has real rooms, 12 openings (doors/windows with TTLock locks and contact sensors) and live fills configured |
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
| Tests | 77 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
| Version | **v1.45.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.1** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
| Product scope | docs/SCOPE.md (2026-07-22) is the feature guard rail — check before accepting any feature |
## Current feature surface (since the 2026-07-17 snapshot)
- **Three editors + View**: Plan / Devices / Background (decor layer v1.33) as
tabs with an X to close; View is the default; nav (space+mode) persists
across reloads (v1.38.2). **Kiosk mode** (v1.41.0): `kiosk: true` — no
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
carousel, per-screen size multipliers in localStorage.
- **Glow fill** (v1.35–v1.37): dark house + per-source light pools (rgb/color
temp/default; per-source radius), door sectors, open boundaries
(`room.open_to`, virtual walls, dashed, transitive light zones).
- **Real switches** (v1.36): `marker.controls[]` group-toggle with HA-group
semantics; icon mirrors targets; hidden grouped lamps fixed (tiered
primaryEntity). **Lights toggle by default** (v1.39): primary domain light
→ tap toggles, kettle-style devices keep the card default.
- **Plan geometry**: polyline split (v1.32), island rooms w/ evenodd holes
(v1.34), smart guides + 45° angle badge (v1.40), opening hover preview.
- **Rooms**: room cards with metrics (temp/hum/lqi/light "1 of 3") and
proportional resize (v1.31); link icon to the HA area (v1.40.1, room taps
removed). **New-device red dot** (v1.29), lock action button (v1.30).
- **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).
## Recent milestones (details in CHANGELOG.md)
@@ -129,3 +156,9 @@
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).
## Product scope
docs/SCOPE.md (fixed 2026-07-22) is the guard rail for all feature work: mission,
personas, jobs J1–J7, partial/out-of-scope lists, excess audit. Check it before
accepting or proposing any feature.
+389 -46
View File
@@ -3,9 +3,76 @@
> **Policy:** this checklist is updated **in the same commit** as any functional
> change (like CHANGELOG.md). Every release: run at least the smoke column on the
> synthetic demo (`demo/`), and the full list before major releases. Items marked
> `[auto]` are covered by unit tests or the headless smokes in `demo/` — they still
> `[manual]` are covered by unit tests or the headless smokes in `demo/` — they still
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
> **What `[manual]` means (since 2026-07-27).** A named check exists that FAILS
> when the behaviour breaks — in `npm test` or in the smoke suite, both of which
> run in CI on every push. Where the check lives is written next to the marker
> (`[auto: smoke_modes]`). Before this date the marker described an intention:
> the smoke suite printed values and always exited 0, so 96 markers guarded
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
> add the failing check in the same commit.
- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named
facts via `check`/`checkAll` and exits non-zero on any mismatch or
uncaught in-card exception; the suite runs in CI against a FRESHLY built
bundle. Sanity ritual: break one invariant on purpose (e.g. remove the
kiosk editor guard) and confirm the matching smoke goes red [auto: CI job "smoke"]
- [ ] Room gear discoverability (v1.43.3, user feedback): in the Plan editor
every room card carries a pill button "⚙ Room" of a FIXED readable size
(independent of the card font) — including rooms without a name; it opens
Room settings [auto: smoke_feedback_v2]
- [ ] Metrics readability (v1.43.3): the metrics line is 0.75 of the room name
(was 0.62 — unreadable on tablets); per-room sliders still apply on top [auto: smoke_feedback_v2]
- [ ] Touch tooltips, take two (v1.43.3): a hover tooltip never appears after
ANY touch/pen pointer event, even if the browser claims `hover: hover`
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
fixtures glows in the "Light sources" fill once "This device is a light
source" is ticked (its own entity or the lights bound under "Controls");
unticked devices without a light entity never glow [auto: smoke_glow]
- [ ] Device card controls (v1.44.0): the device card opens with its
controllable entities FIRST — toggles right there (≥30 px tap targets),
cover/lock/climate open HA more-info; model, links and manuals moved
below; config/diagnostic entities are not listed; locks never toggle from
the card [auto: smoke_card_controls]
- [ ] Lock invariant, all paths (v1.44.2, review CR-1): icon tap, controls[],
device card and _cardToggle refuse locks/alarm panels entirely; the door
card's Unlock asks for confirmation, Lock does not [auto: smoke_lock_invariant]
- [ ] Attachment migration is transactional (v1.44.2, review CR-2/CR-3):
rebinding COPIES files, saves the config, and only then deletes the old
folder; a rejected save leaves the old files and urls intact; a name
collision in the destination gets a unique name (the pre-existing file is
never silently linked); urls are rewritten only for confirmed copies
[auto: unit logic.test + tests_backend]
- [ ] Plans and PDFs load in a real browser (v1.44.3, B1 regression): open a
dashboard with an uploaded plan — the background renders and a manual link
opens; DevTools shows /api/houseplan/content/... returning 200 via a
signed url, while the same url without authSig returns 401
[auto: tests_backend + manual]
- [ ] Auth policy is single-sourced (v1.44.4, B2): the HTTP upload and every WS
write use the same `may_write`, which denies non-admins when the config
entry is unavailable [auto: tests_backend]
- [ ] Coordinates and caps (v1.44.4, B5): NaN/Infinity are refused on room
rects, polygon vertices, view_box and openings — not only in layout; the
openings list honours MAX_OPENINGS [auto: tests_backend]
- [ ] Drag hardening (v1.44.4, L4 sub-item): every drag pipeline captures the
pointer through the tolerant helper; decor shapes cannot be dragged more
than a quarter of the plan outside the viewBox [auto: smoke_decor]
- [ ] Room climate counts hidden sensors (v1.44.5): a thermometer that is NOT
placed on the plan (hidden by curation or by the user) still feeds the
room card, the tooltip and the temperature fill; fridges/TRVs still do
not; an explicit per-room source still wins [auto: unit devices.test]
- [ ] Room tooltip wording (v1.44.5): hovering a room shows its name (plus
temperature/signal when available) and no longer claims "open the area" —
room clicks were removed in v1.40.1 [manual]
## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
@@ -23,17 +90,42 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Installation / upgrade / removal
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
- [ ] `single_config_entry`: adding a second entry is impossible [auto]
- [ ] `single_config_entry`: adding a second entry is impossible [manual]
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [manual]
## Modes (v1.25.0) ★
- [ ] The card always loads in **View**; edit modes are never restored [manual]
- [ ] View: pan/zoom/space-switch/tap/long-press/tooltips only — dragging an icon,
label or opening does nothing; panning may start on top of an icon [manual]
- [ ] View header: space tabs + count + zoom + editor tabs, the general-settings
cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3
for users who may edit); no editor toolbars [auto: smoke_modes]
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
labels/openings draggable; orange stage frame [manual]
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
buttons; accent stage frame [manual]
- [ ] Mode tabs hidden for non-admin users; segmented control highlights the active mode
- [ ] Openings in View (v1.28.1): the door/window itself is a pure drawing — no
cursor change, no hover outline, no hit target, no click, regardless of
bindings [manual]
- [ ] The LOCK BADGE is the one exception: when a lock is bound it is shown and
clickable in View (pointer cursor, click → door/lock info card); inert in
Plan so it does not fight editing [manual]
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
Devices mode [manual]
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
walls, click (any tool) opens its properties — with a 3 px drag threshold
since v1.43.1, so a tap is never swallowed [auto: smoke_inert_openings]
## Onboarding ★
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [manual]
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [auto]
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [manual]
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
@@ -42,23 +134,30 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [manual]; borders+names default ON [manual]
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [manual]
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [manual]
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [auto]
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [auto]; toggling a light from the plan recolors the room
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto]
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [manual]
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [manual]
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [manual]; toggling a light from the plan recolors the room
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [manual]; comfort bounds editable inline on the radio row (swapped bounds tolerated [manual], clearing a field cannot zero a bound [manual]); rooms without a temperature reading stay unfilled [manual]
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto]
- [ ] Room tooltip shows the average room temperature when any thermometer reports [manual]
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [manual]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [manual]
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [manual];
Reset restores defaults; saving defaults stores nothing [manual]
- [ ] Custom fill colors apply to the full card AND the static space-card
- [ ] LQI gradient interpolates between the configured weak/strong colors [manual]
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
devices and the signal line in room tooltips for that space only [manual]
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [manual]
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [manual]
- [ ] Room hover highlight still works when custom borders/fills are on
- [ ] Settings persist across reload and other browsers (server-side)
@@ -81,57 +180,301 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
(imported/legacy polygons), not only on rooms drawn on the current grid [auto]
(imported/legacy polygons), not only on rooms drawn on the current grid [manual]
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
the wall-snap pull is capped, accidental clicks do not pick a wall [auto]
the wall-snap pull is capped, accidental clicks do not pick a wall [manual]
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
- [ ] "No area" room (decorative) requires a name; saves with `area: null`
- [ ] Cancel in the dialog reopens the contour (last point undone)
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [auto]
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [manual]
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
- [ ] Device icons hidden during markup; visible again on exit
## Devices on the plan ★
- [ ] Auto devices appear only in rooms bound to their area [auto]
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [auto]
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [auto]
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [auto]
- [ ] Auto devices appear only in rooms bound to their area [manual]
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
- [ ] ↺ reset restores auto layout after confirm
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
- [ ] State icons (v1.26.0): auto icons morph with state — door/window/garage open↔closed,
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]
- [ ] 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
computed once per config change, not per HA state push — smoke asserts
zero recomputations across 10 state pushes and recomputation after an
edit; the plan still renders dashes/islands correctly [auto: smoke_render_perf]
- [ ] Opening tap vs drag (v1.43.1, audit L4): a tap on a door in the Plan
editor opens its properties (3 px threshold like the other pipelines) and
writes nothing; a real drag that ends where it started also writes nothing [auto: smoke_render_perf]
- [ ] Concave containment (v1.43.1, audit G2): an island room inside a U- or
L-shaped parent is accepted and punches the evenodd hole; a traced
duplicate outline is still NOT containment [auto: smoke_inert_openings]
- [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed
when the entry is unavailable; layout/set honours expected_rev; a
config/set without expected_rev over a non-empty store logs a warning;
NaN/Infinity coordinates and oversized collections are rejected [auto: unit: logic.test]
- [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any
dialog within 500 ms (or let another client save) — the markup edit must
survive and reach the server; a failed reload now shows a toast [auto: unit: tests_backend]
- [ ] Niche split (v1.43.0, audit G1): a cut that starts AND ends on the same
wall carves a niche; the two parts' areas must sum to the original (the
invariant is enforced in code and asserted for every split test) [auto: smoke_save_race]
- [ ] Authenticated content (v1.43.0, audit B1): plan images and marker files
are only reachable through /api/houseplan/content/… with a session; the
old /houseplan_files/plans|files paths return 404 after a restart; old
stored URLs keep working (rewritten on read) [auto+manual]
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
two tabs, attach a background in each in turn — the plan last saved is the
one served, and neither commit deletes the other's file. A rejected upload
disappears on a later save, not immediately
[auto: backend test_late_commit_of_one_client_never_deletes_another_client_s_plan,
test_commit_does_not_collect_another_client_s_uncommitted_upload,
test_abandoned_uploads_are_collected_once_old]
- [ ] Static card background (v1.45.1, review R3-2): a houseplan-space-card on a
dashboard shows the plan image, not an empty stage; the browser never
requests the unsigned path and Home Assistant logs no failed login. A
failed signing request is retried on the next render
[auto: smoke_space_card_bg]
- [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new
background, make the config write fail (a second tab saving first is
enough) — the previously stored plan is still served, with the same or a
different extension; after a successful save the old files are gone
[auto: smoke_plan_upload_reject + backend test_plan_upload_does_not_touch_the_previous_file]
- [ ] Signature cache on a wall tablet (v1.45.0, review R2-2): with more than
200 signed urls every one of them is refreshed (batched), entries for
files no longer in the config are dropped, an expired signature is never
served and an aging one keeps working while its replacement arrives
[auto: smoke_sign_cap]
- [ ] Climate cost does not grow with rooms (v1.45.0, review R2-3): on a plan
with dozens of rooms an unrelated HA state update triggers ONE registry
pass, repeated renders on the same snapshot trigger none, and a changed
sensor value is still visible immediately [auto: smoke_climate_once]
- [ ] Plan upload survives a concurrent config revision (v1.44.8): with a second
tab open on the same plan, attach a background image in space settings —
the plan shows immediately, `plan_url` is in `.storage/houseplan.config`,
and the same holds when the space is being CREATED, not edited
[auto: smoke_plan_upload_race]
- [ ] Signed plan background (v1.44.7): a space whose plan lives on the content
endpoint renders its background image with an `authSig` query — the plan is
visible after a plain page load, and Home Assistant logs NO failed-login
attempt from the viewer's own IP. Nothing is requested before the signature
arrives; a 12 h re-sign keeps the previous url until the new one lands
[auto: smoke_plan_signed]
- [ ] Dialog zombies (v1.43.0, audit L3): close a dialog (Esc) while its save is
in flight and let the save fail — the dialog stays closed, the card keeps
rendering, the error toast still fires [auto: unit: logic.test + manual]
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
phones) taps never pop the room/device tooltip — the data lives in room
cards and long-press; desktop hover tooltips unchanged [auto: smoke_dialog_zombie]
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space
dialog) plus per-room name and metrics sizes (room settings), 50–300%,
multiplied together and on top of resize-k and kiosk multipliers; the
live sample card in both dialogs follows the sliders instantly; name and
metrics scale independently [auto: smoke_font_scales]
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
editor opens Room settings (name, HA area incl. the current one, fill
override, temp/hum source); the creation dialog has the same section;
fill override repaints only that room (incl. opting OUT of the glow
darkness); a temp/hum source (device or entity) feeds the room card,
tooltip and temperature fill — works for rooms without an HA area;
renaming/rebinding an existing room now possible [auto: smoke_room_settings]
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
PDFs to another device — the server moves /files/<oldId>/ to the new id
and the links keep opening; the old folder disappears (no orphans) [manual]
- [ ] Kiosk mode (v1.41.0): kiosk: true hides the whole header, blocks every
editor (admins incl.), full-height stage; swipe left/right switches
spaces at 1:1 (dots indicator, wraps), never while zoomed; double tap
resets zoom; long-press (3 s) on empty plan opens the per-screen size
popover (icon ×0.5–3, room-card font; localStorage per device);
cycle: N auto-advances spaces with a 60 s pause after any touch;
manual: walk the real wall tablet [auto+manual]
- [ ] Room link icon (v1.40.1): clicking empty room space in View does
nothing (default cursor); an open-in-new icon after the room name (rooms
with an HA area, View only) navigates to the area; no icon in editors or
on area-less rooms [auto: smoke_room_link]
- [ ] Smart guides (v1.40.0): while drawing (outline, cut, decor shapes) or
dragging (icons, room cards, decor) dashed accent guides appear from the
nearest object sharing the X and/or Y (max two, with a dot at the
source); the cursor badge shows length · angle and turns green on 45°
multiples; indication only — no magnetism; nothing in View mode [auto: smoke_align_guides]
- [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
light (bulbs, chandeliers, night lights, light groups) toggles on click
out of the box — no per-device setting needed; the device dialog shows
"Toggle" as its effective default; devices where light is a side
function (kettle: primary = sensor) keep the Device-card default;
explicit per-device "Device card" wins over the default [auto: smoke_light_default_tap]
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
segments (.seg) no longer run solid through an open stretch — only the
dash remains there [auto: smoke_openwall]
- [ ] Dashed boundaries in the Plan editor (v1.38.3): open stretches render as
a true dash in markup too (blue trimmed outlines); merge/split-picked
rooms keep their full amber highlight [auto: smoke_openwall]
- [ ] Nav persistence (v1.38.2): closing/reopening the tab restores the last
space AND editor mode (admins; localStorage); a #space= deep link beats
the saved space; a stale cache without the saved space retries after the
live config loads [manual]
- [ ] Tap action cleanup + right click (v1.38.1): the per-device action list
has three options (Device card / HA more-info / Toggle), no "card
default" — the card editor's global tap option is gone and ignored;
right click on an icon in VIEW opens HA more-info (native menu kept in
editors; virtual w/o entity → device card) [auto: smoke_tap_ctx]
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
the HA list — with a "Show entities" checkbox (tooltip) next to the
second; the dropdown (search inside) appears only in HA mode, opens
itself when nothing is chosen, closes on pick; Save is blocked until a
binding is chosen in HA mode; groups/helpers listed always, device
entities only with the checkbox; editing pre-selects everything [auto: smoke_binding_ui]
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
them back), walls elsewhere stay solid; the dashes render ABOVE the
glow pools [auto: smoke_openwall]
- [ ] Open-wall hover (v1.37.1): with the tool active the cursor is default;
near a shared wall it turns pointer and the exact stretch that would
open is previewed (amber dashed); an already-open boundary previews red
solid (the click will close it); preview follows the cursor and clears
on miss [auto: smoke_openwall]
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
toggles a virtual wall between two rooms by clicking their shared wall
(pull like Split; miss → toast); open stretches render dashed (amber
highlight while the tool is active); glow light floods the whole
connected open zone transitively, door sectors work from the zone's
outer walls; merge/split keep links by room id [auto: smoke_openwall]
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
the room — room outline and sectors are separate clipPath children
(union), not subpaths of one nonzero path [auto: smoke_glow]
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
field (HA units; empty = general-settings default shown as placeholder);
an override changes that source's pool and door sectors only [auto: smoke_glow]
- [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in
the registry (folded into a light group) still toggles/reflects the lamp,
not its do-not-disturb switch or identify button; visible entities of the
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
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
one uniform darkness color; lit lamps glow with a radial gradient
(rgb_color → color temp → default color; brightness scales opacity),
clipped by the source's room plus door sectors into NEIGHBOUR rooms
(entrance doors leak nothing; windows don't spill); radius set in
general settings in HA units (m/ft, stored in cm); no shadow casting —
islands don't block light (documented limitation) [auto: smoke_glow]
- [ ] Island rooms (v1.34.0): a contour drawn fully inside an existing room
(or around one) saves as a nested room — column in a ring, inner room;
the parent's fill renders with an evenodd hole so the ring paints
correctly; the island stays clickable; partial overlaps and duplicate
outlines are still rejected at closing [auto: smoke_island_rooms]
- [ ] Icon stays on edit (v1.33.4): rebinding a device (HA device/entity) or
changing its room within the same space never moves the icon — the saved
or auto position migrates to the new marker id; only a brand-new icon or
a move to another space centers it in the target room [auto: smoke_marker_stay]
- [ ] Icon picker placeholder (v1.33.3): with no explicit icon the device
dialog's icon picker shows the auto-derived icon as its placeholder, plus
an "Auto: mdi:..." hint line with the icon preview; the hint disappears
once an explicit icon is picked [auto: smoke_icon_placeholder]
- [ ] No Reset button (v1.33.2): the Device editor toolbar has three tools —
add, show all, icon rules; the layout-wiping Reset is gone [auto: smoke_editor_tabs]
- [ ] Grid in all editors + decor fade (v1.33.1): the dot grid shows in the
Device and Background editors too (instant "I'm editing" cue), not in
View; in the Background editor rooms/devices/openings/labels fade to 35%
while decor shapes stay fully opaque; no fade in the other editors [auto: smoke_decor / smoke_grid_fade]
- [ ] Background editor (v1.33.0): third tab with its own toolbar (select /
line / rect / oval / text / erase + color, width, fill, X); shapes drag-
drawn with grid snap and live preview; degenerate shapes dropped; text
via dialog (S/M/L, color; dblclick re-edits); Select moves (snap), Delete
removes, Erase deletes on click; Esc: draft → selection → select tool →
View; decor renders under rooms, visible everywhere, inert outside the
editor; stored in space.decor (rev/lock, backend schema) [auto: smoke_decor / smoke_grid_fade]
- [ ] Opening hover preview (v1.32.1): with the Opening tool, hovering near a
wall shows a dashed 90 cm ghost snapped onto the wall (with a center
dot); no ghost far from walls, over an existing opening (click = edit),
or in other tools [manual]
- [ ] Split polyline + cursors + Esc (v1.32.0): Merge shows a pointer cursor,
Split shows pointer until a room is picked then crosshair; the cut can be
a polyline — start on a wall, intermediate clicks inside the room, finish
on a wall (path drawn live, walls/self-crossing rejected); Esc walks back:
last cut point → room pick → back to the Draw tool (same for Merge:
selection → tool) [auto: smoke_split_polyline]
- [ ] Merge/split pick highlight (v1.31.2): the first room clicked with the
Merge tool (and the split-selected room) gets an amber outline + fill;
visible over the blue markup outlines [auto: smoke_merge_highlight]
- [ ] Card vs tool conflict (v1.31.1): in the Plan editor, dragging/resizing or
clicking a room card never feeds the active tool (no draw point, no
delete-room confirm, no merge/split pick); clicks past the card work [auto: smoke_merge_highlight]
- [ ] Room cards (v1.31.0): with metrics enabled in space settings (4
checkboxes: temperature, humidity, avg Zigbee, lights) the room name gets
a smaller metrics line under it; lights show On/Off or "1 of 3" when
partially lit; rooms without an HA area show the name only; in the Plan
editor cards show the name only, are draggable and resizable via corner
handles on hover (uniform scale 0.5–3, stored in layout, survives drag);
View mode has no handles/hover [auto: smoke_room_cards]
- [ ] Esc closes dialogs (v1.30.4): Escape closes the topmost dialog (opening
info, device info, icon rules, general settings, device editor, opening
editor, space dialog incl. abandoning an import queue); stacked dialogs
close one per press; Esc while drawing still undoes the last point [manual]
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
(admins), opens the palette dialog from View too [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] Editor tabs (v1.30.2): only two tabs — "Plan editor" / "Device editor"
(no View button; View is the default state); clicking a tab opens its
bottom toolbar (Devices got its own bar with add/show-all/reset/rules);
the bar and the active tab both show an X that returns to View; re-click
on the active tab does nothing; Plan↔Devices switches directly [auto: smoke_editor_tabs]
- [ ] Space gear (v1.30.1): the cog next to the space name is visible in every
mode (admins only), vertically centered with the tab text; clicking it
opens space settings without switching the tab; "+" tab stays Plan-only [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] Lock action (v1.30.0): opening info card (View) shows Unlock (red) when
locked / Lock when unlocked; button calls the lock service; disabled while
locking/unlocking; hidden when unavailable; plan-icon tap still never
toggles a lock [auto: smoke_gear_tabs / smoke_gs_always]
- [ ] New-device flag (v1.29.0): a device added to HA after install gets a big red
dot top-right of its icon (all clients); opening its editor clears it
everywhere; upgrade/first-run seeds the baseline silently — no dot flood [auto: smoke_new_device]
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto: smoke_new_device]
## Device dialog (markers) ★
- [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description)
- [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend]
- [ ] Virtual device: requires name; room required; renders dashed
- [ ] Sub-area rooms (v1.28.0): a room WITHOUT an HA area appears in the marker
room list ("no area, manual"); a device placed there lands at its centre,
the marker stores room_id, reopening the dialog restores the choice [manual]
- [ ] Room override moves the icon to the room center
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
- [ ] `javascript:` in the link field is not rendered as a clickable link [manual]
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
## Icon rules ★
- [ ] ⬡ opens the editor with current rules (defaults if none saved)
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [auto]
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [auto]
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [auto]
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [auto]
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [manual]
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [manual]
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [manual]
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [manual]
- [ ] Rules survive reload; second browser sees them after live-sync
## Tap actions & gestures ★
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [auto]
- [ ] Locks/alarms never toggle, even with per-device override [auto]; covers/valves toggle only with explicit per-device override [auto]
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [auto]
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [manual]
- [ ] Locks/alarms never toggle, even with per-device override [manual]; covers/valves toggle only with explicit per-device override [manual]
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [manual]
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [auto]
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [manual]
## Zoom / pan / labels
@@ -139,7 +482,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
- [ ] Zoom level persists per space (localStorage), restored on reload
- [ ] Window resize / sidebar collapse refits without distortion
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [manual]; hidden in markup mode
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
## Multi-client & concurrency ★
@@ -151,13 +494,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Edge cases
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [auto]
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [manual]
- [ ] Space with zero rooms → renders; markup hint visible
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [auto]
- [ ] 100+ devices in one space → build under ~50 ms [auto], drag stays smooth
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [manual]
- [ ] 100+ devices in one space → build under ~50 ms [manual], drag stays smooth
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [auto]
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [manual]
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
@@ -175,7 +518,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Last self-run
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[manual]` items pass (73 frontend
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
@@ -185,7 +528,7 @@ polygons) — the click now snaps to the nearest wall instead of the grid, and `
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
was missing. The earlier self-run record follows.
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[manual]` items pass
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
1. Edit dialog: switching an existing space from image to "draw" kept the old
@@ -201,14 +544,14 @@ require hands on real hardware — they remain for the human pass.
## houseplan-space-card (read-only embedded)
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
card's plan (background + configured borders/names + room fills + icons), no header/controls [auto]
card's plan (background + configured borders/names + room fills + icons), no header/controls [manual]
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [auto]
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [auto]
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [manual]
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [manual]
- [ ] Several cards with different `space` coexist on one board; one shared config WS request
- [ ] Unknown `space` → tidy error card [auto]
- [ ] Unknown `space` → tidy error card [manual]
- [ ] `show_button: false` hides the footer
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [manual]
## Presence ripples / per-device icon (v1.22.0)
+99
View File
@@ -0,0 +1,99 @@
# UX redesign: three modes (approved 2026-07-21)
> Approved design for reorganizing all card interactions into three tab-like modes.
> Driven by the owner's mandate and confirmed by real user feedback
> ([issue #3](https://github.com/Matysh/houseplan-card/issues/3): *"When moving the
> map around, I sometimes move the doors/sensors around"*). This document is the
> source of truth for the implementation iterations below. No code has been changed yet.
## Principle
A segmented control in the card header with three tabs; the active one is visually
highlighted, and edit modes add a colored frame around the stage so the mode is
obvious at a glance:
**[ 📐 Plan editor ] [ 🔧 Device editor ] [ ✏️ Background editor ]** — View has
NO tab (since v1.30.2). The Background editor (v1.33.0) manages a purely visual
decor layer (lines/rects/ovals/text in `space.decor`, drawn under the rooms,
inert everywhere outside its editor).
- **View** is the implicit default state: no editor tab is active. Since
v1.38.2 the last space AND editor mode are restored across reloads
(localStorage, admins only for edit modes) — closing and reopening the tab
lands you where you were (owner's decision, reversing the earlier
"never restore" rule).
- Activating an editor tab highlights it and opens that editor's bottom toolbar
(both editors have one since v1.30.2). The toolbar and the active tab each
carry an **X** that closes the editor back to View; re-clicking the active tab
does nothing; Plan↔Devices switches directly.
- **Plan editor** and **Device editor** are shown only to admins when
`admin_only` is on.
## View — display and device interaction only
Allowed: pan/zoom (wheel, pinch, buttons), switching spaces, device tap
(info / more-info / toggle per settings), long-press → info card, opening tap →
door/lock info card (with an explicit Unlock/Lock button when a lock is bound —
the only way to operate a lock from the card; plan-icon taps never toggle locks),
room-card link icon → HA area (room taps do nothing since v1.40.1), hover tooltips (name, temperature, signal).
Removed from this mode (they move, not die):
- icon dragging ("drag anywhere", v1.9 — consciously reversed),
- room-label dragging,
- opening dragging along walls and double-click properties (v1.23.1),
- every edit button in the header (+device, 👁 show-all, ↺ reset, ⬡ rules, ⚙ general,
per-space gear, markup toggle).
Header in View: space tabs, device count, zoom cluster. Nothing else.
## Plan — geometry and appearance of the space
- Toolbar tools: Outline room, Delete room, Merge, Split, Opening (place / drag along
walls / properties), Room labels (drag positions — labels are part of the plan).
- Space gear dialog (title, plan image / hand-drawn, scale, Display section, show_lqi),
add space, floors import, delete space.
- ⚙ General settings (fill palette) lives here — it is about the plan's appearance.
## Devices — placement and marker configuration
- 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.
## Deprecations decided
1. "Drag anywhere" (v1.9) — reversed by this design.
2. Opening drag / dbl-click properties in view (v1.23.1) — moved into Plan.
3. The markup toggle button — replaced by the Plan tab.
4. Legacy localStorage mode (card without the integration) — candidate for removal
in the next major; adds branching for a half-working scenario.
## Approved follow-up features (from issue #3, by priority)
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).
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.
## Implementation iterations
- **It.1 — mode shell:** the segmented control, mode state, View-mode gating of all
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.4+:** follow-up features 1–5 above, each its own release.
## Kiosk mode (v1.41.0)
`kiosk: true` on the card is the fourth interaction surface: the full View
experience with the header removed and editors hard-blocked (even for
admins). Swipe switches spaces at 1:1 zoom only (zoomed gestures pan;
double tap resets zoom); `cycle: N` auto-advances spaces with a 60 s pause
after any touch; a 3 s long-press on empty plan opens the per-screen size
popover (localStorage). Nav persistence never restores an editor here.
Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 853 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+12 -12
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.23.1",
"version": "1.41.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.23.1",
"version": "1.41.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
@@ -18,7 +18,7 @@
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"playwright": "^1.62.0",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
@@ -761,35 +761,35 @@
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.23.2",
"version": "1.45.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -16,7 +16,7 @@
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"playwright": "^1.62.0",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
+187 -11
View File
@@ -2,7 +2,7 @@
* Building the device list from HA registries: curation, light groups,
* markers (overrides/virtual). No Lit/DOM — only the hass object.
*/
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types';
@@ -53,20 +53,33 @@ export function isTempEntity(hass: any, eid: string): boolean {
}
export function primaryEntity(hass: any, entIds: string[], icon: string): string | undefined {
const ents = entIds
const all = entIds
.map((eid) => ({ eid, reg: hass.entities[eid], st: hass.states[eid] }))
.filter((e) => e.reg && !e.reg.hidden);
const usable = ents.filter((e) => !e.reg.entity_category);
const pool = usable.length ? usable : ents;
.filter((e) => e.reg);
// Tiers: visible primary entities first, but a HIDDEN light still beats a
// visible config switch. Real case: individual lamps folded into a light
// group get hidden in the registry — the device's main function is still
// the lamp, and tap-toggle must flip IT, not the do-not-disturb switch.
const tiers = [
all.filter((e) => !e.reg.hidden && !e.reg.entity_category),
all.filter((e) => !e.reg.hidden),
all.filter((e) => !e.reg.entity_category),
all,
];
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') {
const t = pool.find((e) => isTempEntity(hass, e.eid));
if (t) return t.eid;
for (const tier of tiers) {
const t = tier.find((e) => isTempEntity(hass, e.eid));
if (t) return t.eid;
}
}
for (const dom of DOMAIN_PRIORITY) {
const found = pool.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
for (const tier of tiers) {
const found = tier.find((e) => e.eid.split('.')[0] === dom);
if (found) return found.eid;
}
}
return pool[0]?.eid;
for (const tier of tiers) if (tier.length) return tier[0].eid;
return undefined;
}
/** Average zigbee LQI across the device's entities (*_linkquality/*_lqi sensors or an attribute). */
@@ -149,7 +162,7 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
}
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
export function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
const byRules = iconFor(name, model, rules);
if (byRules !== FALLBACK_ICON) return byRules;
const classes: string[] = [];
@@ -343,6 +356,169 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
return seen ? 'off' : 'none';
}
/**
* Explicit room measurement source: 'entity:<eid>' reads the state as a
* number; 'device:<id>' aggregates over that device's entities (tempFor /
* humFor). Used by the room-settings override (tier 3).
*/
export function sourceValue(hass: any, src: string | null | undefined, kind: 'temp' | 'hum'): number | null {
if (!src) return null;
const i = src.indexOf(':');
if (i < 0) return null;
const k = src.slice(0, i);
const ref = src.slice(i + 1);
if (!ref) return null;
if (k === 'entity') {
const v = parseFloat(hass.states[ref]?.state);
if (!Number.isFinite(v)) return null;
return kind === 'temp' ? Math.round(v * 10) / 10 : Math.round(v);
}
if (k === 'device') {
const entIds = Object.entries(hass.entities as Record<string, any>)
.filter(([, r]) => (r as any).device_id === ref)
.map(([eid]) => eid);
return kind === 'temp' ? tempFor(hass, entIds) : humFor(hass, entIds);
}
return null;
}
/** Average humidity across the area's climate-ish devices (integer %, or null). */
export function areaHum(
hass: any,
devices: { area: string; icon?: string; entities: string[] }[],
area: string,
): number | null {
const vals: number[] = [];
for (const dv of devices) {
if (dv.area !== area) continue;
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
const h = humFor(hass, dv.entities);
if (h != null) vals.push(h);
}
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
/**
* Entity ids that measure something other than room air, however honest their
* device_class is: water and coolant loops, chip/CPU/board temperatures,
* battery temperature, setpoints (target/external) and the like.
*/
const NON_AIR_RE = new RegExp(
[
'water', 'voda', 'coolant', 'flow_?temp', 'return_?temp', 'target', 'setpoint',
'chip', 'cpu', 'processor', 'board', 'core_temp', 'device_temp',
'batter', 'akkum', 'freezer', 'fridge', 'oven', 'kettle', 'boiler',
].join('|'),
'i',
);
/**
* Room climate from EVERY sensor of the area — including devices that are not
* placed on the plan (hidden by curation or by the user). The old helpers read
* the visible-icon list, so hiding a thermometer silently removed it from the
* room card (field report, 2026-07-27).
*
* Curation is kept: only devices the card itself recognises as thermometers /
* air monitors count, so fridges, TRVs and chip-temperature plugs stay out.
* The AUTO icon is used on purpose — a custom marker icon must not change what
* a device measures.
*/
export interface AreaClimate { temp: number | null; hum: number | null }
/**
* Climate for EVERY area in one registry pass (review R2-3).
*
* The per-area version below rescanned the whole registry for each room and
* each measurement: with 60 rooms and 2000 entities that is 120 traversals per
* render — an entire frame spent re-reading metadata that did not change. The
* caller computes this map once per `hass` snapshot and looks rooms up in O(1).
*/
export function areaClimateMap(
hass: any, rules?: CompiledIconRule[],
): Map<string, AreaClimate> {
const out = new Map<string, AreaClimate>();
if (!hass?.entities) return out;
// area -> device (or lone entity) -> the entities that belong to it
const byArea = new Map<string, Map<string, { name: string; model?: string; ents: string[] }>>();
for (const [eid, reg] of Object.entries<any>(hass.entities)) {
const dev = reg.device_id ? hass.devices?.[reg.device_id] : null;
const area = reg.area_id || dev?.area_id || null;
if (!area) continue;
// Not every "temperature" is room air. Real finds on a live install: the
// NAS processor temperature, the water in a smart kettle, a sauna heater at
// 90 C and a virtual better_thermostat duplicating the real sensor (field
// question, 2026-07-27). Three guards, cheapest first:
if (reg.entity_category) continue; // diagnostic/config readings
if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // curated-out integrations
if (NON_AIR_RE.test(eid)) continue; // water/chip/flow/target/...
let groups = byArea.get(area);
if (!groups) { groups = new Map(); byArea.set(area, groups); }
const key = reg.device_id || eid;
let g = groups.get(key);
if (!g) {
const st = hass.states?.[eid];
g = {
name: (dev ? dev.name_by_user || dev.name : reg.name || st?.attributes?.friendly_name || eid) || eid,
model: dev?.model,
ents: [],
};
groups.set(key, g);
}
g.ents.push(eid);
}
for (const [area, groups] of byArea) {
const temps: number[] = [];
const hums: number[] = [];
for (const g of groups.values()) {
const icon = resolveIcon(hass, g.name, g.model, g.ents, rules);
const air = icon === 'mdi:thermometer' || icon === 'mdi:air-filter';
if (air) {
const t = tempFor(hass, g.ents);
if (t != null) temps.push(t);
}
if (air || icon === 'mdi:water-percent') {
const h = humFor(hass, g.ents);
if (h != null) hums.push(h);
}
}
if (!temps.length && !hums.length) continue;
out.set(area, {
temp: temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null,
hum: hums.length ? Math.round(hums.reduce((a, b) => a + b, 0) / hums.length) : null,
});
}
return out;
}
/** One area's reading. Convenience wrapper — prefer the map for many areas. */
export function areaClimate(
hass: any, area: string, kind: 'temp' | 'hum', rules?: CompiledIconRule[],
): number | null {
if (!area) return null;
return areaClimateMap(hass, rules).get(area)?.[kind] ?? null;
}
/** How many of the area's lights are on: {on, total}, or null without lights. */
export function areaLightStats(
hass: any,
devices: { area: string; entities: string[] }[],
area: string,
): { on: number; total: number } | null {
const seen = new Set<string>();
let on = 0;
for (const dv of devices) {
if (dv.area !== area) continue;
for (const eid of dv.entities) {
if (!eid.startsWith('light.') || seen.has(eid)) continue;
seen.add(eid);
if (hass.states[eid]?.state === 'on') on++;
}
}
return seen.size ? { on, total: seen.size } : null;
}
/** Average temperature across the area's devices (null when nothing reports one). */
/** Average zigbee signal (LQI) across an area's non-virtual devices, or null. */
export function areaLqi(hass: any, devices: { area: string; virtual?: boolean; entities: string[] }[], area: string): number | null {
+4 -14
View File
@@ -63,23 +63,12 @@ class HouseplanCardEditor extends LitElement {
},
},
},
{
name: 'tap_action',
selector: {
select: {
mode: 'dropdown',
options: [
{ value: 'info', label: t(L, 'tap.info') },
{ value: 'more-info', label: t(L, 'tap.more_info') },
{ value: 'toggle', label: t(L, 'tap.toggle') },
],
},
},
},
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
{ name: 'show_temperature', selector: { boolean: {} } },
{ name: 'live_states', selector: { boolean: {} } },
{ name: 'show_signal', selector: { boolean: {} } },
{ name: 'kiosk', selector: { boolean: {} } },
{ name: 'cycle', selector: { number: { min: 0, max: 3600, step: 5, mode: 'box' } } },
];
}
@@ -91,11 +80,12 @@ class HouseplanCardEditor extends LitElement {
title: t(L, 'editor.title'),
default_floor: t(L, 'editor.default_floor'),
language: t(L, 'editor.language'),
tap_action: t(L, 'editor.tap_action'),
icon_size: t(L, 'editor.icon_size'),
show_temperature: t(L, 'editor.show_temperature'),
live_states: t(L, 'editor.live_states'),
show_signal: t(L, 'editor.show_signal'),
kiosk: t(L, 'editor.kiosk'),
cycle: t(L, 'editor.cycle'),
};
return html`<ha-form
.hass=${this.hass}
+2348 -244
View File
File diff suppressed because it is too large Load Diff
+116 -10
View File
@@ -22,7 +22,6 @@
"title.zoom_reset": "Reset zoom",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show all area devices (no curation)",
"title.reset_layout": "Reset icon positions to auto layout",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
@@ -60,7 +59,6 @@
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
"markup.hint_start": "click a grid dot to start the outline",
"tip.room": "room — open the area",
"tip.lqi": "average zigbee signal:",
"info.device_header": "Device on the plan",
"info.model": "Model",
@@ -115,7 +113,6 @@
"device.light_group": "light group",
"device.fallback": "device",
"device.virtual": "virtual device",
"confirm.reset_layout": "Reset all icon positions to the auto layout?",
"confirm.delete_room": "Delete room \"{name}\"?",
"confirm.remove_marker": "Remove \"{name}\" from the plan?",
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
@@ -124,12 +121,11 @@
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”",
"toast.split_pick_wall": "Click a grid dot on the room’s wall",
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
"toast.split_pick_wall": "Start the cut on the room’s wall",
"toast.split_bad_cut": "The cut must run wall to wall inside the room, without crossing walls or itself",
"merge.header": "Merge rooms",
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
"merge.keep": "Keep",
@@ -179,11 +175,9 @@
"rules.saved": "Icon rules saved",
"btn.up": "Up",
"btn.down": "Down",
"editor.tap_action": "Tap on a device",
"tap.info": "Info card",
"tap.info": "Device card",
"tap.more_info": "HA more-info dialog",
"tap.toggle": "Toggle (lights/switches)",
"tap.auto": "As the card default",
"marker.tap_label": "Tap action for this device",
"tap.toggle_note": "Toggle never applies to locks and alarms; hold the icon to open the info card.",
"import.title": "Create spaces from HA floors",
@@ -222,5 +216,117 @@
"editor.button_label": "Button label",
"editor.button_target": "Target dashboard path",
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)",
"marker.sub_entity": "entity"
"marker.sub_entity": "entity",
"title.general_settings": "General settings",
"gs.title": "General settings",
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
"gs.light_group": "Fill: lights",
"gs.light_on": "Lights on",
"gs.light_off": "All lights off",
"gs.temp_group": "Fill: temperature",
"gs.temp_cold": "Cold",
"gs.temp_ok": "Comfortable",
"gs.temp_hot": "Hot",
"gs.lqi_group": "Fill: zigbee signal",
"gs.lqi_low": "Weak signal",
"gs.lqi_high": "Strong signal",
"gs.reset": "Reset to defaults",
"gs.saved": "General settings saved",
"space.show_lqi": "Show zigbee signal (LQI) next to devices",
"gs.light_none": "No light sources",
"mode.plan": "Plan editor",
"mode.devices": "Device editor",
"display.value": "Value instead of an icon",
"marker.subarea": "no area, manual",
"device.new": "New device — open its editor to dismiss",
"opening.unlock_action": "Unlock",
"opening.lock_action": "Lock",
"opening.lock_pending": "Working…",
"title.close_editor": "Close editor (back to view)",
"devbar.add": "Add",
"devbar.show_all": "Show all",
"devbar.rules": "Icon rules",
"space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature",
"space.label_hum": "Humidity",
"space.label_lqi": "Average Zigbee signal",
"space.label_light": "Lights on/off",
"roomcard.light_on": "On",
"roomcard.light_off": "Off",
"roomcard.light_partial": "{on} of {total}",
"toast.split_pick_inside": "Intermediate cut points must be inside the room",
"mode.decor": "Background editor",
"decor.select": "Select",
"decor.line": "Line",
"decor.rect": "Rectangle",
"decor.ellipse": "Oval",
"decor.text": "Text",
"decor.erase": "Erase",
"decor.color": "Color",
"decor.width": "Line width",
"decor.w_thin": "Thin",
"decor.w_mid": "Medium",
"decor.w_thick": "Thick",
"decor.fill": "Fill",
"decor.text_title": "Text label",
"decor.text_label": "Text",
"decor.text_size": "Size",
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks",
"fill.glow": "Light sources (dark house, glowing lamps)",
"gs.glow_group": "Light-sources fill",
"gs.glow_base": "House darkness",
"gs.glow_light": "Default light color / intensity",
"gs.glow_radius": "Glow radius",
"gs.unit_m": "m",
"gs.unit_ft": "ft",
"marker.controls_label": "Controls light sources",
"marker.controls_hint": "With tap action “Toggle”, a click flips all bound lights at once (any on → all off). The icon mirrors their state.",
"marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_hint": "empty = default from general settings",
"markup.openwall": "Open boundary",
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
"toast.openwall_pick": "Click a wall shared by two rooms",
"toast.openwall_opened": "Boundary “{a}” ↔ “{b}” is now open",
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again",
"marker.from_ha_option": "Pick from the HA list",
"marker.show_entities": "Show entities",
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…",
"room.open_area": "Open the HA area",
"kiosk.title": "This screen's sizes",
"kiosk.hint": "Stored on this device only — every wall tablet or TV can have its own comfortable sizes.",
"kiosk.icon_scale": "Device icon size",
"kiosk.font_scale": "Room card text size",
"editor.kiosk": "Wall device (kiosk) mode",
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)",
"room.settings_title": "Room settings",
"room.settings_section": "Room settings (override the space)",
"room.fill_label": "Fill in THIS room",
"fill.inherit": "As the space",
"room.temp_src_label": "Temperature source",
"room.hum_src_label": "Humidity source",
"room.src_average": "Average over the room's sensors (default)",
"room.src_pick": "A specific HA device or entity",
"room.src_ph": "Choose a source…",
"toast.room_updated": "Room updated",
"space.card_font": "Room-card font size (whole space)",
"room.sizes_section": "Font sizes",
"room.name_scale": "Room name size",
"room.label_scale": "Metrics size",
"preview.room_name": "Living room",
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room",
"room.unnamed": "Unnamed room",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above).",
"confirm.unlock": "Unlock “{name}”?",
"toast.files_migrate_failed": "Attachments could not be moved to the new binding, links keep pointing at the old files: {err}"
}
+117 -11
View File
@@ -22,7 +22,6 @@
"title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без курирования)",
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
@@ -60,7 +59,6 @@
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
"tip.room": "комната — открыть зону",
"tip.lqi": "средний сигнал zigbee:",
"info.device_header": "Устройство на плане",
"info.model": "Модель",
@@ -115,7 +113,6 @@
"device.light_group": "группа света",
"device.fallback": "устройство",
"device.virtual": "виртуальное устройство",
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
"confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.remove_marker": "Убрать «{name}» с плана?",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
@@ -124,12 +121,11 @@
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»",
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты",
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты",
"toast.split_pick_wall": "Начните разрез на стене комнаты",
"toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
"merge.header": "Объединение комнат",
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
"merge.keep": "Оставить",
@@ -179,12 +175,10 @@
"rules.saved": "Правила иконок сохранены",
"btn.up": "Вверх",
"btn.down": "Вниз",
"editor.tap_action": "Тап по устройству",
"tap.info": "Инфо-карточка",
"tap.info": "Карточка устройства",
"tap.more_info": "Диалог HA (more-info)",
"tap.toggle": "Переключить (свет/розетки)",
"tap.auto": "Как в настройках карточки",
"marker.tap_label": "Действие по тапу для этого устройства",
"marker.tap_label": "Действие по нажатию для этого устройства",
"tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.",
"import.title": "Создать пространства из этажей HA",
"import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
@@ -222,5 +216,117 @@
"editor.button_label": "Текст кнопки",
"editor.button_target": "Путь дашборда (куда вести)",
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
"marker.sub_entity": "сущность"
"marker.sub_entity": "сущность",
"title.general_settings": "Общие настройки",
"gs.title": "Общие настройки",
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
"gs.light_group": "Заливка: освещение",
"gs.light_on": "Свет включён",
"gs.light_off": "Весь свет выключен",
"gs.temp_group": "Заливка: температура",
"gs.temp_cold": "Холодно",
"gs.temp_ok": "Комфорт",
"gs.temp_hot": "Жарко",
"gs.lqi_group": "Заливка: зигби-сигнал",
"gs.lqi_low": "Слабый сигнал",
"gs.lqi_high": "Сильный сигнал",
"gs.reset": "Сбросить к умолчаниям",
"gs.saved": "Общие настройки сохранены",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
"gs.light_none": "Нет источников света",
"mode.plan": "Редактор плана",
"mode.devices": "Редактор устройств",
"display.value": "Значение вместо иконки",
"marker.subarea": "без зоны, вручную",
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
"opening.unlock_action": "Открыть замок",
"opening.lock_action": "Закрыть замок",
"opening.lock_pending": "Выполняется…",
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить",
"devbar.show_all": "Показать все",
"devbar.rules": "Правила иконок",
"space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура",
"space.label_hum": "Влажность",
"space.label_lqi": "Средний Zigbee-сигнал",
"space.label_light": "Свет вкл/выкл",
"roomcard.light_on": "Вкл",
"roomcard.light_off": "Выкл",
"roomcard.light_partial": "{on} из {total}",
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты",
"mode.decor": "Редактор подложки",
"decor.select": "Выбрать",
"decor.line": "Линия",
"decor.rect": "Прямоугольник",
"decor.ellipse": "Овал",
"decor.text": "Надпись",
"decor.erase": "Стереть",
"decor.color": "Цвет",
"decor.width": "Толщина линии",
"decor.w_thin": "Тонкая",
"decor.w_mid": "Средняя",
"decor.w_thick": "Толстая",
"decor.fill": "Залить",
"decor.text_title": "Надпись",
"decor.text_label": "Текст",
"decor.text_size": "Размер",
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
"fill.glow": "Свет по источникам (тёмный дом, пятна света)",
"gs.glow_group": "Заливка «Свет по источникам»",
"gs.glow_base": "Темнота дома",
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
"gs.glow_radius": "Радиус свечения",
"gs.unit_m": "м",
"gs.unit_ft": "фут",
"marker.controls_label": "Управляет источниками света",
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
"marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
"markup.openwall": "Открытая граница",
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
"toast.openwall_opened": "Граница «{a}» ↔ «{b}» теперь открыта",
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта",
"marker.from_ha_option": "Выбрать из списка HA",
"marker.show_entities": "Отображать сущности",
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…",
"room.open_area": "Открыть зону в HA",
"kiosk.title": "Размеры на этом экране",
"kiosk.hint": "Хранится только на этом устройстве — у каждого настенного планшета или ТВ свои удобные размеры.",
"kiosk.icon_scale": "Размер значков устройств",
"kiosk.font_scale": "Размер текста карточек комнат",
"editor.kiosk": "Режим настенного устройства (киоск)",
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)",
"room.settings_title": "Настройки комнаты",
"room.settings_section": "Настройки комнаты (переопределяют пространство)",
"room.fill_label": "Заливка в ЭТОЙ комнате",
"fill.inherit": "Как у пространства",
"room.temp_src_label": "Источник температуры",
"room.hum_src_label": "Источник влажности",
"room.src_average": "Средняя по датчикам комнаты (по умолчанию)",
"room.src_pick": "Конкретное устройство или сущность HA",
"room.src_ph": "Выберите источник…",
"toast.room_updated": "Комната обновлена",
"space.card_font": "Размер шрифта карточек комнат (всё пространство)",
"room.sizes_section": "Размеры шрифтов",
"room.name_scale": "Размер названия",
"room.label_scale": "Размер подписей",
"preview.room_name": "Гостиная",
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Комната",
"room.unnamed": "Комната без имени",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами).",
"confirm.unlock": "Открыть замок «{name}»?",
"toast.files_migrate_failed": "Не удалось перенести вложения к новой привязке, ссылки остались на старые файлы: {err}"
}
+748 -24
View File
@@ -38,8 +38,14 @@ export function formatLength(cm: number, imperial: boolean): string {
* normalized (0..1) coordinates need more (see roomEdges).
*/
export function segKey(a: number[], b: number[], prec = 1): string {
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
// audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
// rounded ones let one wall produce two different keys, so shared walls were
// emitted twice and the dedup invariant quietly broke.
const ax = a[0].toFixed(prec), ay = a[1].toFixed(prec);
const bx = b[0].toFixed(prec), by = b[1].toFixed(prec);
const first = ax < bx || (ax === bx && ay <= by);
const [px, py, qx, qy] = first ? [ax, ay, bx, by] : [bx, by, ax, ay];
return `${px},${py}-${qx},${qy}`;
}
/**
@@ -224,6 +230,36 @@ export function segmentsProperlyCross(
}
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
/**
* A point guaranteed to lie strictly inside the polygon (audit G2).
* The arithmetic mean of the vertices lies OUTSIDE concave shapes (U/L rooms
* are common in hand-drawn plans), which made containment tests misfire.
* Strategy: try the midpoints of the diagonals from each vertex, then a
* triangle centroid of consecutive vertices — the first point that passes
* pointStrictlyInside wins.
*/
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
if (!poly || poly.length < 3) return null;
const n = poly.length;
const mean = [
poly.reduce((s, p) => s + p[0], 0) / n,
poly.reduce((s, p) => s + p[1], 0) / n,
];
if (pointStrictlyInside(mean, poly, eps)) return mean;
for (let i = 0; i < n; i++) {
// centroid of the ear at vertex i
const a = poly[(i - 1 + n) % n], b = poly[i], c = poly[(i + 1) % n];
const cand = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3];
if (pointStrictlyInside(cand, poly, eps)) return cand;
}
for (let i = 0; i < n; i++)
for (let j = i + 2; j < n; j++) {
const cand = [(poly[i][0] + poly[j][0]) / 2, (poly[i][1] + poly[j][1]) / 2];
if (pointStrictlyInside(cand, poly, eps)) return cand;
}
return null;
}
function coversArea(a: number[][], b: number[][], eps: number): boolean {
let allOnBoundary = true;
for (const v of a) {
@@ -232,28 +268,51 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
}
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
if (allOnBoundary) {
const c = [
a.reduce((s, p) => s + p[0], 0) / a.length,
a.reduce((s, p) => s + p[1], 0) / a.length,
];
return pointStrictlyInside(c, b, eps);
const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
return !!c && pointStrictlyInside(c, b, eps);
}
return false;
}
/** Is `inner` fully contained in `outer` (edges may touch, never cross)? */
export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-6): boolean {
if (!outer || !inner || outer.length < 3 || inner.length < 3) return false;
for (let i = 0; i < inner.length; i++)
for (let j = 0; j < outer.length; j++)
if (segmentsProperlyCross(inner[i], inner[(i + 1) % inner.length], outer[j], outer[(j + 1) % outer.length]))
return false;
for (const v of inner)
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
// identical/traced outlines are NOT containment — probe a real interior point
// (audit G2: the vertex mean lies outside concave rooms)
const c = interiorPoint(inner, eps);
return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
}
/**
* Do two room outlines share floor area? Rooms must never overlap, but sharing a wall
* (fully or partially) and touching at a corner are normal and stay legal. A room nested
* inside another counts as an overlap.
* Do two room outlines ILLEGALLY share floor area? Sharing a wall (fully or
* partially) and touching at a corner are normal. Since v1.34.0 full nesting is
* legal too (island rooms: a column inside a ring, an inner room) — only edge
* crossings and PARTIAL overlaps are rejected.
*/
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
if (!a || !b || a.length < 3 || b.length < 3) return false;
for (let i = 0; i < a.length; i++)
for (let j = 0; j < b.length; j++)
if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
return coversArea(a, b, eps) || coversArea(b, a, eps);
}
/**
* Direct islands of `poly` among `others`: outlines fully inside it that are not
* themselves inside a bigger island (those are subtracted by their parent).
*/
export function islandsOf(poly: number[][], others: number[][][], eps = 1e-6): number[][][] {
const inside = others.filter((o) => polyContainsPoly(poly, o, eps));
return inside.filter((o) => !inside.some((p) => p !== o && polyContainsPoly(p, o, eps)));
}
/** Shoelace area of an outline (absolute value). */
export function polygonArea(poly: number[][]): number {
if (!poly || poly.length < 3) return 0;
@@ -310,29 +369,82 @@ function dropRepeats(pts: number[][], eps: number): number[][] {
export function splitRoom(
poly: number[][], a: number[], b: number[], eps = 1e-6,
): [number[][], number[][]] | null {
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null;
return splitRoomPath(poly, [a, b], eps);
}
/**
* Split a room along a polyline: first and last points on walls, intermediate
* vertices strictly inside the room. A two-point path is the classic straight
* chord. Returns the two parts, or null when the path is not a clean cut.
*/
export function splitRoomPath(
poly: number[][], pts: number[][], eps = 1e-6,
): [number[][], number[][]] | null {
if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
const a = pts[0];
const b = pts[pts.length - 1];
if (samePoint(a, b, eps)) return null;
const ia = edgeIndexOf(poly, a, eps);
const ib = edgeIndexOf(poly, b, eps);
if (ia < 0 || ib < 0) return null; // an end is not on a wall
for (let i = 0; i < poly.length; i++)
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room
// a chord lying along a wall has its midpoint ON the outline, not inside it
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null;
const mids = pts.slice(1, -1);
for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
// no path segment may cross a wall
for (let sI = 0; sI < pts.length - 1; sI++)
for (let i = 0; i < poly.length; i++)
if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
// the path may not properly self-intersect
for (let sI = 0; sI < pts.length - 1; sI++)
for (let t = sI + 2; t < pts.length - 1; t++)
if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
// a straight chord lying along a wall has its midpoint ON the outline, not inside
if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
return null;
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
const pts: number[][] = [from];
const acc: number[][] = [from];
let i = (fromIdx + 1) % poly.length;
for (let guard = 0; guard <= poly.length; guard++) {
pts.push(poly[i]);
acc.push(poly[i]);
if (i === toIdx) break;
i = (i + 1) % poly.length;
}
pts.push(to);
return dropRepeats(pts, eps);
acc.push(to);
return dropRepeats(acc, eps);
};
const p1 = walk(a, ia, b, ib);
const p2 = walk(b, ib, a, ia);
let p1: number[][];
let p2: number[][];
if (ia === ib) {
// BOTH ends on the SAME edge — carving an alcove out of one wall. The walk
// above would traverse the whole outline twice and return two overlapping,
// self-intersecting rooms whose areas sum to 2x the original (audit G1,
// 2026-07-27). The niche is simply the path closed along that edge; the
// remainder is the outline with that stretch replaced by the path.
const niche = dropRepeats([...pts], eps);
if (niche.length < 3 || polygonArea(niche) <= eps) return null;
// the niche must not swallow other geometry: it stays inside the room
const rest: number[][] = [];
for (let i = 0; i < poly.length; i++) {
rest.push(poly[i]);
if (i === ia) {
// walk the cut from a to b along the edge direction
const dir = (poly[(ia + 1) % poly.length][0] - poly[ia][0]) * (b[0] - a[0])
+ (poly[(ia + 1) % poly.length][1] - poly[ia][1]) * (b[1] - a[1]);
const path = dir >= 0 ? pts : [...pts].reverse();
for (const p of path) rest.push(p);
}
}
p1 = dropRepeats(rest, eps);
p2 = niche;
} else {
p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
}
if (p1.length < 3 || p2.length < 3) return null;
if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
// INVARIANT (audit G1): a split partitions the room — the parts must sum to
// the original. Anything else means the walk produced overlapping garbage.
if (Math.abs(polygonArea(p1) + polygonArea(p2) - polygonArea(poly)) > Math.max(eps, polygonArea(poly) * 1e-6))
return null;
return [p1, p2];
}
@@ -441,7 +553,11 @@ export function resolveTapAction(
cardDefault: string | null | undefined,
domain: string | null | undefined,
): TapAction {
const want = explicit || cardDefault || 'info';
// Pure light sources (the device's PRIMARY function is a lamp: bulbs,
// chandeliers, night lights, light groups) toggle by default — no explicit
// setting needed. Devices where light is a side function (a kettle's
// 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';
if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
@@ -484,7 +600,7 @@ export function subst(s: string, vars?: Record<string, string | number>): string
// ---------------- room fills & colors ----------------
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp' | 'glow';
/** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay {
@@ -495,6 +611,15 @@ export interface SpaceDisplay {
fill: RoomFillMode;
tempMin: number; // comfort range lower bound, °C
tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
showLqi: boolean | null;
/** Base font multiplier for room cards (tier 2; rooms multiply on top). */
cardFontScale: number;
/** Room-card metrics under the room name (all default off). */
labelTemp: boolean;
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -511,12 +636,122 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
showNames: s.show_names ?? noPlan,
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
fill: ['lqi', 'light', 'temp', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
cardFontScale: typeof s.card_font_scale === 'number' && s.card_font_scale > 0
? Math.min(3, Math.max(0.5, s.card_font_scale))
: 1,
labelTemp: s.label_temp === true,
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
};
}
// ---------------- global fill colors ----------------
export interface FillColorEntry {
c: string; // #rrggbb
a: number; // 0..1 fill opacity
}
/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
export interface FillColors {
light_on: FillColorEntry;
light_off: FillColorEntry;
/** Rooms with no light sources at all; alpha 0 (default) = no fill, as before. */
light_none: FillColorEntry;
temp_cold: FillColorEntry;
temp_ok: FillColorEntry;
temp_hot: FillColorEntry;
lqi_low: FillColorEntry;
lqi_high: FillColorEntry;
/** Glow mode: uniform "darkness" over every room + default light color. */
glow_base: FillColorEntry;
glow_light: FillColorEntry;
}
export const DEFAULT_FILL_COLORS: FillColors = {
light_on: { c: '#ffd45c', a: 0.18 },
light_off: { c: '#9aa0a6', a: 0.14 },
light_none: { c: '#6b7480', a: 0 },
temp_cold: { c: '#4fc3f7', a: 0.18 },
temp_ok: { c: '#66d17a', a: 0.18 },
temp_hot: { c: '#ffd45c', a: 0.18 },
lqi_low: { c: '#f25a4a', a: 0.18 },
lqi_high: { c: '#4bd28f', a: 0.18 },
glow_base: { c: '#0d1b2a', a: 0.5 },
glow_light: { c: '#ffd9a0', a: 0.85 },
};
const HEX_RE = /^#[0-9a-f]{6}$/i;
/** Merge stored overrides over the defaults, dropping malformed entries. */
export function fillColorsOf(settings: any): FillColors {
const out: any = {};
const src = settings?.fill_colors || {};
for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
const d = DEFAULT_FILL_COLORS[k];
const v = src[k];
out[k] = {
c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
};
}
return out as FillColors;
}
/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
export function lerpColor(a: string, b: string, t: number): string {
const tt = Math.min(1, Math.max(0, t));
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
}
/**
* Room fill (color + opacity) for the selected mode, or null for "no fill",
* using the global palette. The LQI gradient interpolates lqi_low → lqi_high
* over the 40..180 LQI window (same thresholds as the badge color).
*/
export function roomFillStyle(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp: number | null | undefined,
tempMin: number,
tempMax: number,
colors: FillColors,
): FillColorEntry | null {
if (mode === 'lqi') {
if (lqi == null) return null;
const t = (lqi - 40) / 140;
return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
}
if (mode === 'light') {
if (lights === 'none') {
// configurable "no light sources" color; alpha 0 keeps the historical
// no-fill behavior (and the unfilled hover), so nothing changes until
// the user assigns an opacity
return colors.light_none.a > 0 ? colors.light_none : null;
}
return lights === 'on' ? colors.light_on : colors.light_off;
}
if (mode === 'temp') {
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return colors.temp_cold;
if (temp > hi) return colors.temp_hot;
return colors.temp_ok;
}
return null;
}
/**
* Room fill color for the selected mode, or null for "no fill".
* - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
@@ -549,3 +784,492 @@ export function roomFillColor(
}
return null;
}
// ---------------- state-reflecting icons ----------------
/**
* Swap the auto icon for a state variant (open door, unlocked lock…), like core
* HA does. Conservative: only well-known pairs, only when the user has NOT set
* a custom icon, and unknown/unavailable states keep the base icon.
*/
export function stateIcon(
base: string,
domain: string | null | undefined,
deviceClass: string | null | undefined,
state: string | null | undefined,
hasCustomIcon: boolean,
): string {
if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
if (domain === 'binary_sensor') {
if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
}
if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
return base;
}
// ---------------- light color & alarm states ----------------
/**
* The current color of a light entity as a CSS color, or null when it is off,
* unavailable or reports no usable color. rgb_color is the source of truth
* (HA normalizes hs/xy into it); brightness is deliberately ignored — a dim
* red bulb should still read as red on the plan.
*/
export function lightColorOf(state: any): string | null {
if (!state || state.state !== 'on') return null;
const rgb = state.attributes?.rgb_color;
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v))) {
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}
return null;
}
// ---------------- glow fill (light sources) ----------------
/** Blackbody color temperature → RGB (Tanner Helland approximation). */
export function kelvinToRgb(kelvin: number): [number, number, number] {
const t = Math.min(40000, Math.max(1000, kelvin)) / 100;
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
const g = t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
const cl = (v: number) => Math.round(Math.min(255, Math.max(0, v)));
return [cl(r), cl(g), cl(b)];
}
/**
* Color and relative brightness of a light's glow: rgb_color as is, else the
* color temperature via blackbody, else the configured fallback. Off → null.
*/
export function glowColorOf(state: any, fallback: string): { c: string; bri: number } | null {
if (!state || state.state !== 'on') return null;
const a = state.attributes || {};
const briRaw = Number(a.brightness);
const bri = Number.isFinite(briRaw) && briRaw > 0 ? Math.max(0.15, Math.min(1, briRaw / 255)) : 1;
const rgb = a.rgb_color;
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v)))
return { c: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`, bri };
const kelvin = Number(a.color_temp_kelvin) || (Number(a.color_temp) > 0 ? 1e6 / Number(a.color_temp) : NaN);
if (Number.isFinite(kelvin) && kelvin > 0) {
const [r, g, b] = kelvinToRgb(kelvin);
return { c: `rgb(${r}, ${g}, ${b})`, bri };
}
return { c: fallback, bri };
}
/**
* Light spilling through a doorway: the sector of the glow circle between the
* rays source→A and source→B (door edge points), out to radius r. This part of
* the circle is intentionally NOT clipped by the room (owner's spec: no shadow
* casting — just the unclipped sector). Null when the door is out of reach or
* the source sits on a door edge; the sweep is clamped to maxDeg.
*/
export function doorSector(
src: number[], a: number[], b: number[], r: number, maxDeg = 170,
): number[][] | null {
const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
let aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
let sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
while (sweep > Math.PI) sweep -= 2 * Math.PI;
while (sweep < -Math.PI) sweep += 2 * Math.PI;
const max = (maxDeg * Math.PI) / 180;
if (Math.abs(sweep) > max) {
const mid = aa + sweep / 2;
sweep = max * Math.sign(sweep);
aa = mid - sweep / 2;
}
const steps = 8;
const pts: number[][] = [[src[0], src[1]]];
for (let i = 0; i <= steps; i++) {
const ang = aa + (sweep * i) / steps;
pts.push([src[0] + Math.cos(ang) * r, src[1] + Math.sin(ang) * r]);
}
return pts;
}
/**
* Is there a room on the far side of an opening (relative to the light source)?
* Entrance doors lead outside — light must not spill there.
*/
export function hasRoomBehind(
center: number[], angleDeg: number, src: number[], polys: number[][][], probe: number,
): boolean {
const rad = (angleDeg * Math.PI) / 180;
const n = [-Math.sin(rad), Math.cos(rad)];
const toSrc = (src[0] - center[0]) * n[0] + (src[1] - center[1]) * n[1];
const sgn = toSrc > 0 ? -1 : 1;
const p = [center[0] + n[0] * probe * sgn, center[1] + n[1] * probe * sgn];
return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
}
/**
* Group toggle for a switch's controlled entities, HA-group semantics:
* any target on -> turn everything off; all off -> turn everything on.
*/
export function controlsAction(states: (string | undefined)[]): 'turn_on' | 'turn_off' {
return states.some((st) => st === 'on') ? 'turn_off' : 'turn_on';
}
/** Only lights and plain switches may be group-controlled from the plan. */
export function isControllable(entityId: string): boolean {
return entityId.startsWith('light.') || entityId.startsWith('switch.');
}
// ---------------- open (virtual) boundaries ----------------
/**
* Collinear overlapping stretches of two room outlines — their shared
* boundary. Handles the real-house case where neighbouring walls only
* PARTIALLY overlap (collinear, different lengths). Returns segments
* [x1,y1,x2,y2] with length > eps.
*/
export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number[][] {
const res: number[][] = [];
if (!a || !b || a.length < 3 || b.length < 3) return res;
for (let i = 0; i < a.length; i++) {
const p1 = a[i], p2 = a[(i + 1) % a.length];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
for (let j = 0; j < b.length; j++) {
const q1 = b[j], q2 = b[(j + 1) % b.length];
// both q endpoints must lie on the line of p1-p2
const d1 = Math.abs((q1[0] - p1[0]) * uy - (q1[1] - p1[1]) * ux);
const d2 = Math.abs((q2[0] - p1[0]) * uy - (q2[1] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
// overlap of parameter intervals along the line
const t1 = (q1[0] - p1[0]) * ux + (q1[1] - p1[1]) * uy;
const t2 = (q2[0] - p1[0]) * ux + (q2[1] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) {
res.push([p1[0] + ux * lo, p1[1] + uy * lo, p1[0] + ux * hi, p1[1] + uy * hi]);
}
}
}
return res;
}
/**
* Connected component of rooms joined by open (virtual) boundaries — the
* "open zone" light flows through. The open_to link counts in either
* direction. Returns a set of room ids including the start.
*/
export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: string[] | null }[]): Set<string> {
const zone = new Set<string>([roomId]);
const linked = (x: any, y: any) =>
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
let grew = true;
while (grew) {
grew = false;
for (const r of rooms) {
if (!r.id || zone.has(r.id)) continue;
for (const z of rooms) {
if (!z.id || !zone.has(z.id)) continue;
if (linked(r, z)) { zone.add(r.id); grew = true; break; }
}
}
}
return zone;
}
/**
* Segments with the given collinear stretches removed — the workhorse behind
* TRUE dashed open boundaries (derived walls and room outlines alike).
*/
export function cutSegments(segs: number[][], cuts: number[][], eps = 1e-6): number[][] {
const out: number[][] = [];
for (const seg of segs) {
const p1 = [seg[0], seg[1]], p2 = [seg[2], seg[3]];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
// collect cut intervals on this edge
const iv: [number, number][] = [];
for (const c of cuts) {
const d1 = Math.abs((c[0] - p1[0]) * uy - (c[1] - p1[1]) * ux);
const d2 = Math.abs((c[2] - p1[0]) * uy - (c[3] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
const t1 = (c[0] - p1[0]) * ux + (c[1] - p1[1]) * uy;
const t2 = (c[2] - p1[0]) * ux + (c[3] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) iv.push([lo, hi]);
}
if (!iv.length) {
out.push([p1[0], p1[1], p2[0], p2[1]]);
continue;
}
iv.sort((a, b) => a[0] - b[0]);
let cur = 0;
for (const [lo, hi] of iv) {
if (lo - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p1[0] + ux * lo, p1[1] + uy * lo]);
cur = Math.max(cur, hi);
}
if (len - cur > eps) out.push([p1[0] + ux * cur, p1[1] + uy * cur, p2[0], p2[1]]);
}
return out;
}
/** Room outline pieces with the given collinear stretches removed. */
export function outlineWithout(poly: number[][], cuts: number[][], eps = 1e-6): number[][] {
const edges: number[][] = [];
for (let i = 0; i < poly.length; i++) {
const p1 = poly[i], p2 = poly[(i + 1) % poly.length];
edges.push([p1[0], p1[1], p2[0], p2[1]]);
}
return cutSegments(edges, cuts, eps);
}
/**
* Legacy static URLs (/houseplan_files/plans|files/...) are rewritten to the
* authenticated content endpoint (audit B1). Applied on READ, so stored
* configs keep working without a migration.
*/
/**
* How many paths one `houseplan/content/sign` call may carry. The backend caps
* the request at the same number and silently ignores the rest, so a client
* that sends more gets a partial answer with no way to tell which paths were
* dropped — on a wall tablet those entries then expire for good (review R2-2).
* Keep in sync with MAX_SIGN_PATHS in custom_components/houseplan/const.py.
*/
export const MAX_SIGN_PATHS = 200;
/** A signature is valid for 24 h; refresh once two thirds of it is gone. */
export const SIGN_TTL_MS = 24 * 3600 * 1000;
export const SIGN_REFRESH_MS = 16 * 3600 * 1000;
/** Split a list into chunks of at most `size` (used for signing batches). */
export function chunk<T>(items: T[], size: number): T[][] {
const n = Math.max(1, Math.floor(size));
const out: T[][] = [];
for (let i = 0; i < items.length; i += n) out.push(items.slice(i, i + n));
return out;
}
/**
* Every content url the given config still refers to, normalised through
* `contentUrl`. The signature cache is pruned to this set: without it the cache
* only grows — replaced plans and deleted attachments keep their entries, and
* the total can cross the per-request cap even when the live config is small.
*/
export function referencedContentUrls(cfg: any): Set<string> {
const out = new Set<string>();
const add = (u: unknown) => {
if (typeof u !== 'string' || !u) return;
const c = contentUrl(u);
if (c.startsWith('/api/houseplan/content/')) out.add(c);
};
for (const sp of cfg?.spaces || []) {
add(sp?.plan_url);
for (const m of sp?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
}
for (const m of cfg?.markers || []) for (const p of m?.pdfs || []) add(p?.url);
return out;
}
export function contentUrl(url: string | null | undefined): string {
if (!url) return '';
if (url.startsWith('/houseplan_files/plans/')) {
return '/api/houseplan/content/plans/_/' + url.slice('/houseplan_files/plans/'.length);
}
if (url.startsWith('/houseplan_files/files/')) {
return '/api/houseplan/content/files/' + url.slice('/houseplan_files/files/'.length);
}
return url;
}
// ---------------- room-level settings (tier 3) ----------------
/**
* Effective fill mode of a room: its own override wins, otherwise the space's.
* Four settings tiers (owner's principle, 2026-07-26): global > space > room >
* device; the more specific tier overrides the more general one. A room may
* override even in a glow space ('none' pulls it out of the darkness).
*/
export function roomFillModeOf(
spaceFill: RoomFillMode,
room: { settings?: { fill_mode?: string | null } | null } | null | undefined,
): RoomFillMode {
const o = room?.settings?.fill_mode;
return o === 'none' || o === 'lqi' || o === 'light' || o === 'temp' ? o : spaceFill;
}
// ---------------- marker files ----------------
/**
* Rewrite attached-file urls when a marker's id changes (rebinding): the
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
*/
export function migratePdfUrls<T extends { url: string }>(
pdfs: T[], oldId: string, newId: string, mapping?: Record<string, string>,
): T[] {
if (!oldId || !newId || oldId === newId) return pdfs;
const from = '/files/' + oldId + '/';
const to = '/files/' + newId + '/';
return pdfs.map((p) => {
if (!p.url.includes(from)) return p;
const tail = p.url.split(from)[1] || '';
const [name, query] = [tail.split('?')[0], tail.includes('?') ? '?' + tail.split('?')[1] : ''];
if (mapping) {
// review CR-3: rewrite ONLY files the server confirmed it copied, and use
// the name it actually wrote (collisions get a unique name). A url that
// was not copied keeps pointing at the still-existing old folder.
const dst = mapping[decodeURIComponent(name)] ?? mapping[name];
if (!dst) return p;
return { ...p, url: p.url.split(from + name)[0] + to + encodeURIComponent(dst) + query };
}
return { ...p, url: p.url.split(from).join(to) };
});
}
// ---------------- kiosk gestures ----------------
/**
* Kiosk swipe: which neighbouring space a horizontal gesture selects.
* Only fires at 1:1 zoom (owner's decision — when zoomed the gesture pans),
* needs a mostly-horizontal move of at least minPx. Wraps around.
*/
export function swipeTarget(
dx: number, dy: number, zoom: number, spaceIds: string[], current: string, minPx = 60,
): string | null {
if (zoom > 1.001 || spaceIds.length < 2) return null;
if (Math.abs(dx) < minPx || Math.abs(dx) < Math.abs(dy) * 1.5) return null;
const i = spaceIds.indexOf(current);
if (i < 0) return null;
const n = spaceIds.length;
return dx < 0 ? spaceIds[(i + 1) % n] : spaceIds[(i - 1 + n) % n];
}
/** Clamp a per-screen size multiplier (icons / room-card font). */
export function clampScale(v: unknown, def = 1): number {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.min(3, Math.max(0.5, n)) : def;
}
// ---------------- alignment guides ----------------
export interface AlignGuide {
axis: 'x' | 'y';
/** The candidate's aligned coordinate (x for axis x, y for axis y). */
at: number;
/** The candidate point the guide is drawn from. */
from: number[];
}
/**
* Alignment guides for a point being drawn/dragged: the nearest candidate
* sharing its X and the nearest sharing its Y (within tol). Indication only —
* no magnetism, the grid owns the actual position (owner's decision).
*/
export function alignGuides(pt: number[], candidates: number[][], tol: number): AlignGuide[] {
let bestX: { d: number; c: number[] } | null = null;
let bestY: { d: number; c: number[] } | null = null;
for (const c of candidates) {
const same = Math.abs(c[0] - pt[0]) < 1e-6 && Math.abs(c[1] - pt[1]) < 1e-6;
if (same) continue;
if (Math.abs(c[0] - pt[0]) <= tol) {
const d = Math.abs(c[1] - pt[1]);
if (d > 1e-6 && (!bestX || d < bestX.d)) bestX = { d, c };
}
if (Math.abs(c[1] - pt[1]) <= tol) {
const d = Math.abs(c[0] - pt[0]);
if (d > 1e-6 && (!bestY || d < bestY.d)) bestY = { d, c };
}
}
const out: AlignGuide[] = [];
if (bestX) out.push({ axis: 'x', at: bestX.c[0], from: bestX.c });
if (bestY) out.push({ axis: 'y', at: bestY.c[1], from: bestY.c });
return out;
}
/** Segment angle in degrees, normalized to [0, 360). */
export function segmentAngle(a: number[], b: number[]): number {
let deg = (Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI;
if (deg < 0) deg += 360;
return deg;
}
/** Is the angle a multiple of 45° (within tolerance)? */
export function is45(deg: number, tol = 0.5): boolean {
const m = ((deg % 45) + 45) % 45;
return m <= tol || 45 - m <= tol;
}
/** Distance from a point to a segment [x1,y1,x2,y2]. */
export function distToSegment(p: number[], s: number[]): number {
const dx = s[2] - s[0], dy = s[3] - s[1];
const len2 = dx * dx + dy * dy;
if (!len2) return Math.hypot(p[0] - s[0], p[1] - s[1]);
let t = ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (s[0] + t * dx), p[1] - (s[1] + t * dy));
}
/** Device classes whose active state is an emergency, not a status. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
/**
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, or a
* siren that is on. Unavailable/unknown never alarm (an outage is not a fire).
*/
export function isAlarmState(
domain: string | null | undefined,
deviceClass: string | null | undefined,
state: string | null | undefined,
): boolean {
if (state !== 'on') return false;
if (domain === 'siren') return true;
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
}
// ---------------- room references ----------------
/**
* Parse a marker's room reference. Two shapes:
* - `space#area` — a room bound to an HA area (historical form)
* - `space#@roomId` — a room WITHOUT an area (sub-area rooms, issue #3):
* devices are placed into it manually, by room id.
*/
export function parseRoomRef(
v: string | null | undefined,
): { space: string; area: string | null; roomId: string | null } | null {
if (!v) return null;
const i = v.indexOf('#');
if (i <= 0) return null;
const space = v.slice(0, i);
const rest = v.slice(i + 1);
if (!rest) return null;
if (rest.startsWith('@')) {
const roomId = rest.slice(1);
return roomId ? { space, area: null, roomId } : null;
}
return { space, area: rest, roomId: null };
}
// ---------------- new-device detection ----------------
/**
* Which auto-appearing device ids are NEW against the known baseline.
* No baseline yet (first run / upgrade) → nothing is new: every current id
* becomes the baseline silently, so an update never floods the plan with dots.
*/
export function diffNewDevices(
currentIds: string[],
known: string[] | null | undefined,
): { fresh: string[]; known: string[] } {
if (!Array.isArray(known)) return { fresh: [], known: [...currentIds] };
const knownSet = new Set(known);
const fresh = currentIds.filter((id) => !knownSet.has(id));
return { fresh, known: fresh.length ? [...known, ...fresh] : known };
}
+4 -1
View File
@@ -29,6 +29,8 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
{ pattern: 'чайник|kettle|термопот', icon: 'mdi:kettle' },
{ pattern: 'сауна|sauna|harvia|парная|парилк', icon: 'mdi:hot-tub' },
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
@@ -46,7 +48,8 @@ export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'стиральн|washer|washing', icon: 'mdi:washing-machine' },
{ pattern: 'сушилк|dryer', icon: 'mdi:tumble-dryer' },
{ pattern: 'пылесос|vacuum|dreame|roborock', icon: 'mdi:robot-vacuum' },
{ pattern: 'soundbar|колонк|станц|speaker', icon: 'mdi:soundbar' },
{ pattern: 'soundbar', icon: 'mdi:soundbar' },
{ pattern: 'колонк|станц|speaker|яндекс|yandex|алиса|alice', icon: 'mdi:speaker' },
{ pattern: 'tv|телевизор|hyundaitv|mitv|television', icon: 'mdi:television' },
{ pattern: 'keenetic|роутер|router|mesh|access point', icon: 'mdi:router-wireless' },
{ pattern: 'ибп|ups|kirpich', icon: 'mdi:battery-charging-high' },
+127
View File
@@ -0,0 +1,127 @@
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
/**
* Signed urls for the authenticated content endpoint, shared by both cards.
*
* A browser cannot authenticate an `<image href>` or an `<a href>`: Home
* Assistant takes a Bearer header or an `authSig` signed path, and an element
* sends neither. So whatever is about to be displayed has to be signed first.
*
* This used to be implemented twice — and the second copy (houseplan-space-card)
* signed correctly but never handed the result to its renderer, so the plan
* background asked for the raw protected url and got a 401 on every render
* (review R3-2). One implementation, one set of rules:
*
* - requests are chunked to MAX_SIGN_PATHS, the cap the backend silently
* applies (an oversized call comes back partial with no way to tell what was
* dropped, and those entries then expire for good);
* - every entry carries the time it was issued: past SIGN_REFRESH_MS a
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
* the entry is dropped rather than served (it would 401 and raise a
* failed-login warning for the viewer's own IP);
* - `pending` is always released, so one failed request does not wedge a url
* forever.
*/
export class ContentSigner {
private signed: Record<string, { url: string; at: number }> = {};
private pending = new Set<string>();
private batchTimer?: ReturnType<typeof setTimeout>;
private resignTimer?: ReturnType<typeof setInterval>;
/**
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
* @param now injectable clock — the tests need to age a signature
*/
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
start(hass: () => any, referenced: () => Set<string>): void {
this.stopTimer();
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
}
/** Release every timer; the cache survives a reconnect, the timers must not. */
dispose(): void {
this.stopTimer();
clearTimeout(this.batchTimer);
this.pending.clear();
}
private stopTimer(): void {
if (this.resignTimer !== undefined) clearInterval(this.resignTimer);
this.resignTimer = undefined;
}
/** The url to put in the DOM: a signature we hold and still trust, else ''. */
display(hass: any, url: string | null | undefined): string {
const u = contentUrl(url);
if (!u.startsWith('/api/houseplan/content/')) return u;
const hit = this.signed[u];
const age = hit ? this.now() - hit.at : Infinity;
if (age < SIGN_REFRESH_MS) return hit.url;
if (age < SIGN_TTL_MS) {
// aging but still valid: keep showing it while a fresh one is fetched
this.request(hass, u);
return hit.url;
}
if (hit) delete this.signed[u];
this.request(hass, u);
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
// returns 401 and Home Assistant raises a "failed login attempt".
return '';
}
private request(hass: any, url: string): void {
if (this.pending.has(url) || !hass?.callWS) return;
this.pending.add(url);
clearTimeout(this.batchTimer);
// batch: switching space asks for several urls in the same tick
this.batchTimer = setTimeout(() => {
const paths = [...this.pending];
this.pending.clear();
this.sign(hass, paths);
}, 30);
}
private sign(hass: any, paths: string[]): void {
if (!paths.length || !hass?.callWS) return;
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
hass
.callWS({ type: 'houseplan/content/sign', paths: batch })
.then((r: any) => {
if (!r?.urls) return;
const at = this.now();
const next = { ...this.signed };
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
this.signed = next;
this.onUpdate();
})
.catch(() => undefined) // a retry happens on the next render
.finally(() => {
// never leave a url wedged in `pending`: the first failure would
// otherwise make it unrequestable for the life of the page (R3-2)
for (const p of batch) this.pending.delete(p);
});
}
}
/**
* Re-sign what is still in use. A wall tablet outlives a signature, and an
* entry for a plan replaced months ago must not consume a slot in the capped
* request — so prune to the urls the live config still references.
*/
resign(hass: any, referenced: Set<string>): void {
const now = this.now();
const kept: Record<string, { url: string; at: number }> = {};
for (const [k, v] of Object.entries(this.signed)) {
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
}
this.signed = kept;
this.sign(hass, Object.keys(kept));
}
/** Test/debug view of the cache. */
get entries(): Record<string, { url: string; at: number }> {
return this.signed;
}
}
+19
View File
@@ -10,6 +10,8 @@ import { cardStyles } from './styles';
import { renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
import { ContentSigner } from './signing';
import { referencedContentUrls } from './logic';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
@@ -73,11 +75,14 @@ class HouseplanSpaceCard extends LitElement {
this._snap = null;
this.requestUpdate();
});
// a dashboard on a wall tablet outlives a 24 h signature
this._signer.start(() => this.hass, () => this._referenced());
}
public disconnectedCallback(): void {
this._unsub?.();
this._unsub = undefined;
this._signer.dispose();
super.disconnectedCallback();
}
@@ -121,6 +126,18 @@ class HouseplanSpaceCard extends LitElement {
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
}
/**
* Same signer as the main card (review R3-2). The previous copy here signed
* the url and then threw it away: `getCardSize()` mutated a throwaway model
* while `render()` rebuilt its own from the config, so the <image> kept
* asking for the raw protected path and got a 401 on every render.
*/
private _signer = new ContentSigner(() => this.requestUpdate());
private _referenced(): Set<string> {
return referencedContentUrls(this._snap?.config);
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
const cfg = this._snap?.config;
@@ -136,6 +153,8 @@ class HouseplanSpaceCard extends LitElement {
spaceId,
iconSize: this._config.icon_size,
lang: this._lang,
// resolved at render time: a url baked in earlier would be the unsigned one
displayUrl: (raw) => this._signer.display(this.hass, raw),
});
if (!stage) {
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
+2 -2
View File
@@ -3,7 +3,7 @@
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
* mirror the full card's private math.
*/
import { declump } from './logic';
import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs
@@ -30,7 +30,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: s.id,
title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
+15 -5
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillColor } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
@@ -25,6 +25,13 @@ export interface StaticRenderOpts {
spaceId: string;
iconSize?: number;
lang: Lang;
/**
* Resolve a stored content url to what the DOM may actually request — the
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
* Returning '' means "not signed yet": the caller must render no <image>
* rather than an unsigned one, which would 401 (review R3-2).
*/
displayUrl?: (raw: string) => string;
}
/**
@@ -71,18 +78,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = r.area
? roomFillColor(
? roomFillStyle(
disp.fill,
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
disp.tempMin,
disp.tempMax,
fillColorsOf(o.cfg?.settings),
)
: null;
if (fillC) {
cls += ' filled';
parts.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else {
parts.push('--room-fill:transparent', '--room-fill-op:0');
}
@@ -117,11 +125,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
})
: [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
${roomShapes}
</svg>

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