Compare commits

...
65 Commits
Author SHA1 Message Date
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
66 changed files with 8004 additions and 929 deletions
+51 -5
View File
@@ -1,11 +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)
![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.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
🇷🇺 [Документация на русском](README.ru.md)
**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.
---
## What it is and why
@@ -49,6 +78,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 +165,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 +212,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)
+50 -5
View File
@@ -1,13 +1,41 @@
# 🏠 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)
![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)
**Главное**
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
температурная заливка по комфортным границам.
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
никогда случайным тапом.
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
этажами, автокарусель, свои размеры на каждом экране.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
---
## Что это и зачем
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
@@ -49,6 +77,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 +213,7 @@ title: План дома
### Шаг 5. Расставьте значки по местам
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку.
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.24.2"
VERSION = "1.41.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.24.2"
"version": "1.41.1"
}
+36 -1
View File
@@ -68,6 +68,7 @@ ROOM_SCHEMA = vol.All(
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str],
vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float),
@@ -84,14 +85,41 @@ 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,
},
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,
@@ -101,6 +129,7 @@ SPACE_SCHEMA = vol.Schema(
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("decor"): [DECOR_SCHEMA],
vol.Optional("openings"): [
vol.Schema(
{
@@ -140,6 +169,9 @@ 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("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),
@@ -157,6 +189,9 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
+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();
+56
View File
@@ -0,0 +1,56 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+50
View File
@@ -0,0 +1,50 @@
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;
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+51
View File
@@ -0,0 +1,51 @@
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;
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+61
View File
@@ -0,0 +1,61 @@
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(); } };
// два подопытных 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+74
View File
@@ -0,0 +1,74 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+39
View File
@@ -0,0 +1,39 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+35
View File
@@ -0,0 +1,35 @@
import { launch } 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+35
View File
@@ -0,0 +1,35 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+105
View File
@@ -0,0 +1,105 @@
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 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;
// 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+37
View File
@@ -0,0 +1,37 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+18
View File
@@ -0,0 +1,18 @@
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 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;
});
console.log(JSON.stringify(res));
await browser.close();
+31
View File
@@ -0,0 +1,31 @@
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;
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+47
View File
@@ -0,0 +1,47 @@
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;
// добавить открывание на 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+59
View File
@@ -0,0 +1,59 @@
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;
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+73
View File
@@ -0,0 +1,73 @@
import { launch } 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+34
View File
@@ -0,0 +1,34 @@
import { launch } 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+51
View File
@@ -0,0 +1,51 @@
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]) };
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+46
View File
@@ -0,0 +1,46 @@
import { launch } 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+24
View File
@@ -0,0 +1,24 @@
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;
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;
});
console.log(JSON.stringify(res));
await browser.close();
+3 -3
View File
@@ -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
View File
@@ -0,0 +1,79 @@
// UX modes shell (v1.25.0): view is display-only; plan/devices gate the tools.
import { launch } 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;
console.log(JSON.stringify(out, null, 1));
await browser.close();
+35
View File
@@ -0,0 +1,35 @@
import { launch } 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+32
View File
@@ -0,0 +1,32 @@
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;
// 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+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;
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+74
View File
@@ -0,0 +1,74 @@
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;
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+36
View File
@@ -0,0 +1,36 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+32
View File
@@ -0,0 +1,32 @@
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;
// 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+61
View File
@@ -0,0 +1,61 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+38
View File
@@ -0,0 +1,38 @@
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;
// включить имена комнат
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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+1 -1
View File
@@ -5,7 +5,7 @@ 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)
+55
View File
@@ -0,0 +1,55 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+34
View File
@@ -0,0 +1,34 @@
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 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+35
View File
@@ -0,0 +1,35 @@
import { launch } 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;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+54
View File
@@ -0,0 +1,54 @@
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;
// 1) в селекте действий 3 опции, дефолт «Карточка устройства»
c._setMode('devices'); await c.updateComplete;
const dev = c._devices.find((d) => !d.virtual && d.primary);
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;
const plain = c._devices.find((d) => !d.virtual && d.primary?.startsWith('light.') && !d.tapAction);
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-light';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+815 -206
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -212,3 +212,34 @@ 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.
+370
View File
@@ -1,5 +1,375 @@
# Changelog
## 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
+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
+97
View File
@@ -0,0 +1,97 @@
# 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 (explicit button only, never a plan tap) | **Closed** |
| J4 | "From zero to a working plan in one evening, no Inkscape/YAML" — image/PDF/draw, floors-import wizard, room polygons bound to areas, curated auto-placement, editable icon rules | **Closed**; onboarding polish is *partial* (no registry-driven room suggestions) |
| 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).
- 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.
**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.
+39 -9
View File
@@ -9,18 +9,42 @@
> (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-24)
| Item | State |
|---|---|
| Version | **v1.24.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.41.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.41.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 111 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| 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 +153,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.
+184
View File
@@ -29,6 +29,28 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] 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]
## Modes (v1.25.0) ★
- [ ] The card always loads in **View**; edit modes are never restored [auto]
- [ ] 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 [auto]
- [ ] View header: space tabs + count + zoom + mode tabs, ZERO edit buttons [auto]
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
labels/openings draggable; orange stage frame [auto]
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
buttons; accent stage frame [auto]
- [ ] 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 [auto]
- [ ] 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 [auto]
- [ ] Device icons in View show a pointer cursor (no grab); grab only in the
Devices mode [auto]
- [ ] In Plan an opening is interactive: grab cursor, hover outline, drag along
walls, click (any tool) opens its properties [auto]
## Onboarding ★
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
@@ -110,6 +132,165 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] ↺ 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
- [ ] 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 [auto]
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
as its body, small badges hidden; non-numeric fallback keeps the icon [auto]
- [ ] 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 [auto]
- [ ] 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 [auto]; reduced-motion static
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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 [auto]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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 [auto]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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 [auto]
- [ ] General settings gear (v1.30.3): the header cog is visible in every mode
(admins), opens the palette dialog from View too [auto]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] 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]
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
## Device dialog (markers) ★
@@ -117,6 +298,9 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] 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 [auto]
- [ ] 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]
+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

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.23.2",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.23.2",
"version": "1.34.0",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.24.2",
"version": "1.41.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+59 -9
View File
@@ -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). */
@@ -343,6 +356,43 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
return seen ? 'off' : 'none';
}
/** 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);
}
/** 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}
+1530 -186
View File
File diff suppressed because it is too large Load Diff
+77 -9
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",
@@ -115,7 +114,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 +122,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 +176,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",
@@ -239,5 +234,78 @@
"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"
"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)"
}
+78 -10
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": "Добавить пространство",
@@ -115,7 +114,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 +122,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 +176,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 уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
@@ -239,5 +234,78 @@
"gs.reset": "Сбросить к умолчаниям",
"gs.saved": "Общие настройки сохранены",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
"gs.light_none": "Нет источников света"
"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 = выкл)"
}
+473 -17
View File
@@ -241,19 +241,47 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
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 the centroid strictness both ways
const c = [
inner.reduce((s, p) => s + p[0], 0) / inner.length,
inner.reduce((s, p) => s + p[1], 0) / inner.length,
];
return 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,27 +338,50 @@ 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);
const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
const 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;
return [p1, p2];
@@ -441,7 +492,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 +539,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 {
@@ -497,6 +552,11 @@ export interface SpaceDisplay {
tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
showLqi: boolean | null;
/** Room-card metrics under the room name (all default off). */
labelTemp: boolean;
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -513,10 +573,14 @@ 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,
labelTemp: s.label_temp === true,
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
};
}
@@ -538,6 +602,9 @@ export interface FillColors {
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 = {
@@ -549,6 +616,8 @@ export const DEFAULT_FILL_COLORS: FillColors = {
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;
@@ -649,3 +718,390 @@ 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);
}
// ---------------- 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 };
}
+2 -1
View File
@@ -46,7 +46,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' },
+400 -23
View File
@@ -80,6 +80,8 @@ export const cardStyles = css`
border: 0;
background: transparent;
color: var(--hp-muted);
display: inline-flex;
align-items: center;
padding: 6px 13px;
border-radius: 8px;
font-size: 13px;
@@ -187,7 +189,7 @@ export const cardStyles = css`
}
.room {
transition: 0.12s;
cursor: pointer;
cursor: default; /* v1.40.1: rooms are not clickable — the label's link icon is */
}
.room.overlay {
fill: transparent;
@@ -242,22 +244,26 @@ export const cardStyles = css`
transition: opacity 0.15s;
pointer-events: none;
}
g.opening:hover .op-outline {
.stage.markup g.opening:hover .op-outline {
opacity: 0.9;
}
/* openings are pure status graphics outside Plan mode: no cursor, no hover,
no hit target — View must not interact with them at all */
.op-hit {
fill: transparent;
cursor: grab;
pointer-events: auto;
touch-action: none; /* drags, not scrolls, on touch */
}
.op-hit:active {
cursor: grabbing;
pointer-events: none;
cursor: default;
}
.stage.markup .op-hit {
pointer-events: none; /* markup clicks go to the stage tools */
pointer-events: auto;
cursor: grab;
touch-action: none; /* drags, not scrolls, on touch */
}
.stage.markup .op-hit:active {
cursor: grabbing;
}
.oplock {
pointer-events: none; /* inert while editing; clickable in View (rule below) */
position: absolute;
transform: translate(-50%, -50%);
width: calc(var(--icon-size, 2.5cqw) * 0.62);
@@ -268,9 +274,11 @@ export const cardStyles = css`
justify-content: center;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
z-index: 1;
}
.stage.mode-view .oplock {
pointer-events: auto;
cursor: pointer;
z-index: 1;
}
.oplock ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
@@ -280,6 +288,18 @@ export const cardStyles = css`
.oplock.locked { color: #66d17a; border-color: #66d17a; }
.oplock.unlocked { color: var(--hp-open); border-color: var(--hp-open); }
.oplock.unknown { color: var(--hp-muted); }
.btn.lockact {
width: 100%;
justify-content: center;
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.btn.lockact.warn {
color: var(--error-color, #d33);
border-color: var(--error-color, #d33);
}
.oprow {
display: flex;
align-items: center;
@@ -341,23 +361,169 @@ export const cardStyles = css`
.ripple.active i:nth-child(n + 2) { display: none; }
}
.roomlabel {
pointer-events: none; /* draggable only in plan mode (rule below) */
position: absolute;
transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5 * var(--rl-scale, 1) * var(--rl-font, 1));
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: grab;
pointer-events: auto;
user-select: none;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15em;
text-align: center;
}
.rlname { display: inline-flex; align-items: center; gap: 0.25em; }
.rlgo {
--mdc-icon-size: 0.85em;
display: inline-flex;
opacity: 0.55;
}
.stage.mode-view .rlgo {
pointer-events: auto;
cursor: pointer;
}
.stage.mode-view .rlgo:hover { opacity: 1; }
.roomlabel .rlmetrics {
display: flex;
align-items: center;
gap: 0.55em;
font-size: 0.62em;
font-weight: 600;
letter-spacing: 0.02em;
opacity: 0.9;
}
.roomlabel .rlm {
display: inline-flex;
align-items: center;
gap: 0.12em;
}
.roomlabel .rlm ha-icon {
--mdc-icon-size: 1.05em;
display: inline-flex;
}
.roomlabel .rlm.lit { opacity: 1; }
.bindharow {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.bindharow .entcheck { opacity: 0.9; }
.dropbtn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
border: 1px solid var(--hp-muted);
border-radius: 8px;
background: transparent;
color: var(--hp-txt);
padding: 8px 10px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
margin-top: 6px;
}
.dropbtn .ref { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
.dropbtn ha-icon { --mdc-icon-size: 18px; margin-left: 4px; }
.dropbtn.open { border-color: var(--hp-accent); }
.droppanel {
border: 1px solid var(--hp-accent);
border-top: none;
border-radius: 0 0 8px 8px;
padding: 6px;
margin-top: -4px;
}
.ctrlchips { display: flex; flex-wrap: wrap; gap: 5px; margin: 4px 0; }
.ctrlchip {
display: inline-flex; align-items: center; gap: 4px;
background: var(--hp-accent); color: var(--text-primary-color, #fff);
border-radius: 12px; padding: 3px 8px; font-size: 12px;
}
.ctrlchip ha-icon { --mdc-icon-size: 14px; cursor: pointer; }
.ctrllist { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
.ctrlopt {
display: flex; align-items: center; gap: 7px; text-align: left;
border: 0; background: transparent; color: var(--hp-txt);
padding: 5px 7px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px;
}
.ctrlopt:hover { background: var(--secondary-background-color, rgba(128,128,128,0.15)); }
.ctrlopt .sub { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
.ctrlopt ha-icon { --mdc-icon-size: 16px; }
.ctrlstates { display: flex; flex-direction: column; gap: 3px; }
.ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); }
.ctrlstate.on { color: var(--hp-txt); }
.ctrlstate ha-icon { --mdc-icon-size: 15px; }
.iconauto {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin: 4px 0 0;
}
.iconauto ha-icon { --mdc-icon-size: 18px; }
.rlhandle {
display: none;
position: absolute;
width: 9px;
height: 9px;
border-radius: 2px;
background: var(--hp-accent);
border: 1px solid var(--card-background-color, #fff);
z-index: 2;
}
.rlhandle.tl { left: -6px; top: -6px; cursor: nwse-resize; }
.rlhandle.br { right: -6px; bottom: -6px; cursor: nwse-resize; }
.rlhandle.tr { right: -6px; top: -6px; cursor: nesw-resize; }
.rlhandle.bl { left: -6px; bottom: -6px; cursor: nesw-resize; }
.stage.markup .roomlabel:hover .rlhandle { display: block; }
.stage.markup .roomlabel { pointer-events: auto; }
.roomlabel:active { cursor: grabbing; }
.measurelayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.alignline {
stroke: var(--hp-accent);
stroke-width: 1.2;
stroke-dasharray: 4 4;
pointer-events: none;
opacity: 0.9;
}
.aligndot {
fill: var(--hp-accent);
pointer-events: none;
}
.measurelabel.on45 {
color: #4bd28f;
border-color: #4bd28f;
}
.hdr.kioskhide { display: none; }
.kioskdots {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 5;
pointer-events: none;
}
.kdot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--hp-muted);
opacity: 0.55;
}
.kdot.on { background: var(--hp-accent); opacity: 1; }
.measurelabel {
position: absolute;
transform: translate(12px, -150%);
@@ -372,6 +538,68 @@ export const cardStyles = css`
user-select: none;
z-index: 3;
}
/* decor (background) layer */
.decorlayer .dshape { pointer-events: none; }
.stage.mode-decor .decorlayer .dshape {
pointer-events: visiblePainted;
cursor: pointer;
}
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
.decorlayer .ddraft {
opacity: 0.75;
stroke-dasharray: 6 5;
pointer-events: none;
}
.decorlayer text {
font-weight: 600;
user-select: none;
dominant-baseline: middle;
text-anchor: middle;
}
.stage.mode-decor {
outline: 2px solid #26a69a;
outline-offset: -2px;
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
}
.stage.mode-decor .room, .stage.mode-decor .devlayer { pointer-events: none; }
.stage.mode-decor .oplock { pointer-events: none; }
/* decor mode: everything but the decor itself fades back */
.stage.mode-decor .room,
.stage.mode-decor .devlayer,
.stage.mode-decor .opening,
.stage.mode-decor .rlabel {
opacity: 0.35;
}
.decorbar .dcolor {
width: 30px; height: 26px; padding: 0; border: none; background: none; cursor: pointer;
}
.decorbar .dwidth {
font-family: inherit; font-size: 12px; border-radius: 6px;
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
padding: 3px 5px;
}
.decorbar .dfill {
display: inline-flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer;
}
.opghost {
stroke: var(--hp-open, #ff9800);
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 7 6;
opacity: 0.85;
pointer-events: none;
}
.opghost-dot {
fill: var(--hp-open, #ff9800);
opacity: 0.85;
pointer-events: none;
}
.rlabel {
fill: var(--hp-muted);
font-size: 15px;
@@ -385,21 +613,123 @@ export const cardStyles = css`
.stage.markup {
cursor: crosshair;
}
/* room-picking stages: merge (both clicks) and split before a room is chosen */
.stage.markup.tool-merge,
.stage.markup.tool-split.pickstage,
.stage.markup.tool-delroom {
cursor: pointer;
}
/* open-wall tool: default until a shared wall is under the cursor */
.stage.markup.tool-openwall { cursor: default; }
.stage.markup.tool-openwall.wallhot { cursor: pointer; }
.openwall {
stroke: var(--ow-stroke, var(--hp-muted));
stroke-width: 2.5;
stroke-dasharray: 7 7;
stroke-linecap: butt;
pointer-events: none;
opacity: 0.9;
}
/* rooms with open stretches: the polygon's own stroke is fully off
(hover included) — the trimmed .room-outline path draws the walls */
.room.noedge {
stroke-opacity: 0 !important;
}
.room-outline {
fill: none;
stroke-width: 2.5;
pointer-events: none;
}
/* Plan editor: trimmed outlines use the markup blue */
.room-outline.outlined {
stroke: rgba(62, 166, 255, 0.55);
stroke-opacity: 1;
}
.openwalls.hot .openwall {
stroke: #ffc14d;
opacity: 1;
}
.openwall-preview {
stroke: #ffc14d;
stroke-width: 5;
stroke-dasharray: 7 7;
stroke-linecap: round;
pointer-events: none;
opacity: 0.95;
}
/* an already-open boundary under the cursor: the click will CLOSE it */
.openwall-preview.willclose {
stroke: #f25a4a;
stroke-dasharray: none;
}
.stage.markup .room {
pointer-events: none;
}
.stage.markup .devlayer {
display: none; /* in markup mode icons must not get in the way */
.stage.markup .devlayer .dev {
display: none; /* in plan mode the icons do not get in the way; labels stay */
}
.room.picked {
stroke: #ffc14d;
stroke-width: 3;
fill: rgba(255, 193, 77, 0.25);
/* mode frames: the edit modes are visible at a glance */
.stage.mode-plan {
outline: 2px solid #ffc14d;
outline-offset: -2px;
}
.stage.mode-devices {
outline: 2px solid var(--hp-accent);
outline-offset: -2px;
}
.modes {
display: inline-flex;
gap: 2px;
background: rgba(127, 127, 127, 0.12);
border-radius: 10px;
padding: 3px;
}
.modetab {
display: inline-flex;
align-items: center;
gap: 5px;
border: 0;
background: transparent;
color: var(--hp-muted);
padding: 5px 10px;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.modetab ha-icon { --mdc-icon-size: 15px; }
.modetab .closex {
--mdc-icon-size: 13px;
display: inline-flex;
align-items: center;
margin-left: 2px;
opacity: 0.75;
cursor: pointer;
border-radius: 4px;
}
.modetab .closex:hover { opacity: 1; }
.editbar .barclose {
padding: 4px 6px;
margin-left: 6px;
}
.modetab.active {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
}
@media (max-width: 720px) {
.modetab .ml { display: none; }
}
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
}
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
.room.picked {
stroke: #ffc14d;
stroke-width: 3;
fill: rgba(255, 193, 77, 0.25);
}
.griddot {
fill: var(--hp-accent);
opacity: 0.75;
@@ -446,6 +776,53 @@ export const cardStyles = css`
.namein {
width: 130px;
}
.dev.valonly {
width: auto;
min-width: var(--dev-size, var(--icon-size, 2.5cqw));
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.16);
}
.dev.valonly .valtext {
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-weight: 700;
white-space: nowrap;
}
/* RGB lights: the bulb takes the light's actual color */
.dev.rgb ha-icon { color: var(--light-color); }
.dev.rgb.on {
box-shadow: 0 0 10px var(--light-color);
border-color: var(--light-color);
background: var(--hp-bg);
color: var(--light-color);
}
/* alarms pulse red over everything */
.dev.alarm::after {
content: '';
position: absolute;
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
border: 3px solid #f25a4a;
border-radius: 50%;
animation: hp-alarm 1s ease-out infinite;
pointer-events: none;
}
@keyframes hp-alarm {
0% { transform: scale(0.7); opacity: 1; }
100% { transform: scale(1.25); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.dev.alarm::after { animation: none; opacity: 0.9; }
}
.dev .newdot {
position: absolute;
top: calc(var(--icon-size, 2.5cqw) * -0.12);
right: calc(var(--icon-size, 2.5cqw) * -0.12);
width: calc(var(--icon-size, 2.5cqw) * 0.34);
height: calc(var(--icon-size, 2.5cqw) * 0.34);
border-radius: 50%;
background: #f0301f;
border: 2px solid var(--card-background-color, var(--hp-bg));
pointer-events: none;
z-index: 2;
}
.devlayer {
position: absolute;
inset: 0;
@@ -466,7 +843,7 @@ export const cardStyles = css`
align-items: center;
justify-content: center;
color: var(--hp-txt);
cursor: grab;
cursor: pointer;
pointer-events: auto;
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
@@ -479,9 +856,8 @@ export const cardStyles = css`
justify-content: center;
line-height: 0;
}
.dev:active {
cursor: grabbing;
}
.stage.mode-devices .dev { cursor: grab; }
.stage.mode-devices .dev:active { cursor: grabbing; }
.dev:hover {
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
@@ -567,9 +943,10 @@ export const cardStyles = css`
}
.tab .tabedit {
--mdc-icon-size: 13px;
display: inline-flex;
align-items: center;
margin-left: 6px;
opacity: 0.4;
vertical-align: middle;
}
.tab:hover .tabedit {
opacity: 0.9;
+13 -2
View File
@@ -1,6 +1,8 @@
/** Shared types of the House Plan card. */
export interface RoomCfg {
/** Rooms this one has an OPEN (virtual) boundary with — light flows through. */
open_to?: string[] | null;
id?: string;
name: string;
area: string | null;
@@ -38,11 +40,16 @@ export interface Marker {
description?: string | null;
pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
display?: 'badge' | 'ripple' | 'icon_ripple' | null; // how the device is drawn
room_id?: string | null; // manual placement into a room WITHOUT an HA area (sub-area rooms)
display?: 'badge' | 'ripple' | 'icon_ripple' | 'value' | null; // how the device is drawn
ripple_color?: string | null;
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
size?: number | null; // icon size multiplier (default 1)
angle?: number | null; // icon rotation, degrees
/** Entities this icon toggles as a group (wall switch → its lights). */
controls?: string[] | null;
/** Per-source glow radius in cm (glow fill); null = the global default. */
glow_radius_cm?: number | null;
}
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
@@ -101,5 +108,9 @@ export interface CardConfig {
live_states?: boolean;
show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle'
tap_action?: string; // legacy, ignored since v1.38.1
/** Wall-device (kiosk) mode: no header, no editors, swipe between spaces. */
kiosk?: boolean;
/** Kiosk auto-carousel: seconds between space switches, 0/undefined = off. */
cycle?: number;
}
+59 -2
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp } from '../test-build/devices.js';
import { compileIconRules } from '../test-build/rules.js';
import { buildDevices, lightGroups, primaryEntity, lqiFor, tempFor, humFor, areaLights, areaTemp, areaHum, areaLightStats } from '../test-build/devices.js';
import { compileIconRules, iconFor } from '../test-build/rules.js';
/** Minimal fake hass around the pieces buildDevices reads. */
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
@@ -280,3 +280,60 @@ test('buildDevices: humidity badge is gated on device_class, not the icon (name
assert.notEqual(d.icon, 'mdi:water-percent'); // name rule won the icon
assert.equal(d.hum, 45); // …but the humidity value is still shown (gated on device_class)
});
test('areaLightStats: counts unique lights that are on', () => {
const hass = { states: {
'light.a': { state: 'on' }, 'light.b': { state: 'off' }, 'light.c': { state: 'on' },
} };
const devs = [
{ area: 'living', entities: ['light.a', 'sensor.x'] },
{ area: 'living', entities: ['light.b', 'light.a'] }, // light.a duplicated on purpose
{ area: 'other', entities: ['light.c'] },
];
assert.deepEqual(areaLightStats(hass, devs, 'living'), { on: 1, total: 2 });
assert.deepEqual(areaLightStats(hass, devs, 'other'), { on: 1, total: 1 });
assert.equal(areaLightStats(hass, devs, 'empty'), null);
});
test('areaHum: averages climate sensors only, integer %', () => {
const hass = { states: {
'sensor.t1_humidity': { state: '41', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
'sensor.t2_humidity': { state: '46', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
'sensor.fridge_humidity': { state: '90', attributes: { device_class: 'humidity', unit_of_measurement: '%' } },
} };
const devs = [
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1_humidity'] },
{ area: 'living', icon: 'mdi:air-filter', entities: ['sensor.t2_humidity'] },
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_humidity'] }, // curated out
];
assert.equal(areaHum(hass, devs, 'living'), 44);
assert.equal(areaHum(hass, devs, 'nothing'), null);
});
test('primaryEntity: hidden light beats visible config switch (grouped lamps)', () => {
const hass = {
entities: {
'light.lamp': { hidden: true },
'switch.lamp_do_not_disturb': { entity_category: 'config' },
'button.lamp_identify': { entity_category: 'diagnostic' },
},
states: {},
};
assert.equal(
primaryEntity(hass, ['switch.lamp_do_not_disturb', 'light.lamp', 'button.lamp_identify'], 'mdi:lightbulb'),
'light.lamp',
);
// видимая сущность того же домена всё равно приоритетнее скрытой
const hass2 = {
entities: { 'light.a': { hidden: true }, 'light.b': {} },
states: {},
};
assert.equal(primaryEntity(hass2, ['light.a', 'light.b'], 'mdi:lightbulb'), 'light.b');
});
test('icon rules: soundbars vs smart speakers (v1.40.2)', () => {
assert.equal(iconFor('Sony soundbar', ''), 'mdi:soundbar');
assert.equal(iconFor('Яндекс Станция Мини', ''), 'mdi:speaker');
assert.equal(iconFor('Колонка Алисы', ''), 'mdi:speaker');
assert.equal(iconFor('Kitchen speaker', ''), 'mdi:speaker');
});
+312 -8
View File
@@ -3,8 +3,15 @@ import assert from 'node:assert/strict';
import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
splitRoomPath, polyContainsPoly, islandsOf,
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
controlsAction, isControllable,
sharedBoundary, openZoneOf, distToSegment,
outlineWithout,
alignGuides, segmentAngle, is45,
swipeTarget, clampScale,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
@@ -170,8 +177,9 @@ test('icon rules: device_class fallback', () => {
assert.equal(iconFromDeviceClasses([]), null);
});
test('tap action: defaults to info', () => {
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info');
test('tap action: defaults — info everywhere except pure lights (v1.39.0)', () => {
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'toggle'); // лампы кликабельны из коробки
assert.equal(resolveTapAction(undefined, undefined, 'switch'), 'info');
assert.equal(resolveTapAction(null, 'info', 'switch'), 'info');
assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info');
});
@@ -324,14 +332,32 @@ test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', (
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
});
test('roomsOverlap: nested, identical and enclosing outlines all count as overlap', () => {
assert.ok(roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]])); // nested
assert.ok(roomsOverlap(SQ, SQ)); // duplicate
// drawn AROUND an existing room: every vertex outside, no vertex of ours inside it
assert.ok(roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
test('roomsOverlap v1.34: nesting is legal (islands), duplicates and crossings are not', () => {
// nested & enclosing — legal since island rooms
assert.ok(!roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]]));
assert.ok(!roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
// a duplicate outline is still an overlap
assert.ok(roomsOverlap(SQ, SQ));
// a cross: no vertex of either lies inside the other, but the edges cross
assert.ok(roomsOverlap([[0, 0.5], [3, 0.5], [3, 1.5], [0, 1.5]],
[[0.5, -1], [1.5, -1], [1.5, 3], [0.5, 3]]));
// genuine partial overlap is still rejected
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
});
test('polyContainsPoly & islandsOf', () => {
const outer = [[0, 0], [10, 0], [10, 10], [0, 10]];
const col = [[4, 4], [6, 4], [6, 6], [4, 6]];
const tiny = [[4.5, 4.5], [5, 4.5], [5, 5], [4.5, 5]]; // внутри col
const partial = [[8, 8], [12, 8], [12, 12], [8, 12]];
assert.ok(polyContainsPoly(outer, col));
assert.ok(!polyContainsPoly(col, outer));
assert.ok(!polyContainsPoly(outer, outer)); // дубликат — не вложенность
assert.ok(!polyContainsPoly(outer, partial));
// прямые острова: col — да; tiny — нет (он остров col, не outer)
const isl = islandsOf(outer, [col, tiny, partial]);
assert.deepEqual(isl, [col]);
assert.deepEqual(islandsOf(col, [tiny]), [tiny]);
});
test('roomPoly: polygon rooms as-is, legacy rect rooms as four corners', () => {
@@ -483,3 +509,281 @@ test('roomFillStyle light_none: alpha 0 keeps no-fill; a custom color fills ligh
const c = fillColorsOf({ fill_colors: { light_none: { c: '#123456', a: 0.2 } } });
assert.deepEqual(roomFillStyle('light', null, 'none', null, 20, 25, c), { c: '#123456', a: 0.2 });
});
test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never morph', () => {
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'on', false), 'mdi:door-open');
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'off', false), 'mdi:door-closed');
assert.equal(stateIcon('mdi:window-closed', 'binary_sensor', 'window', 'on', false), 'mdi:window-open');
assert.equal(stateIcon('mdi:garage-variant', 'binary_sensor', 'garage_door', 'on', false), 'mdi:garage-open-variant');
assert.equal(stateIcon('mdi:lock', 'lock', undefined, 'unlocked', false), 'mdi:lock-open-variant');
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'on', false), 'mdi:lightbulb-on');
assert.equal(stateIcon('mdi:lightbulb', 'light', undefined, 'off', false), 'mdi:lightbulb');
assert.equal(stateIcon('mdi:door', 'binary_sensor', 'door', 'unavailable', false), 'mdi:door');
assert.equal(stateIcon('mdi:custom', 'lock', undefined, 'unlocked', true), 'mdi:custom'); // user icon wins
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
});
test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () => {
assert.equal(lightColorOf({ state: 'on', attributes: { rgb_color: [255, 20, 40] } }), 'rgb(255, 20, 40)');
assert.equal(lightColorOf({ state: 'off', attributes: { rgb_color: [255, 20, 40] } }), null);
assert.equal(lightColorOf({ state: 'on', attributes: {} }), null);
assert.equal(lightColorOf({ state: 'unavailable', attributes: { rgb_color: [1, 2, 3] } }), null);
assert.equal(lightColorOf(undefined), null);
});
test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () => {
assert.ok(isAlarmState('binary_sensor', 'moisture', 'on'));
assert.ok(isAlarmState('binary_sensor', 'smoke', 'on'));
assert.ok(isAlarmState('siren', undefined, 'on'));
assert.ok(!isAlarmState('binary_sensor', 'door', 'on'));
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
});
test('parseRoomRef: area rooms, sub-area rooms by id, malformed refs', () => {
assert.deepEqual(parseRoomRef('f1#kitchen'), { space: 'f1', area: 'kitchen', roomId: null });
assert.deepEqual(parseRoomRef('f1#@r7'), { space: 'f1', area: null, roomId: 'r7' });
assert.equal(parseRoomRef(''), null);
assert.equal(parseRoomRef('f1#'), null);
assert.equal(parseRoomRef('f1#@'), null);
assert.equal(parseRoomRef('#kitchen'), null);
assert.equal(parseRoomRef(null), null);
});
test('diffNewDevices: first run seeds the baseline silently; later additions are fresh', () => {
const first = diffNewDevices(['a', 'b'], undefined);
assert.deepEqual(first, { fresh: [], known: ['a', 'b'] });
const none = diffNewDevices(['a', 'b'], ['a', 'b']);
assert.deepEqual(none.fresh, []);
assert.equal(none.known.length, 2);
const added = diffNewDevices(['a', 'b', 'c'], ['a', 'b']);
assert.deepEqual(added.fresh, ['c']);
assert.deepEqual(added.known, ['a', 'b', 'c']);
// removed devices stay in known (harmless, keeps the list append-only)
const removed = diffNewDevices(['a'], ['a', 'b']);
assert.deepEqual(removed.fresh, []);
});
test('spaceDisplayOf: room-card metric flags default to off', () => {
const d0 = spaceDisplayOf({ plan_url: 'x', settings: {} });
assert.deepEqual([d0.labelTemp, d0.labelHum, d0.labelLqi, d0.labelLight], [false, false, false, false]);
const d1 = spaceDisplayOf({ plan_url: 'x', settings: { label_temp: true, label_light: true } });
assert.deepEqual([d1.labelTemp, d1.labelHum, d1.labelLqi, d1.labelLight], [true, false, false, true]);
});
test('splitRoomPath: polyline cut of a square', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// Г-образный разрез: верхняя стена (4,0) → внутрь (4,6) → правая стена (10,6)
const parts = splitRoomPath(sq, [[4, 0], [4, 6], [10, 6]], 1e-6);
assert.ok(parts);
const [p1, p2] = parts;
const area = (p) => Math.abs(p.reduce((a, _, i) => {
const [x1, y1] = p[i], [x2, y2] = p[(i + 1) % p.length];
return a + x1 * y2 - x2 * y1;
}, 0)) / 2;
assert.ok(Math.abs(area(p1) + area(p2) - 100) < 1e-6);
// меньшая часть — прямоугольник 6x6 минус... фактически площадь 4*6+... проверим что обе > 0
assert.ok(area(p1) > 0 && area(p2) > 0);
});
test('splitRoomPath: two points == classic straight chord', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
const parts = splitRoomPath(sq, [[5, 0], [5, 10]], 1e-6);
assert.ok(parts);
});
test('splitRoomPath rejects bad paths', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// промежуточная точка снаружи
assert.equal(splitRoomPath(sq, [[4, 0], [4, -3], [10, 6]], 1e-6), null);
// сегмент пересекает стену (выходит и возвращается)
assert.equal(splitRoomPath(sq, [[4, 0], [14, 5], [10, 6]], 1e-6), null);
// конец не на стене
assert.equal(splitRoomPath(sq, [[4, 0], [5, 5]], 1e-6), null);
// самопересечение пути
assert.equal(splitRoomPath(sq, [[2, 0], [8, 8], [8, 2], [2, 8], [0, 4]], 1e-6), null);
// менее двух точек
assert.equal(splitRoomPath(sq, [[4, 0]], 1e-6), null);
});
test('kelvinToRgb: warm is orange-ish, cool is blue-ish white', () => {
const warm = kelvinToRgb(2700);
assert.equal(warm[0], 255);
assert.ok(warm[2] < 200 && warm[1] < 230);
const cool = kelvinToRgb(6600);
assert.ok(cool[0] > 245 && cool[1] > 240 && cool[2] > 245);
// clamps
assert.ok(kelvinToRgb(100)[0] === 255);
});
test('glowColorOf: rgb wins, then color temp, then fallback; off = null', () => {
assert.equal(glowColorOf({ state: 'off', attributes: {} }, '#fff'), null);
assert.equal(glowColorOf(null, '#fff'), null);
const rgb = glowColorOf({ state: 'on', attributes: { rgb_color: [10, 20, 30], brightness: 128 } }, '#fff');
assert.equal(rgb.c, 'rgb(10, 20, 30)');
assert.ok(Math.abs(rgb.bri - 0.5) < 0.01);
const ct = glowColorOf({ state: 'on', attributes: { color_temp_kelvin: 2700 } }, '#fff');
assert.ok(ct.c.startsWith('rgb(255'));
const mireds = glowColorOf({ state: 'on', attributes: { color_temp: 370 } }, '#fff'); // ~2700K
assert.ok(mireds.c.startsWith('rgb(255'));
const fb = glowColorOf({ state: 'on', attributes: {} }, '#abcdef');
assert.equal(fb.c, '#abcdef');
assert.equal(fb.bri, 1);
});
test('doorSector: sector through a door, clamped and guarded', () => {
const s = [0, 0];
const sec = doorSector(s, [10, -2], [10, 2], 50);
assert.ok(sec && sec.length === 10 + 0); // вершина + 9 точек дуги
assert.deepEqual(sec[0], [0, 0]);
for (const p of sec.slice(1)) {
const d = Math.hypot(p[0], p[1]);
assert.ok(Math.abs(d - 50) < 1e-6);
assert.ok(p[0] > 0); // сектор смотрит в сторону двери
}
// дверь за радиусом
assert.equal(doorSector(s, [60, -2], [60, 2], 50), null);
// источник на краю двери
assert.equal(doorSector(s, [0, 0], [10, 2], 50), null);
});
test('hasRoomBehind: neighbour room yes, street no', () => {
const neighbour = [[10, -5], [20, -5], [20, 5], [10, 5]];
// дверь в стене x=10 (стена вертикальна: угол 90°), источник слева в (5,0)
assert.ok(hasRoomBehind([10, 0], 90, [5, 0], [neighbour], 1));
// за дверью пусто
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [], 1));
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [[[30, 30], [40, 30], [40, 40], [30, 40]]], 1));
});
test('controlsAction: HA-group semantics', () => {
assert.equal(controlsAction(['off', 'off']), 'turn_on');
assert.equal(controlsAction(['off', 'on']), 'turn_off');
assert.equal(controlsAction(['on', 'on']), 'turn_off');
assert.equal(controlsAction([undefined, 'off']), 'turn_on');
assert.equal(controlsAction([]), 'turn_on');
});
test('isControllable: lights and switches only', () => {
assert.ok(isControllable('light.kitchen'));
assert.ok(isControllable('switch.pump'));
assert.ok(!isControllable('lock.front'));
assert.ok(!isControllable('cover.gate'));
assert.ok(!isControllable('alarm_control_panel.home'));
});
test('sharedBoundary: exact, partial-collinear and none', () => {
const A = [[0, 0], [2, 0], [2, 2], [0, 2]];
// точное общее ребро x=2
const B = [[2, 0], [4, 0], [4, 2], [2, 2]];
const s1 = sharedBoundary(A, B);
assert.equal(s1.length >= 1, true);
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
assert.ok(Math.abs(Math.max(...s1.map(len)) - 2) < 1e-6);
// частичное коллинеарное наложение (дачный случай): стена соседа длиннее
const C = [[2, -1], [4, -1], [4, 3], [2, 3]];
const s2 = sharedBoundary(A, C);
assert.ok(Math.abs(Math.max(...s2.map(len)) - 2) < 1e-6); // перекрытие = наша стена
// нет наложения
assert.equal(sharedBoundary(A, [[5, 5], [6, 5], [6, 6], [5, 6]]).length, 0);
// перпендикулярное касание — не граница
assert.equal(sharedBoundary(A, [[2, 2], [3, 2], [3, 3], [2, 3]]).filter((s) => len(s) > 1e-6).length, 0);
});
test('openZoneOf: transitive, either-direction links', () => {
const rooms = [
{ id: 'a', open_to: ['b'] },
{ id: 'b' }, // связь только со стороны a
{ id: 'c', open_to: ['b'] }, // b↔c со стороны c
{ id: 'd' }, // не связан
];
const z = openZoneOf('a', rooms);
assert.deepEqual([...z].sort(), ['a', 'b', 'c']);
assert.deepEqual([...openZoneOf('d', rooms)], ['d']);
});
test('distToSegment', () => {
assert.equal(distToSegment([0, 5], [0, 0, 0, 10]), 0);
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
});
test('outlineWithout: removes the cut stretch, keeps the rest', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
// вырез середины нижней стены: 3..7
const pieces = outlineWithout(sq, [[3, 0, 7, 0]]);
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
const total = pieces.reduce((a, s) => a + len(s), 0);
assert.ok(Math.abs(total - (40 - 4)) < 1e-6);
// куски нижней стены: 0..3 и 7..10
const bottom = pieces.filter((s) => s[1] === 0 && s[3] === 0);
assert.equal(bottom.length, 2);
// вырез целого ребра
const p2 = outlineWithout(sq, [[0, 0, 10, 0]]);
assert.ok(Math.abs(p2.reduce((a, s) => a + len(s), 0) - 30) < 1e-6);
// без вырезов — весь периметр
assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6);
});
test('resolveTapAction: pure lights toggle by default (v1.39.0)', () => {
assert.equal(resolveTapAction(null, null, 'light'), 'toggle');
assert.equal(resolveTapAction('', undefined, 'light'), 'toggle');
// явный выбор пользователя сильнее дефолта
assert.equal(resolveTapAction('info', null, 'light'), 'info');
// не-световые домены не трогаем
assert.equal(resolveTapAction(null, null, 'switch'), 'info');
assert.equal(resolveTapAction(null, null, 'sensor'), 'info');
// замок никогда
assert.equal(resolveTapAction('toggle', null, 'lock'), 'info');
});
test('alignGuides: nearest per axis, indication only', () => {
const cands = [[10, 50], [10, 90], [70, 20], [40, 40]];
const g = alignGuides([10, 60], cands, 0.5);
const gx = g.find((x) => x.axis === 'x');
assert.ok(gx && gx.at === 10 && gx.from[1] === 50); // ближайший по Y из выровненных по X
// выравнивание по Y
const g2 = alignGuides([30, 20], cands, 0.5);
const gy = g2.find((x) => x.axis === 'y');
assert.ok(gy && gy.at === 20 && gy.from[0] === 70);
// вне допуска — пусто
assert.equal(alignGuides([11, 60], cands, 0.5).length, 0);
// совпадающая точка не гид сама себе
assert.equal(alignGuides([10, 50], [[10, 50]], 0.5).length, 0);
// максимум два гида
const g3 = alignGuides([10, 20], cands, 0.5);
assert.ok(g3.length <= 2);
});
test('segmentAngle & is45', () => {
assert.equal(segmentAngle([0, 0], [10, 0]), 0);
assert.equal(segmentAngle([0, 0], [0, 10]), 90);
assert.equal(segmentAngle([0, 0], [10, 10]), 45);
assert.equal(segmentAngle([0, 0], [-10, 0]), 180);
assert.ok(is45(45) && is45(90) && is45(315) && is45(0));
assert.ok(is45(44.8) && is45(45.3));
assert.ok(!is45(30) && !is45(52));
});
test('swipeTarget: kiosk swipe rules', () => {
const ids = ['f1', 'f2', 'garden'];
// влево → следующее, вправо → предыдущее, по кругу
assert.equal(swipeTarget(-100, 5, 1, ids, 'f1'), 'f2');
assert.equal(swipeTarget(100, 5, 1, ids, 'f1'), 'garden');
assert.equal(swipeTarget(-100, 0, 1, ids, 'garden'), 'f1');
// при зуме — не свайп
assert.equal(swipeTarget(-100, 0, 2, ids, 'f1'), null);
// короткий или диагональный жест — не свайп
assert.equal(swipeTarget(-30, 0, 1, ids, 'f1'), null);
assert.equal(swipeTarget(-80, 70, 1, ids, 'f1'), null);
// одно пространство — некуда
assert.equal(swipeTarget(-100, 0, 1, ['f1'], 'f1'), null);
});
test('clampScale', () => {
assert.equal(clampScale(2), 2);
assert.equal(clampScale(9), 3);
assert.equal(clampScale(0.1), 0.5);
assert.equal(clampScale('x'), 1);
assert.equal(clampScale(undefined, 1.5), 1.5);
});