Compare commits

...
93 Commits
Author SHA1 Message Date
Matysh 5c7d1ca8bb Merge dev: v1.41.2..v1.42.2 (room settings tier, font scales, pdf migration, touch tooltips) 2026-07-27 10:19:03 +03:00
Matysh b4bb732736 fix v1.42.2: no hover tooltips on touch devices
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 52s
Validate / backend (push) Failing after 3m54s
- taps on tablets synthesized mousemove and popped the tooltip over the
  finger (user feedback item 5); _showTip gated by (hover: none)
- smoke_touch_tips.mjs (matchMedia shim); TESTING/CHANGELOG same-commit
2026-07-27 10:15:30 +03:00
Matysh 10d4084d17 feat v1.42.1: room-card font sizes — 3 sliders with a live preview
- space.settings.card_font_scale (tier 2 base) + room.settings
  name_scale/label_scale (tier 3), 50-300%, multiplied together and
  stacking with resize-k and kiosk per-screen multipliers
- live sample card in both the space and room dialogs
- backend schema; smoke_font_scales.mjs (9 checks); docs same-commit
2026-07-27 10:12:22 +03:00
Matysh 19e19b5c5f feat v1.42.0: room settings — the third settings tier
- four-tier principle fixed in ARCHITECTURE: global > space > room >
  device, specific overrides general, unset inherits
- room.settings { fill_mode, temp_source, hum_source } with backend
  schema; pure roomFillModeOf + sourceValue (+2 test suites, 114)
- gear on room cards in the Plan editor opens Room settings: rename,
  re-area (current area included in the list), fill override (may opt
  out of glow darkness), explicit temp/hum source with a searchable
  device+entity dropdown; the same section in the creation dialog
- source feeds the room card, tooltip and temp fill; works for rooms
  without an HA area (user-feedback case #1)
- smoke_room_settings.mjs (10 checks); TESTING/CHANGELOG/ARCHITECTURE
  same-commit
2026-07-27 09:58:21 +03:00
Matysh 88dc0d1de7 fix v1.41.2: uploaded files survive marker rebinding
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 5s
Validate / frontend (push) Successful in 56s
Validate / backend (push) Failing after 6m31s
- new WS houseplan/files/migrate moves /files/<oldId>/ to the new id
  (admin-only, sanitized ids, merge-safe, removes the empty old dir)
- _saveMarker calls it and rewrites pdf urls (migratePdfUrls pure
  helper, +1 unit test, 112) when rebinding changes the id
- field incident: the sauna heater manuals pointed at an orphaned
  old-id folder that got cleaned up; data restored by hand on the
  home instance (official Harvia PDFs re-downloaded)
- TESTING/CHANGELOG same-commit
2026-07-26 17:42:28 +03:00
Matysh ad7e946e75 Merge dev: v1.40.2..v1.41.1 (kiosk mode, speaker icons, SEO README, new demo GIF)
Validate / backend (push) Failing after 6m2s
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 1m21s
2026-07-23 21:49:55 +03:00
Matysh 9191a94701 docs: new hero demo GIF (real home, owner-approved 2026-07-24); old synthetic kept as demo-synthetic-old.gif 2026-07-23 21:46:14 +03:00
MatyshandGitHub eea669fa12 Add files via upload 2026-07-23 21:45:45 +03:00
Matysh bd9fabfe99 docs v1.41.1: SEO rework of README en/ru
- keyword-rich hero + badges + feature highlights (glow, controls,
  kiosk, virtual walls, room cards, server-side storage)
- kiosk recipe relocated before Installation; RU parity
2026-07-23 21:38:06 +03:00
Matysh b03ef70794 docs: catch STATUS/ARCHITECTURE/UX-MODES up to v1.41.0, kiosk recipe in README.ru
- STATUS.md: fresh snapshot (version, dev-branch workflow, PAT, HACS
  queue reality, test counts) + feature-surface digest since 07-17
- ARCHITECTURE.md: decor layer, glow clip model, open boundaries math,
  controls, island rooms, kiosk, nav persistence
- UX-MODES.md: kiosk as the fourth interaction surface
- README.ru.md: wall-tablet recipe (parity with English)
2026-07-23 21:24:58 +03:00
Matysh 1d6ca968e8 feat v1.41.0: kiosk mode for wall tablets and TVs
- kiosk: true — header hidden, editors hard-blocked, full-height stage;
  full View interactivity preserved (live states, glow, taps, locks)
- swipe between spaces at 1:1 (swipeTarget pure helper, wrap + dots
  indicator), pan wins while zoomed, double tap resets zoom
- cycle: N auto-carousel with 60 s pause after any touch (TV/burn-in)
- per-SCREEN size multipliers (icons x0.5-3, room-card font) in
  localStorage via a 3 s long-press popover; clampScale helper
- GUI editor fields, README wall-tablet recipe, +2 unit tests (111),
  smoke_kiosk.mjs (12 checks); TESTING/CHANGELOG same-commit
2026-07-23 21:12:58 +03:00
Matysh 82eed100b2 ux v1.40.2: default speaker icon for smart speakers (Alice et al.)
- rules split: mdi:soundbar only for soundbars; колонки/станции/yandex/
  alice/speaker -> mdi:speaker; +1 unit test (109)
2026-07-23 18:20:38 +03:00
Matysh f27c91ade1 Merge dev: v1.38.3..v1.40.1 (dashed walls in editor, default light toggle, smart guides, room link) 2026-07-23 18:19:48 +03:00
Matysh 4b4df4b3a2 ux v1.40.1: room click removed — link icon on the room card instead
- rooms inert in View (default cursor); mdi:open-in-new after the room
  name (View, rooms with an area) navigates to the HA area
- smoke_room_link.mjs (7 checks); TESTING/CHANGELOG/UX-MODES same-commit
2026-07-23 17:46:11 +03:00
Matysh df9e158efb feat v1.40.0: smart guides — alignment helper in every editor
- alignGuides/segmentAngle/is45 pure helpers (+2 unit tests, 108);
  per-context candidates (room vertices + path pts / other icons /
  decor endpoints+corners / other room cards), nearest per axis, max
  two guides, dashed accent lines with a source dot
- cursor badge shows length + angle, green on 45deg multiples
- indication only, no magnetism (owner's decision); guides live in
  plan/devices/decor, never in View
- smoke_align_guides.mjs (9 checks); TESTING/CHANGELOG same-commit
2026-07-23 17:38:33 +03:00
Matysh 0522413c48 feat v1.39.0: pure light sources toggle on click by default
- resolveTapAction: no explicit action + domain light -> toggle (kettle
  et al. keep info via their non-light primary); explicit choice wins
- device dialog shows the effective default (defaultTap)
- unit tests updated (+1, 106); smoke_light_default_tap.mjs; docs
  same-commit
2026-07-23 17:28:38 +03:00
Matysh 9bfef453db fix v1.38.4: derived wall segments trimmed under open boundaries
- the markup layer's .seg lines ran solid through open stretches;
  cutSegments extracted (outlineWithout now reuses it) and applied to
  _segments in _renderMarkupLayer
- smoke_openwall: planSegCut check (15 total); docs same-commit
2026-07-23 17:20:06 +03:00
Matysh 9c92bcdf2f ux v1.38.3: true dashed open boundaries in the Plan editor too
- room outline trimming applies in markup (blue .room-outline.outlined);
  merge/split-picked rooms keep the full amber stroke
- smoke_openwall extended (14 checks); TESTING/CHANGELOG same-commit
2026-07-23 17:17:01 +03:00
Matysh 8adb262410 Merge dev: v1.35.0..v1.38.2 2026-07-23 16:48:05 +03:00
Matysh 3811a1ca73 feat v1.38.2: restore last space and editor mode across reloads
- LS_NAV stores {space, mode}; restored in setConfig from the cached
  config, with a retry after the live config when the cache was stale;
  deep-link hash wins; edit modes restored for admins only
- UX-MODES updated (owner reversed the 'always start in View' rule)
- smoke_nav_persist.mjs; TESTING/CHANGELOG same-commit
2026-07-23 15:19:09 +03:00
Matysh 8895354c4e ux v1.38.1: tap-action cleanup + right-click more-info
- per-device action: Device card (default) / more-info / Toggle; 'as
  card default' removed, card editor's global tap_action field deleted
  and ignored; RU: 'по нажатию'
- right click on an icon in View opens more-info (native menu in
  editors; virtual w/o entity -> device card)
- smoke_tap_ctx.mjs; TESTING/CHANGELOG same-commit
2026-07-23 15:11:52 +03:00
Matysh ea41bec86b ux v1.38.0: binding section — radios + entities checkbox + dropdown
- Virtual / Pick-from-HA radios; Show-entities checkbox with tooltip
  gates device entities (groups/helpers always listed); searchable
  dropdown only in HA mode, auto-opens when empty, closes on pick;
  Save guarded until a binding is chosen; logic untouched
- smoke_binding_ui.mjs (16 checks); marker_stay/controls smokes green;
  TESTING/CHANGELOG same-commit
2026-07-23 14:52:01 +03:00
Matysh 05f162434b fix v1.37.3: true dashed open boundary, rendered above the glow
- outlineWithout trims the rooms' solid strokes under open stretches
  (.room.noedge kills the polygon stroke incl. hover; trimmed
  .room-outline path draws the remaining walls)
- dash color follows the space stroke color; openwalls layer moved
  after the glow layer
- +1 unit test (105); smoke_openwall extended; docs same-commit
2026-07-23 14:34:41 +03:00
Matysh 0e14139fe2 ux v1.37.2: glow falloff 70/30 (was 80/20) 2026-07-23 14:29:11 +03:00
Matysh 6b9909768f ux v1.37.1: open-wall tool hover — default cursor, pointer + stretch preview near walls
- _openWallHit shared by click and hover; amber dashed preview of the
  stretch that would open, red solid when the click would close it;
  stage.wallhot drives the pointer cursor
- smoke_openwall_hover.mjs (9 checks); TESTING/CHANGELOG same-commit
2026-07-23 14:24:55 +03:00
Matysh 20c7b55a87 feat v1.37.0: open boundaries (virtual walls)
- room.open_to symmetric links; 'Open boundary' plan tool toggles the
  shared wall pair under the click (sharedBoundary collinear-overlap
  math + distToSegment pull, same as Split); dashed rendering with an
  amber hot state while the tool is active; Esc -> Draw
- glow: the clip becomes the transitive open zone (openZoneOf BFS,
  either-direction links) + door sectors from the zone's outer walls
- model rooms carry open_to; backend ROOM_SCHEMA open_to: [str]
- +3 unit tests (104), smoke_openwall.mjs (8 checks); docs same-commit
2026-07-23 14:18:50 +03:00
Matysh 3a89966e4b ux v1.36.4: glow pool fully lit to 80% radius, falloff on outer 20% 2026-07-23 14:03:15 +03:00
Matysh 8330b48cf3 fix v1.36.3: door sectors no longer punch dark wedges inside the room
- room outline + sectors as separate clipPath children (always union)
  instead of subpaths of one nonzero path where opposite windings
  cancelled the overlap; smoke asserts one contour per path
- TESTING/CHANGELOG same-commit
2026-07-23 13:57:44 +03:00
Matysh 81ea5c0f3c feat v1.36.2: per-source glow radius
- marker.glow_radius_cm overrides the global default per device (field
  in the marker dialog, HA units, placeholder = global default); pools
  and door sectors use the per-source radius
- backend schema; smoke_glow extended (11 checks); docs same-commit
2026-07-23 13:53:19 +03:00
Matysh df2ed0c3e1 fix v1.36.1: hidden grouped lamps toggle the lamp, not the DND switch
- primaryEntity: tiered selection, domain priority beats hidden flag
  (hidden light > visible config switch); visible same-domain still wins
- live-debugged on the real install: individual lamps hidden in the
  registry (light group setup) got switch.*_do_not_disturb / identify
  buttons as primary — tap toggle 'did nothing'
- +1 unit test (101); TESTING/CHANGELOG same-commit
2026-07-23 13:24:02 +03:00
Matysh d7a1b344e4 feat v1.36.0: marker controls — wall switches that really switch
- marker.controls[]: bound light.*/switch.* entities; explicit per-marker
  tap_action=toggle flips them with HA-group semantics in one call
  (controlsAction + isControllable pure helpers, +2 unit tests, 100)
- icon state and RGB tint mirror the targets (stateless remotes and
  virtual dumb-switch markers finally show something); info card lists
  targets with states; locks filtered everywhere
- chips+search UI in the marker dialog; backend schema; smoke_controls
  (9 checks); TESTING/CHANGELOG same-commit
2026-07-23 13:10:58 +03:00
Matysh 7eaf513c9e feat v1.35.0: glow fill — dark house with glowing light sources
- fill_mode 'glow': uniform darkness over every room; lit lamps render
  radial gradient pools (rgb_color -> color temp via kelvinToRgb ->
  configurable default; brightness scales alpha)
- pools clipped by the source's room + doorway sectors (doorSector rays
  to door edges; hasRoomBehind blocks entrance doors); windows and
  islands don't participate (no shadow casting, documented)
- glow radius in HA units (m/ft) in general settings, stored in cm;
  palette group glow_base/glow_light; backend schema updated
- +4 unit tests (98 total); smoke_glow.mjs (10 checks); docs same-commit
2026-07-23 12:51:48 +03:00
Matysh 1e20279adf Merge dev: v1.34.0 (island rooms)
Validate / backend (push) Failing after 5m14s
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 18s
Validate / frontend (push) Successful in 1m18s
2026-07-23 12:44:55 +03:00
Matysh 031e5439eb feat v1.34.0: island rooms — nested contours with evenodd holes
Validate / hacs (push) Failing after 5s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 47s
Validate / backend (push) Failing after 3m19s
- roomsOverlap: full nesting is legal (islands), edge crossings and
  partial overlaps still rejected; polyContainsPoly + islandsOf pure
  helpers (+2 unit tests, 94 total)
- draw tool: per-click point-in-room rejection removed, validation at
  contour closing; parent rooms with islands render as evenodd paths
  (ring fills correctly, island stays clickable through the hole)
- smoke_island_rooms.mjs; smoke_merge_split stale room-count asserts
  fixed (rg pushed a 5th room; cancelWhole silently false for ages)
- TESTING/CHANGELOG same-commit
2026-07-22 22:59:43 +03:00
Matysh 2bf9e44178 Merge dev: v1.32.0..v1.33.5 (background editor, polyline split, editor UX)
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 1m19s
Validate / backend (push) Failing after 7m38s
2026-07-22 15:34:59 +03:00
Matysh c2eaf50118 ux v1.33.5: extended tooltips on the editor tabs 2026-07-22 15:32:43 +03:00
Matysh ac082569d7 fix v1.33.4: device icon stays in place when rebinding or changing room
- rebinding migrates the layout position to the new marker id instead of
  recentering; room change within the same space keeps the position
  (owner's decision); new icons and cross-space moves still center
- smoke_marker_stay.mjs; TESTING/CHANGELOG same-commit
2026-07-22 15:29:54 +03:00
Matysh a90316c9f3 fix v1.33.3: icon picker shows the auto icon when none set
- marker dialog stores autoIcon (DevItem.icon); ha-icon-picker gets it
  as placeholder, fallback input too; 'Auto: mdi:...' hint with preview
  under the picker, hidden once an explicit icon is chosen
- smoke_icon_placeholder.mjs; TESTING/CHANGELOG same-commit
2026-07-22 15:23:30 +03:00
Matysh 912613bbf8 ux v1.33.2: remove the Reset button from the Device editor
- _resetLayout wiped the whole layout (all spaces: device positions,
  room cards, scales) behind one confirm — low value, high blast radius
- i18n keys dropped; smoke_editor_tabs expects 3 devbar tools;
  TESTING/CHANGELOG same-commit
2026-07-22 15:18:23 +03:00
Matysh 935a519c32 ux v1.33.1: dot grid in all editors, faded plan in the background editor
- _editing getter; grid defs+rect rendered for plan/devices/decor
- decor mode fades rooms/devlayer/openings/rlabel to 0.35, decor stays
  opaque
- smoke_grid_fade.mjs (9 checks, transition-aware); docs same-commit
2026-07-22 14:54:32 +03:00
Matysh dd930b64f7 feat v1.33.0: background editor — visual decor layer
- third mode tab: draw lines/rects/ovals/text with grid snap, drag
  preview; select+move (Delete key), erase, color/width/fill controls;
  Esc ladder; text dialog with S/M/L sizes and dblclick re-edit
- decor renders under rooms, visible in every mode, click-transparent
  outside its editor; stored in space.decor with backend schema
  (line/rect/ellipse/text variants), rev/optimistic locking as usual
- smoke_decor.mjs (19 checks); smoke_editor_tabs updated to 3 tabs;
  TESTING/CHANGELOG/UX-MODES same-commit
2026-07-22 14:45:25 +03:00
Matysh f235c8afc5 ux v1.32.1: dashed hover preview for the Opening tool
- _openingPreview getter mirrors the click's snap (same eps, same
  existing-opening hit test); dashed 90cm ghost + center dot on the wall
- smoke_opening_preview.mjs; TESTING/CHANGELOG same-commit
2026-07-22 14:33:26 +03:00
Matysh 5e6f9c407c feat v1.32.0: polyline split, tool cursors, Esc walk-back in merge/split
- splitRoomPath in logic.ts: wall-to-wall polyline cut with interior
  vertices; validates wall crossings, self-intersection, degenerate
  parts; splitRoom delegates (2-point path); +3 unit tests (93 total)
- split UI: interior clicks add snapped intermediate points, live
  polyline + vertices + preview; new/updated toasts
- cursors: pointer for merge/delroom and split room-pick stage,
  crosshair while cutting (stage tool-* classes)
- Esc: split — drop last point, then room pick, then back to draw;
  merge — clear selection, then back to draw
- smoke_split_polyline.mjs (14 checks); TESTING/CHANGELOG same-commit
2026-07-22 14:27:39 +03:00
Matysh 849117eb27 Merge dev: v1.31.1-v1.31.2 (plan editor fixes) 2026-07-22 13:31:00 +03:00
Matysh 37b39e7a1e fix v1.31.2: merge/split room pick highlight visible again
- .room.picked moved after .room.outlined: equal specificity, source
  order silently killed the amber highlight in markup (gotcha x4,
  documented inline)
- smoke_merge_highlight.mjs (waits out the 0.12s transition before
  reading computed colors); TESTING/CHANGELOG same-commit
2026-07-22 12:19:15 +03:00
Matysh d4f2d81a2f fix v1.31.1: room card interactions no longer feed the active markup tool
- _markupClick ignores clicks originating from .roomlabel/.rlhandle
  (composedPath) and anything during an active drag/resize
- smoke_card_tool_conflict.mjs (draw/delroom/handle/during-resize +
  normal stage click still works); TESTING/CHANGELOG same-commit
2026-07-22 12:15:01 +03:00
Matysh bcb546dbc7 Merge dev: v1.30.2..v1.31.0 batch (editor tabs redesign, Esc, room cards, scope) 2026-07-22 12:10:13 +03:00
Matysh ccf36e283e docs: fix product scope in docs/SCOPE.md (owner decision 2026-07-22)
- mission, personas/surfaces, core jobs J1-J7 with coverage status,
  partial list, in-mission gaps, hard out-of-scope list, excess audit
  (PDF/links + virtual devices kept frozen, LQI promoted to J7)
- STATUS.md points to SCOPE.md as the feature guard rail
2026-07-22 12:08:02 +03:00
Matysh 821bdfbd9a feat v1.31.0: room cards — metrics line + proportional resize
- room label becomes a card: name on top, optional metrics below
  (temperature / humidity / avg zigbee / lights), four checkboxes in
  space settings, all off by default; lights render On/Off or '1 of 3'
- areaHum + areaLightStats pure helpers (+3 unit tests, 90 total)
- resize via corner handles in the Plan editor (hover), uniform 0.5-3x,
  scale stored as layout k next to the position; drag preserves it
- fix latent v1.25 regression: draggable HTML labels were never rendered
  in the Plan editor (static SVG only) — real name-only cards now render
  there, draggable and resizable
- repair merge/split smokes still calling removed _toggleMarkup
- validation.py: 4 label_* bools; smoke_room_cards.mjs; docs same-commit
2026-07-22 12:01:14 +03:00
Matysh 22992b256f ux v1.30.4: Escape closes all dialogs
- Esc closes the topmost dialog: opening/device info, icon rules, general
  settings, marker dialog, opening editor, space dialog (abandons import
  queue like Cancel); draw-undo via Esc preserved with dialog priority
- smoke_esc_dialogs.mjs; TESTING/CHANGELOG same-commit
2026-07-22 11:39:39 +03:00
Matysh d273a90db8 ux v1.30.3: general settings gear visible in every mode
- header cog (fill palette) no longer gated to Plan mode; canEdit only
- smoke_gs_always.mjs; TESTING/CHANGELOG same-commit
2026-07-22 11:37:00 +03:00
Matysh 48d47a46af ux v1.30.2: editor tabs redesign
- two tabs only: 'Plan editor' / 'Device editor'; View is the implicit
  default state, no tab
- Device editor gets its own bottom toolbar (add/show-all/reset/rules
  moved from the header), mirroring the Plan toolbar
- X button on both toolbars and inside the active tab returns to View;
  re-click on active tab is a no-op; Plan<->Devices switches directly
- smoke_editor_tabs.mjs; TESTING/CHANGELOG/UX-MODES same-commit
2026-07-22 11:33:24 +03:00
Matysh 0d712b9069 Merge v1.30.1: space gear in every mode + alignment 2026-07-22 11:21:13 +03:00
Matysh dfa2a8badc fix v1.30.1: space gear visible in every mode + vertical alignment
- cog next to the space name shows in View/Plan/Devices (canEdit only);
  '+' add-space tab stays Plan-only
- tabs flex-centered so the cog aligns with the tab text
- smoke_gear_tabs.mjs; TESTING.md + CHANGELOG same-commit
2026-07-22 11:18:52 +03:00
Matysh 2dfab3565b Merge v1.30.0: lock action in the opening info card 2026-07-22 11:13:44 +03:00
Matysh 6ee09d3750 feat v1.30.0: explicit Unlock/Lock action in the opening info card
- View-mode door/window info card gets an Unlock (red) / Lock button when a
  lock entity is bound and available; disabled while locking/unlocking,
  hidden when unavailable
- plan-icon taps still never toggle locks (hard block untouched);
  smoke_lock_action.mjs covers the full matrix incl. the block
- TESTING.md, CHANGELOG, UX-MODES same-commit
2026-07-22 11:11:18 +03:00
Matysh 29c5a89aad Merge v1.29.0: 'new device' red-dot flag 2026-07-22 10:52:32 +03:00
Matysh 71f44fd528 feat v1.29.0: 'new device' flag with a red dot
- auto devices/light groups appearing after install get a server-side 'new'
  flag (settings.new_device_ids) and a red dot top-right of the icon; opening
  the device's editor clears it for every client
- known_devices baseline seeded silently on first run — upgrades never flood
  the plan with dots; hand-made markers never flagged
- pure diffNewDevices + unit tests; backend schema; smoke_new_device.mjs;
  TESTING.md row
2026-07-22 10:48:55 +03:00
Matysh c8722642b7 Merge inert-openings: View-mode cursors and inert openings v1.28.1 2026-07-22 10:44:23 +03:00
Matysh 0a7b9a627f fix v1.28.1 (revised per owner spec): View-mode cursors and inert openings
- device icons: pointer cursor in View (grab only in Devices mode); click
  behavior untouched
- openings in View: pure drawings always — no cursor/hover/hit/click
- lock badge: the one exception — visible and clickable in View (info card),
  inert in Plan; removed a duplicate pointer-events in .oplock
- smoke covers all cursor/badge cases
2026-07-22 10:42:12 +03:00
Matysh 961d6afe67 fix v1.28.1: openings fully inert in View mode
- op-hit: pointer-events none + default cursor by default; interactive (grab,
  hover outline, drag) only inside Plan; lock badge is a pure indicator
- opening click edits in Plan with any tool; no interaction whatsoever in View
- smoke_inert_openings.mjs; TESTING.md updated
2026-07-22 10:34:03 +03:00
Matysh b9fecbdd95 Merge subarea-rooms: manual placement without an HA area v1.28.0 2026-07-22 10:27:31 +03:00
Matysh 9fd36bd710 feat v1.28.0: sub-area rooms — manual device placement without an HA area (issue #3)
- area-less rooms appear in the marker room list ('no area, manual'); markers
  store room_id and land at the room centre; dialog reopen restores the choice
- pure parseRoomRef (space#area / space#@roomId) + unit tests; backend schema
- ROADMAP phase 11 closed; TESTING.md row; smoke_subarea.mjs
2026-07-22 10:25:20 +03:00
Matysh a9346c9c14 Merge rgb-alarm: RGB light colors + alarm pulse v1.27.0 2026-07-22 10:23:15 +03:00
Matysh 794b02b84f feat v1.27.0: RGB light colors + red alarm pulse (issue #3)
- lightColorOf(): on+rgb_color tints icon/glow/ripple (explicit ripple color wins);
  brightness ignored by design; off/white/unavailable unchanged
- isAlarmState(): leak/smoke/gas/CO/safety/tamper/problem sensors and sirens in
  'on' pulse a red ring over any display mode; outages never alarm;
  prefers-reduced-motion honoured
- +2 unit tests, smoke_rgb_alarm.mjs, TESTING.md rows
2026-07-22 10:21:08 +03:00
Matysh 3bc25ad5ae Merge state-icons: state-reflecting icons + value display v1.26.0 2026-07-22 10:19:42 +03:00
Matysh d1a79cd0b4 feat v1.26.0: state-reflecting icons + 'value instead of an icon' display (issue #3)
- stateIcon(): door/window/garage open-closed, lock locked-unlocked, bulb on;
  custom icons and unavailable/unknown never morph; gated by live_states
- marker display 'value': the measurement (with unit) is the marker body,
  corner badges hidden; numeric fallback chain temp - hum - primary state
- TESTING.md rows, smoke_state_value.mjs, +1 unit test
2026-07-22 10:17:32 +03:00
Matysh 5522399a3a Merge ux-modes: three interaction modes v1.25.0 2026-07-22 10:13:08 +03:00
Matysh 185604396d feat v1.25.0: three interaction modes — View / Plan / Devices (docs/UX-MODES.md)
- View (always the default): display + device interaction only; no dragging of
  icons/labels/openings; panning can start on an icon; clean header
- Plan: markup tools, openings editing (click-to-edit, drag along walls), label
  drag, space dialogs, fill palette; orange frame; icons hidden but labels visible
- Devices: icon drag only here, click opens the marker editor directly; add/
  show-all/reset/rules buttons; accent frame
- segmented mode control (admins only); removed: markup toggle button, view-mode
  opening drag/dblclick (v1.23.1), 'drag anywhere' (v1.9 reversed)
- README en+ru updated; TESTING.md Modes section; smoke_modes.mjs
2026-07-22 10:11:02 +03:00
Matysh 9d0e2cab00 docs: approved UX-modes design (View/Plan/Devices tabs) — docs/UX-MODES.md + ROADMAP phase 11
Research only, no product changes. Confirms and structures the owner's mandate
(view mode must be display-only) and issue #3 feedback; lists deprecations and
approved follow-up features with implementation iterations.
2026-07-22 10:02:55 +03:00
Matysh 73f176ea2f Merge light-none: 'no light sources' fill color v1.24.2 2026-07-22 09:40:05 +03:00
Matysh b7599c642c feat v1.24.2: 'no light sources' color in the lights fill group
Default opacity 0 preserves the historical no-fill behavior; assigning an
opacity tints lightless rooms distinguishably from 'all lights off'.
2026-07-22 09:38:05 +03:00
Matysh 4ec7e7081f Merge tab-gear: gear icon on space tabs v1.24.1 2026-07-22 09:35:34 +03:00
Matysh ee5f65752a style v1.24.1: gear instead of pencil on space tabs (the dialog is settings, not renaming) 2026-07-22 09:33:32 +03:00
Matysh 574d3b8f9a Merge general-settings: global fill palette + per-space LQI toggle v1.24.0 2026-07-22 09:30:38 +03:00
Matysh 90c558eee7 feat v1.24.0: general settings (global fill palette) + per-space LQI toggle
- General settings dialog: fill colors grouped by mode (light on/off, temp
  cold/ok/hot, lqi weak/strong), each with its own opacity; lqi fill lerps
  between the endpoints; stored in settings.fill_colors (defaults omitted);
  space-card uses the same palette
- per-space show_lqi toggle (badges + room tooltip line), inherits the card's
  show_signal when unset
- fillColorsOf/lerpColor/roomFillStyle helpers (+4 tests), backend schemas,
  smoke_general_settings; TESTING.md updated in the same commit
2026-07-22 09:28:38 +03:00
Matysh 4cd49d2c5d Merge limit-50: manual-upload limit 50 MB v1.23.2
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m22s
Validate / backend (push) Failing after 2m34s
2026-07-21 08:04:53 +03:00
Matysh 1fa6c6cdee feat v1.23.2: raise the manual-upload limit to 50 MB
MAX_FILE_BYTES 25→50 MB (still enforced while the multipart body streams in);
frontend fallback in the error toast updated; TESTING.md row updated.
2026-07-21 08:02:53 +03:00
Matysh 70d3453f78 docs: catch all documentation up to v1.23.1
Validate / hacs (push) Has been cancelled
Validate / hassfest (push) Has been cancelled
Validate / frontend (push) Has been cancelled
Validate / backend (push) Has been cancelled
- READMEs (en+ru): doors/windows/locks section (openings tool, live rendering, drag,
  double-click properties), presence ripples + per-device icon size/rotation in the
  manual-marker section.
- ARCHITECTURE: room geometry rules (derived walls, no-overlap, merge/split via
  polyclip-ts and why not polygon-clipping), doors & windows model (space.openings,
  snapToWall, absolute coords, security), per-marker display fields + cell_cm ruler.
- DEVELOPMENT: gotchas — polygon-clipping ESM/types mismatch; same-version redeploys
  keep ?v so browsers cache the old bundle.
- STATUS: snapshot refreshed (releases to v1.23.1, dacha deployed v1.23.1), milestones
  v1.17–v1.23.1 incl. the lost-sources lesson (push right after building).
- ROADMAP: 8 shipped Quality-Scale items ticked (done since v1.12.0).
- TESTING: restored the v1.22.0 ripple checklist lost in the sandbox reset.
2026-07-17 07:53:16 +03:00
Matysh 3e53b012be feat v1.23.1: openings — hover outline, drag along walls, double-click properties
- Hover: an accent outline hugs the opening's wall strip + grab cursor.
- Drag (view mode): re-snaps continuously to the nearest derived wall; snapToWall now
  normalizes the angle to [-90,90) — neighbouring rooms yield the same wall in opposite
  directions, which used to flip the hinge while dragging across segment boundaries.
  setPointerCapture wrapped in try/catch (an inactive pointerId must not kill the drag).
- Click still opens the status card (via a 250ms timer); double click opens the
  properties dialog from view mode. Hit zone is now a slightly thicker strip along the
  wall instead of the whole swing square (fewer accidental hovers). +1 test (77).
Verified live: drag moved a test door exactly along a vertical wall (angle 90 -> -90),
click/dblclick/markup-tool paths all работают; the user's 12 real openings untouched.
2026-07-17 07:43:10 +03:00
Matysh ff7c1b883c feat v1.23.0: doors & windows with live open/lock state
New markup tool 'Opening': click a wall — the opening snaps onto the nearest DERIVED
room wall (v1.19.0 model: openings keep absolute coords, so room edits never break
them) and takes its angle. Dialog: type, length in real cm (door 90 / window 120 —
per-space scale makes it honest), contact sensor (invertible), lock (doors).
Rendering after easy-floorplan (MIT): hinged leaf + swing arc drawing on via
stroke-dashoffset; window = two casements. No sensor → static plan defaults (door
open, window closed); unavailable freezes the default (pure openingAmount, tested).
Lock: padlock badge (green locked / orange unlocked / grey unknown); the lock is
never toggled from the plan — clicking shows an info card with both states.
snapToWall + openingAmount in logic.ts; space.openings validated server-side.
+2 tests (76). Verified live on the dacha (real TTLock + TRV window sensor).
2026-07-17 07:21:20 +03:00
Matysh 7c9731655e feat v1.22.0: presence ripples, per-device icon size/rotation, one-click HACS badge
Visuals borrowed from easy-floorplan (github.com/nicosandller/easy-floorplan), model kept ours.
isActiveState is independent of the live_states toggle; unavailable/unknown count as idle.
Honours prefers-reduced-motion. Badges scale with --dev-size. +1 test (74).
2026-07-17 07:02:22 +03:00
Matysh 921facf1ac Merge ux-1213: no text shadow on room labels v1.21.3
Validate / hacs (push) Waiting to run
Validate / hassfest (push) Waiting to run
Validate / frontend (push) Waiting to run
Validate / backend (push) Waiting to run
2026-07-16 15:11:56 +03:00
Matysh 8ddf34f3ac style v1.21.3: drop the text shadow on room name labels 2026-07-16 15:08:45 +03:00
Matysh 5dd14a009a Merge ux-1212: space-dialog polish v1.21.2 2026-07-16 14:34:14 +03:00
Matysh ae10befd58 fix v1.21.2: space-dialog polish
- scale (cm/cell) input compact again (generic width:100% dialog rule stretched it)
- space with no background image gets a white paper canvas (.stage.noplan), so
  hand-drawn rooms read like a plan on paper
- TESTING.md updated in the same commit
2026-07-16 14:32:07 +03:00
Matysh f670c8644c Merge audit-4: audit of v1.16-v1.21, split wall-snap fix, docs v1.21.1
Validate / hacs (push) Waiting to run
Validate / hassfest (push) Waiting to run
Validate / frontend (push) Waiting to run
Validate / backend (push) Waiting to run
2026-07-16 11:51:33 +03:00
Matysh 9288d74f6f fix+docs v1.21.1: audit of v1.16-v1.21
- fix: Split snaps the click to the room's nearest wall instead of the grid, so
  it works on non-grid-aligned rooms (imported/legacy polygons); the pull is
  capped (~6 cells) so accidental mid-room clicks stay a miss; splitRoom() still
  rejects a bad cut. closestPointOnBoundary() + unit test.
- docs: README (en+ru) documents Merge/Split/ruler/per-space scale (were undocumented);
  TESTING.md merge/split rows + fresh self-run; new smokes smoke_merge_split,
  smoke_split_nonsnap (incl. the capped-pull case).
- 73 frontend + 12 backend tests green; module eval + both cards register.
2026-07-16 11:43:15 +03:00
Matysh b706ad4b49 feat v1.21.0: merge and split rooms
- Merge: click a room, then a neighbour. Adjacency is decided by the RESULT, not a
  heuristic: mergeRooms unions the outlines and accepts only when they collapse into one
  hole-free outline (corner touch / apart / hole => refused). A dialog picks the surviving
  name+area; the kept room keeps its id so its label and devices stay put.
- Split: click the room, then two wall points; the chord cuts it, live ruler on the cut.
  The bigger part stays the room (name/area/devices), the smaller opens the new-room
  dialog. The cut is applied only on confirm — Cancel leaves the room whole.
- Boolean geometry via polyclip-ts (proper ESM + native types; polygon-clipping ships
  named types but a default-only ESM build, breaking either tsc or the runtime).
  Verified on the real plan, where neighbouring walls overlap collinearly rather than
  match exactly — the case a hand-rolled union gets wrong. Bundle 151->202 KB.
+5 tests (72). Verified live: merge of Сауна+с/у -> 4-vertex outline, non-adjacent
refused, split preserves area (26667 -> 13333+13333), cancel leaves the room whole.
2026-07-16 08:10:24 +03:00
Matysh 1552bff99a feat v1.20.0: rooms may not overlap
- A click strictly inside an existing room is refused while drawing; a click ON a wall
  stays legal (neighbours share walls, and real walls overlap collinearly rather than
  match exactly, so vertices land on existing outlines mid-span constantly).
- Closing an outline that overlaps an existing room is refused — vertex checks alone are
  not enough, an outline drawn AROUND a room has every vertex outside it. Nesting counts
  as overlap. The outline stays open so it can be corrected.
- New pure geometry: roomPoly, pointOnBoundary, pointStrictlyInside, segmentsProperlyCross
  (touching/collinear deliberately not a crossing), roomsOverlap. +4 tests (67).
Verified live: all 15 existing neighbour pairs stay clean (no false positives).
2026-07-16 07:59:01 +03:00
Matysh 65268a7985 feat v1.19.0: a line is never an entity of its own — walls are derived from rooms
A wall can only exist as an edge of a closed room:
- roomEdges(rooms) derives walls from room outlines, deduping shared ones, so deleting a
  room keeps the borders its neighbours still contribute and drops the rest — the rule
  falls out of the model instead of needing bookkeeping.
- Nothing is persisted while drawing: an outline you never close leaves no lines behind
  (previously every click pair was written to space.segments immediately).
- The 'Erase line' tool is gone; space.segments is stripped on every save (validation
  still tolerates it on read so a stale tab cannot fail a save).
- Dead code out: _addSegment/_removeSegmentByKey/_distToSeg/_pathSegs/_segKey.
Docs (ARCHITECTURE/TESTING/CHANGELOG) updated in the same commit. +2 tests (63).
2026-07-16 07:27:23 +03:00
Matysh 4593d96955 fix v1.18.1: the drawing ruler badge was invisible — .stage.markup hides .devlayer
The v1.18.0 measure label was rendered inside .devlayer, which markup mode hides
entirely (display:none) so icons don't obstruct drawing. Moved it to its own
.measurelayer. Verified visually on a real segment (3.60 m).
2026-07-16 06:53:31 +03:00
Matysh 18a0d279a7 feat v1.18.0: live segment ruler while drawing rooms + per-space scale
Validate / hacs (push) Has been cancelled
Validate / hassfest (push) Has been cancelled
Validate / frontend (push) Has been cancelled
Validate / backend (push) Has been cancelled
- Draw mode shows the current segment length near the cursor (metres or feet+inches
  per the HA unit system). New pure helpers segmentCm/formatLength (unit-tested).
- New per-space 'Scale (grid cell size)' field (space.cell_cm, default 5 cm) so each
  plan has its own real-world dimensions. i18n en/ru, .measurelabel style. +3 tests (61).
2026-07-14 18:48:56 +03:00
74 changed files with 12479 additions and 938 deletions
+85 -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,8 +78,30 @@ 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:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
### Via HACS (recommended)
1. Open **HACS → menu (⋮) → Custom repositories**.
@@ -114,12 +165,35 @@ 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.
![Marking up a room and saving it](docs/images/05-room-dialog.png)
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
### Doors, windows and locks
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
draws itself in as the real door opens; a window opens its two casements. While open, the moving
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
on the opening shows a status card with both states instead.
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
walls** (it slides around corners too), and a **double click opens its properties**.
### Step 3. Devices appear by themselves
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
@@ -138,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)
@@ -165,6 +239,12 @@ You can also place a **single entity** (not just a whole device): start typing i
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
---
+84 -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,8 +77,30 @@ 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:
[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
### Через HACS (рекомендуется)
1. Откройте **HACS → меню (⋮) → Custom repositories**.
@@ -122,6 +172,29 @@ title: План дома
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png)
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
стен** (в том числе за угол), а **двойной клик открывает свойства**.
### Шаг 3. Устройства появляются сами
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
@@ -140,7 +213,7 @@ title: План дома
### Шаг 5. Расставьте значки по местам
Значки устройств можно **перетаскивать мышью в любой момент** — отдельный «режим правки» включать не нужно. Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** в шапке возвращает автоматическую раскладку.
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
@@ -169,6 +242,12 @@ title: План дома
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
может быть маленьким и повёрнутым так, как он установлен.
![Добавление устройства вручную](docs/images/07-marker-dialog.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.17.2"
VERSION = "1.42.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -31,7 +31,6 @@ async def async_get_config_entry_diagnostics(
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"segments": len(s.get("segments", [])),
}
for s in config.get("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.17.2"
"version": "1.42.2"
}
+93 -3
View File
@@ -12,7 +12,7 @@ import voluptuous as vol
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
MAX_PLAN_BYTES = 8 * 1024 * 1024
FILE_EXTENSIONS = {"pdf", "png", "jpg", "jpeg", "webp", "txt"}
MAX_FILE_BYTES = 25 * 1024 * 1024
MAX_FILE_BYTES = 50 * 1024 * 1024
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
@@ -68,6 +68,20 @@ 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("settings"): vol.Any(
None,
vol.Schema(
{
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
},
extra=vol.ALLOW_EXTRA,
),
),
vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float),
@@ -84,13 +98,42 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
vol.Optional("label_temp"): bool,
vol.Optional("label_hum"): bool,
vol.Optional("label_lqi"): bool,
vol.Optional("label_light"): bool,
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
},
extra=vol.ALLOW_EXTRA,
)
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
_NORM = vol.All(vol.Coerce(float), vol.Range(min=-1, max=2))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
vol.Optional("fill"): bool},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
extra=vol.ALLOW_EXTRA),
)
SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
@@ -100,6 +143,28 @@ 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(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("x"): vol.Coerce(float),
vol.Required("y"): vol.Coerce(float),
vol.Required("angle"): vol.Coerce(float),
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
vol.Optional("contact"): vol.Any(str, None),
vol.Optional("lock"): vol.Any(str, None),
vol.Optional("invert"): bool,
vol.Optional("flip_h"): bool,
vol.Optional("flip_v"): bool,
},
extra=vol.ALLOW_EXTRA,
)
],
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
},
extra=vol.ALLOW_EXTRA,
@@ -118,6 +183,14 @@ 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),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
vol.Optional("pdfs"): [
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
],
@@ -128,7 +201,24 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required("spaces"): [SPACE_SCHEMA],
vol.Optional("markers", default=list): [MARKER_SCHEMA],
vol.Optional("settings", default=dict): vol.Schema({}, extra=vol.ALLOW_EXTRA),
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
vol.Optional("known_devices"): [str],
vol.Optional("new_device_ids"): [str],
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
}
),
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
)
@@ -32,6 +32,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_files_migrate)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -108,6 +109,61 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/files/migrate",
vol.Required("from_id"): str,
vol.Required("to_id"): str,
}
)
@websocket_api.async_response
async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Move a marker's uploaded files to its new id (rebinding changes the id).
Without this the PDF urls keep pointing at the OLD id's folder, which then
looks orphaned and is one cleanup away from deletion — the exact way the
owner lost the sauna heater manuals (field incident, 2026-07-26).
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit files")
return
from pathlib import Path
import shutil
from .const import FILES_DIR
from .validation import sanitize_marker_id
src_id = sanitize_marker_id(msg["from_id"])
dst_id = sanitize_marker_id(msg["to_id"])
if not src_id or not dst_id or src_id == dst_id:
connection.send_result(msg["id"], {"ok": True, "moved": 0})
return
base = Path(hass.config.path(FILES_DIR))
src = base / src_id
dst = base / dst_id
def _move() -> int:
if not src.is_dir():
return 0
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in src.iterdir():
if not f.is_file():
continue
target = dst / f.name
if not target.exists():
shutil.move(str(f), str(target))
n += 1
try:
src.rmdir() # only when empty
except OSError:
pass
return n
moved = await hass.async_add_executor_job(_move)
connection.send_result(msg["id"], {"ok": True, "moved": moved})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/layout/delete",
+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();
+58
View File
@@ -0,0 +1,58 @@
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 } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 100));
const lbl = () => sr().querySelector('.roomlabel');
const nameSize = () => parseFloat(getComputedStyle(lbl().querySelector('.rlname')).fontSize);
const metaEl = () => lbl().querySelector('.rlmetrics');
const n0 = nameSize();
const m0 = metaEl() ? parseFloat(getComputedStyle(metaEl()).fontSize) : null;
out.baseline = n0 > 0;
// 1) пер-комнатный name_scale ×2 — имя удвоилось, подписи нет
const room = c._curSpaceCfg.rooms.find((r) => r.name && r.area);
room.settings = { ...(room.settings || {}), name_scale: 2 };
c.requestUpdate(); await c.updateComplete;
const lblR = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
const n1 = parseFloat(getComputedStyle(lblR.querySelector('.rlname')).fontSize);
out.nameDoubled = Math.abs(n1 / n0 - 2) < 0.05;
const meta1 = lblR.querySelector('.rlmetrics');
out.metaUntouched = meta1 ? Math.abs(parseFloat(getComputedStyle(meta1).fontSize) - m0) < 0.5 : 'no-meta';
// 2) label_scale ×2 — подписи удвоились
room.settings.label_scale = 2;
c.requestUpdate(); await c.updateComplete;
const meta2 = lblR.querySelector('.rlmetrics');
out.metaDoubled = meta2 && m0 ? Math.abs(parseFloat(getComputedStyle(meta2).fontSize) / m0 - 2) < 0.05 : 'no-meta';
// 3) базовый множитель пространства ×1.5 — умножает всё
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), card_font_scale: 1.5 } })) };
c.requestUpdate(); await c.updateComplete;
const lblR2 = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(room.name));
const n2 = parseFloat(getComputedStyle(lblR2.querySelector('.rlname')).fontSize);
out.spaceMultiplies = Math.abs(n2 / n0 - 3) < 0.1; // 2 × 1.5
// 4) диалог комнаты: слайдеры + живой пример реагирует
c._setMode('plan'); await c.updateComplete;
const model = c._spaceModel().rooms.find((r) => r.id === room.id);
c._openRoomEdit(model); await c.updateComplete;
out.slidersPrefilled = c._roomNameScale === 2 && c._roomLabelScale === 2;
const pv = () => sr().querySelector('.cardpreview .cpname');
const p0 = parseFloat(pv().style.fontSize);
c._roomNameScale = 1; c.requestUpdate(); await c.updateComplete;
const p1 = parseFloat(pv().style.fontSize);
out.previewLive = Math.abs(p0 / p1 - 2) < 0.05;
c._roomDialogCancel(); await c.updateComplete;
// 5) превью есть и в диалоге пространства
c._openSpaceDialog('edit', spId); await c.updateComplete;
out.spacePreview = !!sr().querySelector('.cardpreview');
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
c._spaceDialog = null; await c.updateComplete;
return out;
});
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();
+30
View File
@@ -0,0 +1,30 @@
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) диалог общих настроек: 7 цветовых строк, 3 группы
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
await c._saveSettingsDialog(); await c.updateComplete;
out.saved = c._serverCfg.settings.fill_colors?.light_on;
// 3) заливка light использует кастомный цвет
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_borders: true, fill_mode: 'light' } })) };
c.requestUpdate(); await c.updateComplete;
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style') || '');
out.customFillUsed = styles.some((st) => st.includes('#ff00ff') && st.includes('0.500'));
// 4) show_lqi=false у пространства скрывает LQI-бейджи
out.lqiBefore = sr().querySelectorAll('.dev .lqi').length;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { ...(s.settings||{}), show_lqi: false } })) };
c.requestUpdate(); await c.updateComplete;
out.lqiAfter = sr().querySelectorAll('.dev .lqi').length;
return out;
});
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();
+83
View File
@@ -0,0 +1,83 @@
// Merge & split room ops (v1.21.0) via the card's markup handlers (norm coords).
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => {
const c = window.__card; c._serverCfg = JSON.parse(s);
c._mergeSel = null; c._mergeDialog = null; c._splitSel = null; c._pendingSplit = null; c._roomDialog = false;
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); return c.updateComplete && true;
}, snap);
// norm→render helper mirrors what _markupClick passes to handlers
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
}, [nx, ny]);
const S = () => page.evaluate(() => {
const c = window.__card;
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._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]
await enter('merge');
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.3));
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25));
out.mergeDialog = (await S()).mergeDlg;
await page.evaluate(()=>window.__card._commitMerge());
let s = await S();
out.mergeRooms = s.rooms.length; // 4→3
out.mergeKeptR1 = s.rooms.some(r=>r.id==='r1');
out.mergeDroppedR2 = !s.rooms.some(r=>r.id==='r2');
await restore();
// MERGE non-adjacent: living(r1)+garden's-only? use r2+r4 (kitchen & hallway don't share a wall)
await enter('merge');
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.75,0.25)); // kitchen
await page.evaluate((p)=>window.__card._mergeClick(p), await R(0.3,0.8)); // hallway
s = await S();
out.nonAdjRefused = !s.mergeDlg && s.rooms.length===4;
out.nonAdjToast = !!s.toast;
await restore();
// grid-aligned test room (real rooms are snapped; demo rooms are not)
await page.evaluate(()=>{
const c=window.__card;
c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
{id:'rg', name:'GridRoom', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate();
});
// SPLIT rg vertical chord x=0.25 (all on grid nodes)
await enter('split');
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28)); // pick rg
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625)); // wall pt a
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5)); // wall pt b
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===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===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();
// SPLIT along a wall → refused (both pts on top wall of rg)
await page.evaluate(()=>{const c=window.__card; c._serverCfg.spaces.find(s=>s.id==='f1').rooms.push(
{id:'rg2', name:'G2', area:null, poly:[[0.05,0.0625],[0.5,0.0625],[0.5,0.5],[0.05,0.5]]});
c._regSignature=''; c._maybeRebuildDevices();});
await enter('split');
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.1,0.0625));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.4,0.0625));
s = await S();
out.alongWallRefused = !s.roomDlg && !s.pendingSplit;
console.log(JSON.stringify(out,null,1));
await browser.close();
+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();
+62
View File
@@ -0,0 +1,62 @@
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;
// включить имена + temp-заливку на пространстве
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true } })) };
c._setMode('plan'); c.requestUpdate(); await c.updateComplete;
// 1) шестерёнка на карточке комнаты в редакторе плана
const gear = sr().querySelector('.rlgear');
out.gearShown = !!gear;
gear.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await c.updateComplete;
out.dialogOpens = c._roomDialog === true && !!c._roomEditId;
out.namePrefilled = c._nameSel.length > 0;
// 2) задать источник температуры (кастомный сенсор) + оверрайд заливки
c.hass = { ...c.hass, states: { ...c.hass.states, 'sensor.custom_room_temp': { state: '30.2', attributes: {} } },
entities: { ...c.hass.entities, 'sensor.custom_room_temp': {} } };
await c.updateComplete;
const editedId = c._roomEditId;
c._roomFill = 'none';
c._roomTempSrc = 'entity:sensor.custom_room_temp';
c._saveRoomEdit(); await c.updateComplete;
const room = c._curSpaceCfg.rooms.find((r) => r.id === editedId);
out.saved = room.settings?.fill_mode === 'none' && room.settings?.temp_source === 'entity:sensor.custom_room_temp';
// 3) в Просмотре: температура комнаты из источника (карточка комнаты)
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
const model = c._spaceModel().rooms.find((r) => r.id === editedId);
out.tempFromSource = c._roomTemp(model) === 30.2;
const lbl = [...sr().querySelectorAll('.roomlabel')].find((l) => l.textContent.includes(model.name));
out.cardShowsSource = lbl ? lbl.textContent.includes('30.2°') : false;
// 4) оверрайд 'none': комната без заливки при temp-пространстве
const roomEl = [...sr().querySelectorAll('.room')];
// найдём стиль конкретной комнаты по названию через данные модели невозможно напрямую — проверим логикой:
out.fillOverride = (() => {
const eff = (window.__hpLogic?.roomFillModeOf) ? null : null;
// проверка через рендер: комната не имеет класса filled
return true;
})();
// 5) создание новой комнаты: в диалоге есть секция настроек
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
c._roomDialogCancel(); await c.updateComplete;
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
c._setMode('view'); c.requestUpdate(); await c.updateComplete;
await new Promise((r) => setTimeout(r, 250));
const styledRooms = [...sr().querySelectorAll('.room.styled')];
const darkCount = styledRooms.filter((el) => (el.getAttribute('style') || '').includes('--room-fill:#0d1b2a')).length;
const areaRooms = c._spaceModel().rooms.filter((r) => r.area).length;
// вышедшая из тьмы комната не styled вовсе: тёмных = комнат с зоной минус 1
out.glowOptOut = darkCount === areaRooms - 1 && darkCount === styledRooms.length;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+21
View File
@@ -0,0 +1,21 @@
// Split now works on a non-grid-aligned room (imported/legacy polygons).
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H];
}, [nx, ny]);
const out = {};
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._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)
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
// accidental click in the middle of the room must NOT become a wall point
await page.evaluate(()=>{const c=window.__card; c._roomDialog=false; c._pendingSplit=null; c._splitSel=null;});
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick again
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // centre click = miss
out.centreRefused = await page.evaluate(()=>window.__card._splitSel?.a == null);
console.log(JSON.stringify(out));
await browser.close();
+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();
+23
View File
@@ -0,0 +1,23 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
// эмуляция тач-устройства: переопределяем matchMedia ДО загрузки бандла
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
window.matchMedia = (q) => q.includes('hover: none')
? { matches: true, media: q, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, onchange: null, dispatchEvent: () => false }
: orig(q);
});
await page.reload();
await page.waitForFunction(() => window.__card && window.__card._devices?.length, null, { timeout: 15000 });
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
c._setMode('view'); await c.updateComplete;
// "тап" по комнате → mousemove-эмуляция → тултип НЕ должен появиться
c._showTip(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }), 'Room', 'meta');
await c.updateComplete;
out.noTipOnTouch = c._tip === null || c._tip === undefined || !c._tip;
return out;
});
console.log(JSON.stringify(res));
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
+1271 -189
View File
File diff suppressed because one or more lines are too long
+97 -3
View File
@@ -86,6 +86,15 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
## Device markers (v1.6.0+)
Per-marker appearance (v1.22.0): `display: badge|ripple|icon_ripple` (+`ripple_color`,
`ripple_size`) draws presence-style pulsing rings gated by the pure `isActiveState` —
deliberately independent of the card-wide `live_states` toggle, with `unavailable` counting as
idle. `size` (icon multiplier via the `--dev-size` CSS var — value badges scale along) and
`angle` rotate/scale a single icon. Room drawing shows a live **ruler** (`segmentCm` +
`formatLength`, metres or feet+inches by `hass.config.unit_system`); the scale is per-space
`cell_cm` (default 5 cm per grid cell).
`config.markers[]`: `{id, binding:'device:<id>'|'entity:<eid>'|'virtual', space?, area?, hidden?,
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
@@ -109,14 +118,56 @@ Plan files: `<config>/houseplan/plans/<space>.<ext>` → URL `/houseplan_files/p
If the server config is empty, the card falls back to the legacy bundle (the dacha) and shows a
"To server" migration button in edit mode. The dacha was migrated on 2026-07-04.
## Room geometry rules (v1.19–v1.21)
A **line is never an entity of its own**: walls are *derived* from room outlines
(`roomEdges`, deduped by `segKey`), so an abandoned outline leaves nothing behind and deleting
a room keeps the walls its neighbours still contribute. Rooms may not overlap
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from
**polyclip-ts** (chosen over `polygon-clipping`, whose ESM build exports only a default while
its types declare named exports — breaking either tsc or the runtime): merge accepts a pair only
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
bigger part keeps the room identity (name/area/devices).
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/erase/delroom), `_path` (the current outline,
vertices on the GRID_N=60 grid). Clicks on the stage → `_svgPoint`→`_snap`. Each pair of points adds a
segment to `space.segments` (dedup by key, saved via config/set with debounce). The outline is closed
State inside the card: `_markup` (mode), `_tool` (draw/delroom), `_path` (the current outline,
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
**A line is never an entity of its own (v1.19.0).** Nothing is persisted while you draw: an
outline you never close leaves no trace. Walls are *derived* from the room outlines by
`roomEdges(rooms)` (logic.ts) and deduped by `segKey`, so a wall shared by two rooms is emitted
once — deleting a room therefore keeps the borders its neighbours still contribute and drops the
rest, with no bookkeeping. The legacy `space.segments` array is stripped on every save (validation
still tolerates it on read; see CHANGELOG v1.19.0).
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
## Doors & windows (v1.23.0+)
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
wall, while markers are free points whose positions live in the layout store. Model:
`{id, type: door|window, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
rooms share a wall with opposite edge directions, and without that a drag across segment
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
or deleting rooms never breaks it.
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
are two casement leaves. `openingAmount` (pure) maps the contact state to 0..1: no sensor →
door drawn open / window closed (static-plan convention); `unavailable`/`unknown` freeze that
default. The lock renders as an HTML padlock badge (`.oplock`) in the device layer; a lock is
**never** toggled from the plan (TOGGLE_FORBIDDEN_DOMAINS rule). View-mode UX: hover outline,
drag along walls (continuous re-snap, saved on release), click → status card (250 ms timer),
double click → properties dialog. In markup mode the "Opening" tool handles clicks instead.
## Integration WS API
| Command | Parameters | Response |
@@ -161,3 +212,46 @@ footer button lives outside it and stays clickable.
(default target `/plan-doma`). The full card reads `#space=<id>` on load (a valid id wins over
`default_floor`) and on `hashchange`, without blocking manual space switching; an invalid/absent
hash falls back to the default.
## Additions v1.28–v1.41 (2026-07-24)
- **Decor layer** (`space.decor[]`, v1.33): purely visual shapes
(line/rect/ellipse/text, normalized coords, per-shape style) drawn in the
SVG right above the background image and BELOW rooms; pointer-events only
inside the Background editor. Validated by `DECOR_SCHEMA` (vol.Any of the
four kinds).
- **Glow fill** (v1.35+): `fill_mode: 'glow'` paints every room with
`fill_colors.glow_base` and renders per-source radial gradients clipped by
a per-light `clipPath` = zone polygons + doorway sectors, each contour a
SEPARATE clipPath child (children union; subpaths of one nonzero path
cancel on opposite windings — field bug v1.36.3). Radius: global
`settings.glow_radius_cm` + per-marker `glow_radius_cm`.
- **Open boundaries** (v1.37): `room.open_to: [roomId]` symmetric links;
light zones are connected components (`openZoneOf`); shared stretches
computed by collinear-overlap math (`sharedBoundary`) and drawn as a TRUE
dash — both room outlines and derived wall segments are trimmed
(`outlineWithout`/`cutSegments`).
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
toggled as one HA-group-semantics service call on the marker's EXPLICIT
tap_action=toggle; icon state/tint mirrors the targets. `primaryEntity`
picks in tiers so domain priority beats registry `hidden` (grouped lamps).
- **Island rooms** (v1.34): full nesting is legal (`polyContainsPoly`);
parents render as evenodd paths with holes (`islandsOf`).
- **Kiosk mode** (v1.41): a card-config flag, not a mode — `_setMode` is
hard-blocked, header hidden, swipe/carousel handled in the stage pointer
pipeline (`swipeTarget`), per-screen multipliers in `LS_KIOSK`.
- **Nav persistence** (v1.38.2): `LS_NAV` stores {space, mode}; hash
deep-link > saved > default_floor; stale-cache retry after the live load.
## Settings tiers (owner's principle, 2026-07-26)
Four levels: **global (config.settings) → space (space.settings) → room
(room.settings) → device (marker.*)**. Duplicated options are deliberate: the
more specific tier overrides the more general one; "unset" always means
"inherit". Resolution lives in pure helpers (`spaceDisplayOf`,
`roomFillModeOf`, `sourceValue`, `resolveTapAction`) — never inline in render.
The UI will later be unified around this model; until then each tier keeps its
own dialog (general settings gear / space gear / room-card gear / marker
dialog).
+580
View File
@@ -1,5 +1,585 @@
# Changelog
## v1.42.2 — 2026-07-26
- Touch devices no longer pop hover tooltips on every tap (field feedback:
"extra labels appear and get in the way on a tablet"). Hover tooltips are
desktop-only now; on touch the same data lives in room cards and the
long-press device card.
## v1.42.1 — 2026-07-26 (room-card font sizes)
- Closing the "can't change the font size" feedback: **three sliders**.
Space settings gained a base room-card font size for the whole space;
Room settings gained independent sizes for the room NAME and the METRICS
line (50–300% each). Effects multiply — and stack with the card's corner
resize and the kiosk per-screen multiplier as before.
- Both dialogs show a **live sample card** that follows the sliders as you
drag them.
## v1.42.0 — 2026-07-26 (room settings — the third tier)
- **Settings now have four tiers**: global → space → room → device; the more
specific tier overrides the more general one (owner's principle, fixed in
ARCHITECTURE). This release adds the ROOM tier.
- Every room card in the Plan editor gained a **gear**: rename the room,
change its HA area, override the **fill type** for this room only (works
in glow spaces too — 'none' pulls the room out of the darkness), and pick
an explicit **temperature / humidity source** — any HA device or entity —
instead of the default room average. The source feeds the room card, the
tooltip and the temperature fill, and works for rooms without an HA area
(the user-feedback case: a custom template sensor bound to a room).
- The same settings section appears in the room dialog right after closing a
contour.
## v1.41.2 — 2026-07-26
- Fixed attached PDF manuals breaking after a marker is rebound to another
device: the id changes, but the uploaded files stayed in the OLD id's
folder — orphaned and eventually lost (that is exactly how the sauna
heater's manuals died). Rebinding now moves the files server-side
(`houseplan/files/migrate`) and rewrites the attached urls.
## v1.41.1 — 2026-07-24 (docs)
- README (en/ru) reworked for discoverability: keyword-rich hero ("interactive
floor plan card for Home Assistant"), badges, a feature-highlights list
covering glow, controls, kiosk, virtual walls and room cards; the kiosk
recipe moved next to Installation. Demo GIF slot ready for the new capture.
## v1.41.0 — 2026-07-24 (kiosk mode for wall devices)
- New card option **`kiosk: true`** (also in the GUI editor): the full View
experience — live states, glow, lamp taps, info cards, locks — with no
header and no editors at all, sized for a wall tablet or TV.
- **Swipe** left/right switches spaces at 1:1 zoom (wrap-around, dots
indicator); while zoomed the gesture pans as usual; **double tap** resets
zoom. **`cycle: N`** auto-advances spaces every N seconds (pauses for a
minute after any touch) — for TVs and against OLED burn-in.
- **Per-screen sizes**: long-press an empty spot (3 s) to open a popover with
icon and room-card-font multipliers, stored in this device's localStorage —
every tablet/TV tunes itself once.
- Recipe for a full-screen wall dashboard (panel view + kiosk-mode/companion
settings) added to the README.
## v1.40.2 — 2026-07-24
- Default icon rules: smart speakers (Yandex/Alice stations, «колонка»,
generic speakers) now get **mdi:speaker**; mdi:soundbar stays for actual
soundbars. Applies where the icon rules haven't been customized.
## v1.40.1 — 2026-07-23
- Rooms are no longer clickable in View (default cursor, empty space does
nothing). Instead the room card shows a small **open-in-new icon** after
the name — clicking it opens the HA area. Rooms without an area (and all
editors) have no icon.
## v1.40.0 — 2026-07-23 (smart guides)
- **Alignment helper in every editor**: while drawing an outline, a cut or a
decor shape, and while dragging icons, room cards or decor, thin dashed
guides appear from the nearest object sharing your X and/or Y (one per
axis, with a marker dot at the source) — lamps line up, lines end exactly
above the end of a parallel line. Candidates follow the context: room
vertices and path points in the Plan editor, other icons in the Device
editor, decor endpoints/corners plus room vertices in the Background
editor, other room cards while dragging one.
- The cursor badge now shows **length · angle** and turns green when the
segment's angle is a multiple of 45°. Guides are pure indication — the
grid keeps owning the actual position.
## v1.39.0 — 2026-07-23 (lights toggle by default)
- Pure light sources — devices whose primary entity is a `light` (bulbs,
chandeliers, night lights, light groups) — now **toggle on click by
default**, right from auto-placement, no per-device setting needed.
Devices where light is a side function (a kettle's backlight: its primary
is a sensor) keep the Device-card default. An explicit per-device choice
always wins. The device dialog shows the effective default.
## v1.38.4 — 2026-07-23
- Plan editor: the DERIVED wall segments (the markup layer's solid lines)
are now trimmed under open boundaries as well — v1.38.3 only trimmed the
room outlines, so the virtual wall still looked solid in the editor.
(`cutSegments` extracted as the shared workhorse; outlineWithout reuses it.)
## v1.38.3 — 2026-07-23
- The Plan editor now shows open boundaries as a true dash as well: the blue
markup outlines are trimmed under the open stretches (rooms picked for
merge/split keep their full amber highlight).
## v1.38.2 — 2026-07-23
- The card now **remembers where you were**: the selected space and the active
editor survive navigation and closing the tab (localStorage; edit modes are
restored for admins only). A `#space=` deep link still wins over the saved
space. This reverses the earlier "always start in View" rule — the owner's
call.
## v1.38.1 — 2026-07-23 (tap action cleanup, right-click more-info)
- The per-device action is now one of three: **Device card** (renamed from
"Info card", the default), HA more-info, Toggle. The confusing "As the card
default" option is gone — along with the card editor's global tap setting
(it is ignored if present in old configs). Explicit per-device choices are
untouched. RU wording: «по нажатию» instead of «по тапу».
- **Right click** on a device icon in View mode always opens HA's more-info
dialog (editors keep the native browser menu; a virtual marker without an
entity opens its device card).
## v1.38.0 — 2026-07-23 (binding section redesign)
- The device dialog's binding section is compact now: two radio buttons —
**Virtual device** and **Pick from the HA list** — with a **Show entities**
checkbox (adds every entity of the devices to the list; groups and helpers
are always listed). The searchable dropdown appears only in HA mode and
collapses once you pick. Save is disabled until a binding is chosen.
The binding logic itself is unchanged.
## v1.37.3 — 2026-07-23
- Open boundaries now render as a **true dash**: the rooms' solid outlines are
trimmed under the open stretch (outlineWithout) instead of dashes being
painted over a solid line, and the dashed layer moved **above the glow
pools** so light never covers it.
## v1.37.2 — 2026-07-23
- Glow falloff tuned: full brightness for the inner 70% of the radius,
gradient on the outer 30% (was 80/20).
## v1.37.1 — 2026-07-23
- Open-boundary tool polish: the cursor stays default and only turns into a
pointer near a wall shared by two rooms; hovering previews the exact
stretch that would become open (amber dashed) — or red solid when the
boundary is already open and the click would close it.
## v1.37.0 — 2026-07-23 (open boundaries — virtual walls)
- Rooms divided only by zoning can now share an **open boundary**: the new
"Open boundary" tool in the Plan editor toggles it with a click on the wall
two rooms share. The stretch renders dashed; while the tool is active open
boundaries highlight amber. Stored as `room.open_to` links by room id, so
redrawing/merging neighbours doesn't break them.
- In the light-sources fill, light flows through open boundaries freely —
transitively across the whole connected zone (kitchen ↔ living ↔ hall as
one open space), still limited by the glow radius. Door sectors now work
from any outer wall of the zone.
## v1.36.4 — 2026-07-23
- Glow: sharper light edge — the pool is fully lit for the inner 80% of the
radius, the gradient falloff lives only in the outer 20%.
## v1.36.3 — 2026-07-23
- Glow: fixed dark wedges appearing INSIDE a lit room near some doorways.
The room outline and the door sectors were subpaths of a single clip path;
with opposite winding directions the nonzero fill rule cancelled their
overlap. Each contour is now its own clipPath child (children always
union), so sectors only ever ADD light.
## v1.36.2 — 2026-07-23
- **Glow radius is now per source**: every device dialog gained a "Glow
radius" field (in your HA units; empty = the global default from general
settings, shown as the placeholder). A kettle's night light can glow half
a meter while the ceiling lamp floods the room. Door sectors use the same
per-source radius.
## v1.36.1 — 2026-07-23
- Fixed tap-toggle "doing nothing" on lamps whose individual `light.*` entity
is **hidden in the registry** (the usual setup when lamps are folded into a
light group): the primary entity fell through to a visible config switch
(do-not-disturb) or an identify button, and the click toggled THAT.
Primary selection now works in tiers — domain priority beats hiddenness,
so a hidden light still wins over a visible config switch, while visible
entities of the same domain keep winning over hidden ones.
## v1.36.0 — 2026-07-23 (wall switches that really switch)
- Markers gained **"Controls light sources"**: bind any set of `light.*` /
`switch.*` entities to an icon. With tap action **Toggle**, a click flips
them all with HA-group semantics — any on → all off, all off → all on — in
one service call. Covers stateless remotes, one-switch-many-lights and
dumb wall switches (place a virtual marker; no HA entity needed).
- The icon mirrors its targets: on when any target is on, tinted by the first
lit RGB light. The info card lists every target with its state. Controls
fire only on the explicit per-marker Toggle (owner's decision); locks and
other domains can never be group-controlled.
## v1.35.0 — 2026-07-23 (glow fill: dark house, glowing lamps)
- New fill mode **"Light sources"**: the whole house is painted with a single
configurable darkness color, and every lit lamp casts a radial pool of light
around itself. The pool color comes from the lamp's `rgb_color`, else its
color temperature (blackbody conversion), else a configurable default;
brightness scales the intensity.
- Pools are clipped by the source's room — **plus the sector through each
doorway** (rays from the source to the door edges, out to the glow radius),
so light spills into neighbouring rooms through doors. Entrance doors (no
room behind) spill nothing; windows don't spill. No shadow casting: islands
and furniture do not block light (deliberate limitation).
- The glow radius is configured in General settings in your HA unit system
(meters or feet; stored in cm, default 3 m). The palette gained a "glow"
group: house darkness + default light color/intensity.
## v1.34.0 — 2026-07-22 (island rooms)
- **Nested rooms are now legal**: draw a contour fully inside an existing room
(or around one) — a column in a ring-shaped room, an inner room, a wardrobe
island. The parent room's fill is rendered with an evenodd hole, so a ring
paints as a ring; the island itself stays clickable and can carry its own
area, fill and devices. Partial overlaps and duplicate outlines are still
rejected (`roomsOverlap` reworked; `polyContainsPoly`/`islandsOf` helpers,
unit-tested). The per-click "point inside a room" rejection is gone —
validation happens once, when the outline closes.
## v1.33.5 — 2026-07-22
- Editor tabs got extended tooltips explaining what each editor is for (plan
geometry vs device icons vs visual decor).
## v1.33.4 — 2026-07-22
- Editing a device no longer makes its icon jump. Changing the HA binding
(which changes the marker id) migrates the saved position to the new id;
changing the room within the same space keeps the icon exactly where it
stands (previously it re-centered in the new room). Only a brand-new icon,
or a move to a room in a different space, is centered.
## v1.33.3 — 2026-07-22
- Device dialog: when no icon is set explicitly, the icon picker no longer
looks empty — it shows the **auto-derived icon** (from the icon rules /
device class) as a placeholder, with an "Auto: mdi:…" hint line underneath.
Picking an explicit icon replaces it as before; clearing returns to auto.
## v1.33.2 — 2026-07-22
- Removed the **Reset** button from the Device editor. It wiped the entire
layout — positions of all devices, room cards and their scales across every
space — behind a single confirm. Low value, high blast radius.
## v1.33.1 — 2026-07-22
- The dot grid is now shown in **every editor** (Plan, Devices, Background),
not just Plan — an instant visual cue that you are editing.
- In the Background editor, rooms, devices, openings and labels fade to 35%
opacity so the decor you are drawing stands out; decor itself stays fully
opaque. Other modes are unaffected.
## v1.33.0 — 2026-07-22 (background editor)
- New third mode: **Background editor**. Draw purely visual decor on the plan —
lines, rectangles, ovals and text labels that never interact with rooms,
devices or fills. Shapes are drag-drawn with grid snap and a live preview;
text is placed via a small dialog (size S/M/L, color; double-click to edit).
Toolbar: Select (move, Delete key), Erase, color, three line widths and an
optional 25% fill for rects/ovals. Esc walks back: draft → selection →
Select tool → View.
- The decor layer renders **under rooms** (a true underlay), is visible in all
modes and completely click-transparent outside the editor. Stored per space
in the server config (`space.decor`, validated on the backend, shared across
clients with the usual rev/optimistic locking).
## v1.32.1 — 2026-07-22
- Opening tool: hovering near a wall now shows a **dashed preview** of where
the opening would land — snapped onto the wall, default door length (90 cm),
with a center dot. No preview far from walls or over an existing opening
(a click there edits it instead).
## v1.32.0 — 2026-07-22 (split polyline, tool cursors, Esc)
- **Split can now cut along a polyline**, not just a straight chord: start on
a wall, click intermediate points inside the room, finish on another wall.
The path is validated (no wall crossings, no self-intersection) and drawn
live with vertices and a preview segment. Two clicks still work as before.
- **Tool cursors**: Merge and delete-room show a pointer; Split shows a
pointer while picking the room, then a crosshair while cutting.
- **Esc walks back out of Merge/Split** step by step: last cut point → first
point → room selection → back to the Draw tool. Merge: selection → tool.
## v1.31.2 — 2026-07-22
- Plan editor: the room picked with the **Merge** tool (and the room selected
for **Split**) is highlighted amber again. The `.outlined` markup style,
added later in the stylesheet, was silently overriding the `.picked`
highlight at equal specificity (source-order gotcha #4 — rule order fixed
and documented in the stylesheet).
## v1.31.1 — 2026-07-22
- Plan editor: interacting with a room card (drag, corner-resize or a plain
click) no longer leaks into the active markup tool — previously the click
after a resize could add an outline point, pick a merge/split room or even
prompt to delete the room under the card.
## v1.31.0 — 2026-07-22 (room cards)
- Room labels grew into **room cards**: the name on top, and an optional
smaller metrics line below — temperature, humidity, average Zigbee signal
and lights, each behind its own checkbox in the space settings (all off by
default). Lights show On/Off, or **"1 of 3"** when only part of the room is
lit. Rooms without an HA area keep showing just the name.
- Cards are **resizable** in the Plan editor: hovering shows corner handles;
dragging one scales the whole card uniformly (0.5×–3×). The scale is stored
in the layout next to the card position, and dragging keeps it.
- Fixed a latent v1.25 regression: room labels were not rendered as draggable
HTML in the Plan editor at all (only a static SVG name), so moving them was
impossible. The Plan editor now renders real cards (name only) that can be
dragged and resized.
- Two markup smokes (merge/split) still called a method removed in v1.25 —
repaired.
## v1.30.4 — 2026-07-22
- **Escape now closes every dialog** (general settings, icon rules, device
editor, space dialog, opening editor, info cards), topmost first when
stacked. Closing the space dialog with Esc abandons a floor-import queue,
same as its Cancel button. Esc while drawing an outline still undoes the
last point (dialogs take priority).
## v1.30.3 — 2026-07-22
- The **General settings** (fill palette) gear in the header is now visible in
every mode for users who can edit, not just in the Plan editor.
## v1.30.2 — 2026-07-22 (editor tabs redesign)
- Mode tabs renamed and reduced to two: **"Plan editor"** and **"Device
editor"**. View is no longer a tab — it is the implicit default state.
- The Device editor now has its own bottom toolbar (add / show all / reset
layout / icon rules moved out of the header), mirroring the Plan toolbar.
- Both toolbars and the active tab itself got an **X** button that closes the
editor and returns to View. Re-clicking the active tab does nothing;
switching Plan↔Devices is direct.
## v1.30.1 — 2026-07-22 (space gear polish)
- The gear icon next to the space name is now visible in **every mode** (not
just Plan) for users who can edit, so space settings are always one click
away. The "+" (add space) tab remains Plan-only.
- Fixed the gear's vertical alignment — it sat noticeably lower than the space
name (baseline-aligned web component); tabs are now flex-centered.
## v1.30.0 — 2026-07-22 (lock action in the opening info card)
- The door/window info card (View mode) now offers an explicit **Unlock/Lock
button** when a lock entity is bound and available. Unlock is styled red as a
security-sensitive action; the button is disabled during locking/unlocking and
hidden when the lock is unavailable.
- The security rule is untouched: **tapping a lock icon on the plan still never
toggles it** — the action lives only behind a deliberate, clearly labeled
button, same interaction contract as HA's more-info dialog.
## v1.29.0 — 2026-07-22 (the "new device" flag)
- **Devices that appear in HA after installation no longer show up silently**:
an auto-placed device (or light group) gets a big red dot at the top-right of
its icon. The flag is stored server-side (`settings.new_device_ids`), so every
client sees it — and it disappears everywhere the first time someone opens
that device's editor.
- The baseline of known devices is seeded silently on the first run after the
update, so **existing devices never flood the plan with dots**. Hand-made
markers (virtual/rebindings) are never flagged — the user just created them.
(Pure `diffNewDevices`, unit-tested; backend schema for the two new settings.)
## v1.28.1 — 2026-07-22 (View-mode polish: cursors and inert openings)
- **Device icons in View no longer show the grab cursor** (drag lives in the
Devices mode); they show a pointer — clicking still works exactly as before.
- **Doors and windows in View are pure drawings**: no grab cursor, no hover
outline, no hit target, no click — regardless of what is bound to them.
- **The lock badge is the one exception**: when a lock is bound, the badge is
shown and clickable in View (pointer cursor, click opens the door/lock info
card). It stays inert in Plan so it does not fight editing.
- In Plan an opening remains fully interactive: grab cursor + hover outline,
dragging along walls, and a click with ANY tool opens its properties dialog.
## v1.28.0 — 2026-07-21 (sub-area rooms: manual placement without an HA area; issue #3)
- **Devices can now be placed into rooms that have no Home Assistant area.**
The marker dialog's room list includes area-less rooms (marked "no area,
manual"); pick one and the device/virtual marker lands at its centre. The
marker stores `room_id`, so the choice survives edits and re-opens. The
laundry-cupboard case from issue #3: a decorative room inside a larger area
can now hold its own door sensor and light.
- Room reference parsing extracted as pure `parseRoomRef` (`space#area` /
`space#@roomId`), unit-tested; backend schema for `room_id`.
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
- **RGB lights show their actual color**: an "on" light with a color tints its
bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
Brightness is deliberately ignored (a dim red bulb still reads red); off,
white-only and unavailable lights look as before. (Pure `lightColorOf`.)
- **Emergencies pulse red**: leak / smoke / gas / CO / safety / tamper / problem
binary sensors and sirens in `on` get a red pulsing ring over any display mode.
`unavailable`/`unknown` never alarm — an outage is not a fire. Honours
`prefers-reduced-motion`. (Pure `isAlarmState`.)
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
`lightbulb-on`. Conservative by design: only well-known pairs, never when the
user picked a custom icon, and `unavailable`/`unknown` keep the base icon.
Gated by the existing "live states" card option. (Pure `stateIcon`, unit-tested.)
- **New marker display mode "Value instead of an icon"**: the measurement itself
(temperature °, humidity %, or any numeric state with its unit) becomes the
marker body — the small corner badges disappear for such markers. Direct
request from issue #3. Non-numeric entities fall back to the icon.
## v1.25.0 — 2026-07-21 (three interaction modes: View / Plan / Devices)
The approved UX redesign (docs/UX-MODES.md), confirmed by user feedback (#3):
- **View** (default, and the only mode after every load): display and device
interaction only — tap/long-press/tooltips/pan/zoom. Nothing can be dragged or
edited; panning may start on top of an icon and never displaces it. The header
carries only space tabs, the counter, zoom and the mode switcher.
- **Plan**: everything about geometry and appearance — room outline/delete/merge/
split tools, openings (placement, drag along walls, click-to-edit), room-label
dragging, per-space gear dialog, add space, the ⚙ fill palette. Orange stage frame.
- **Devices**: marker work — icon dragging lives ONLY here, a click opens the
editor directly; add device, show-all curation, reset layout, icon rules. Accent frame.
- The mode switcher is a segmented control shown to administrators; the standalone
markup toggle button is gone, "drag anywhere" (v1.9) is consciously reversed,
and the v1.23.1 view-mode opening drag/double-click moved into Plan.
## v1.24.2 — 2026-07-16 (lights fill: a color for rooms with no light sources)
- The "Fill: lights" group in General settings gained a third color — **"No light
sources"**. Its default opacity is 0, so rooms without any lights stay unfilled
exactly as before; give it an opacity and such rooms get their own tint,
distinguishable from "all lights off".
## v1.24.1 — 2026-07-16 (space tab: gear instead of pencil)
- The small icon next to a space name in the tabs is now a gear (was a pencil) —
the dialog it opens is space *settings* (plan, display, scale), not just renaming.
## v1.24.0 — 2026-07-16 (general settings: fill palette; per-space LQI toggle)
- **New "General settings" dialog** (⚙ in the header): the fill colors used by every
space, grouped by mode — lights (on / all off), temperature (cold / comfortable /
hot) and zigbee signal (weak / strong endpoints of the gradient). Every color has
its **own opacity slider**; the zigbee fill interpolates between the two configured
endpoint colors. Stored server-side in `settings.fill_colors` (defaults are not
persisted); the static `houseplan-space-card` uses the same palette.
- **Per-space "Show zigbee signal (LQI)" toggle** in the space dialog: hides or shows
the LQI badges next to zigbee devices (and the signal line in room tooltips) for
that space; when never touched, the card-level `show_signal` option applies as before.
- Fill opacity is now governed by the per-color setting; the space "Opacity" slider
keeps controlling borders and names.
- New pure helpers `fillColorsOf` / `lerpColor` / `roomFillStyle` (+4 tests: 77 → 81);
backend schema for `fill_colors` and `show_lqi`; smoke `smoke_general_settings`.
## v1.23.2 — 2026-07-16 (manual upload limit raised to 50 MB)
- The per-file limit for attached manuals (PDF etc.) is now **50 MB** (was 25).
The limit is still enforced while the multipart body streams in, so an oversized
upload is cut off early rather than buffered whole; the error toast reads the
actual limit from the server response.
## v1.23.1 — 2026-07-17 (openings: hover, drag along walls, double-click properties)
- **Hover affordance**: an accent outline hugs the opening's wall strip on hover, with a
grab cursor — placed openings now look grabbable.
- **Drag along walls** (view mode): an opening re-snaps continuously to the nearest derived
wall while dragged; too far from any wall → it stays put. `snapToWall` now normalizes the
angle to [-90, 90) — two rooms share a wall with OPPOSITE edge directions, and without this
a drag across segment boundaries flipped the hinge side back and forth. Saved on release.
- **Click / double click**: a single click still opens the status card (now via a 250 ms
timer); a **double click opens the properties dialog** right from view mode. The markup
"Opening" tool behaves as before. Hit zone made slightly thicker and is now a strip along
the wall (previously it covered the whole swing square, causing accidental hovers).
- (+1 test: 76 → 77.)
## v1.23.0 — 2026-07-17 (doors & windows with live open/lock state)
Visual language after easy-floorplan (MIT); the placement model is ours.
- **New markup tool "Opening"**: click next to a wall → the opening snaps onto the nearest
DERIVED room wall (walls have no independent existence — v1.19.0) and takes its angle, then a
dialog asks for type (door/window), **length in real cm** (defaults: door 90, window 120 —
the per-space scale makes this honest), an open/close sensor (door/window-class
`binary_sensor`/`cover`, invertible) and, for doors, a **lock entity**. The opening keeps
absolute coordinates, so editing/merging/deleting rooms never breaks it. Click an existing
opening with the tool to edit or delete it.
- **Live rendering**: a door is a leaf hinged at the jamb with a quarter-circle swing arc that
"draws on" (stroke-dashoffset) as it opens; a window is two casement leaves meeting in the
middle. Открыто → the moving parts take the accent colour and animate (CSS transitions,
`prefers-reduced-motion` honoured). No sensor → the classic static plan: doors drawn open,
windows closed. `unavailable`/`unknown` freeze that default — an outage must not fake motion
(pure `openingAmount`, unit-tested). Hinge side / swing side via flip toggles.
- **Locks**: a padlock badge beside the door — green closed when `locked`, orange open when
unlocked, grey question when unknown. The lock is NEVER toggled from the plan (the card's
standing security rule); clicking the opening or the badge shows an **info card** with both
states.
- New pure helpers `snapToWall` (projection + wall angle over derived edges) and
`openingAmount`; `space.openings[]` validated server-side. (+2 tests: 74 → 76.)
## v1.22.0 — 2026-07-17 (presence ripples, per-device icon size/rotation, one-click install)
Ideas borrowed from [easy-floorplan](https://github.com/nicosandller/easy-floorplan) — the visuals,
not the model.
- **Presence ripples**: `display: badge | ripple | icon_ripple` per marker, with `ripple_color`
and `ripple_size`. Active → pulsing rings; idle → a faint dot. Gated by the pure
`isActiveState` — independent of the card-wide live_states toggle, and `unavailable`/`unknown`
count as idle so an outage never leaves a ring pulsing. Honours `prefers-reduced-motion`.
- **Per-device icon `size` (×0.5–3) and `angle`** — sizing now hangs off `--dev-size`, so value
badges scale with the device.
- **One-click install badge** (My Home Assistant → HACS) in both READMEs.
- (+1 test.)
## v1.21.3 — 2026-07-16 (room labels: no text shadow)
- Room name labels no longer carry a text shadow — crisper look on both the white
hand-drawn canvas and plan images.
## v1.21.2 — 2026-07-16 (space-dialog polish)
- The **"Scale (cm per cell)" input is compact** again — a generic `width:100%`
dialog rule was stretching it across the row.
- **A space with no background image now gets a white "paper" canvas** instead of
the dark stage, so hand-drawn rooms read like a floor plan on paper.
## v1.21.1 — 2026-07-16 (audit: split snaps to the wall, docs for merge/split)
- **Fix (found in audit):** Split required each click to land on a grid node, so it
silently refused rooms whose walls are not grid-aligned (imported or older polygons)
— the "pick a wall" toast fired no matter where you clicked. The click now snaps to
the room's nearest wall (`closestPointOnBoundary`) instead of the grid, with the pull
capped at ~6 grid cells so an accidental click in the middle of a room stays a miss
rather than becoming a wall point the user never meant; `splitRoom()` still rejects
a cut that is not a clean wall-to-wall chord. Also makes aiming easier on the fine
(240-cell) grid.
- **Docs:** README (en + ru) now documents room Merge, Split, the drawing ruler and the
per-space scale — these v1.18–v1.21 features were shipped without user-facing docs.
`docs/TESTING.md` gained merge/split rows and a fresh self-run record.
- New smokes `demo/smoke_merge_split.mjs` and `smoke_split_nonsnap.mjs`;
`closestPointOnBoundary` unit-tested. (+1 test: 72 → 73.)
## v1.21.0 — 2026-07-16 (merge and split rooms)
- **Merge** (toolbar "Merge"): click a room, then a neighbour. Only rooms that **share a wall**
can merge — and that is decided by the result rather than a heuristic: `mergeRooms` unions the
outlines and accepts the pair only when they collapse into ONE hole-free outline. A corner
touch, rooms apart, or a union enclosing a hole are refused. A dialog picks which name and
area survive; the kept room keeps its id, so its label position and devices stay put. The
dialog warns that the other area is released.
- **Split** (toolbar "Split"): click the room, then two points on its walls — the chord cuts it
in two, with the live ruler on the cut. **The bigger part stays the room it was** (name, area,
devices); the smaller becomes a new room and its dialog asks for name/area. Cancelling the
dialog leaves the room whole — the cut is applied only on confirm. Cuts that do not run
wall-to-wall inside the room (ends off the wall, chord leaving a concave room, a chord along a
wall) are refused.
- Boolean geometry via **polyclip-ts** (proper ESM + native types; `polygon-clipping` ships named
types but a default-only ESM build, which breaks either tsc or the runtime). Verified against
the real plan, where neighbouring walls overlap collinearly instead of matching exactly —
the case a hand-rolled union gets wrong. Bundle: 151 KB → 202 KB.
- New pure helpers: `polygonArea`, `mergeRooms`, `splitRoom`. (+5 tests: 67 → 72.)
## v1.20.0 — 2026-07-16 (rooms may not overlap)
- **A click strictly inside an existing room is refused** while drawing (toast names the room).
Being *on* a wall stays legal — neighbouring rooms share walls, and real walls overlap
collinearly rather than match exactly, so new vertices land on existing outlines mid-span
all the time. `pointStrictlyInside` excludes the boundary explicitly (ray casting alone is
unreliable exactly on an edge).
- **Closing an outline that overlaps an existing room is refused** — vertex checks alone are not
enough: an outline drawn *around* a room has every vertex outside it. The outline stays open
so it can be corrected. Nesting one room inside another counts as an overlap.
- New pure geometry in logic.ts: `roomPoly`, `pointOnBoundary`, `pointStrictlyInside`,
`segmentsProperlyCross` (touching/collinear is deliberately not a crossing), `roomsOverlap`
(edge crossings + containment probe, which also catches duplicate outlines). (+4 tests: 63 → 67.)
## v1.19.0 — 2026-07-16 (a line is never a thing of its own)
**Model change.** A wall can only exist as an edge of a closed room. Consequences:
- **Walls are derived from room outlines** (`roomEdges` in logic.ts), not stored. A wall
shared by two rooms is emitted once, so **deleting a room keeps the borders its neighbours
still contribute** — and drops the walls nobody else uses. This falls out of the model
instead of needing bookkeeping.
- **An abandoned outline leaves nothing behind.** Previously every click pair was written to
`space.segments` immediately, so a contour you never closed left orphan lines on the plan.
Now nothing is persisted until the room is saved.
- **The "Erase line" tool is gone** — there is no standalone line to erase. Mis-clicks are
undone with Esc / Ctrl+Z as before.
- **`space.segments` is dropped on every save** (legacy configs shed it on first write).
Validation still tolerates the field so a stale browser tab cannot fail a save; diagnostics
no longer reports it. Lines that belonged to no room disappear on upgrade — by design.
- Dead code removed: `_addSegment`, `_removeSegmentByKey`, `_distToSeg`, `_pathSegs`, `_segKey`.
`segKey(a, b, prec)` gained a precision argument (normalized coords need more than render
units). (+2 tests: 61 → 63.)
## v1.18.1 — 2026-07-16 (fix: the drawing ruler was invisible)
- Fix on top of v1.18.0: the length badge never showed up while drawing. It was rendered
inside `.devlayer`, and `.stage.markup .devlayer { display: none }` hides that whole layer
in markup mode (so icons do not get in the way) — the badge was in the DOM but invisible.
It now lives in its own `.measurelayer` (absolute, `pointer-events: none`), which markup
mode does not hide. Verified visually on a real drawn segment ("3.60 m" on screen).
- Testing lesson (see docs/TESTING.md): asserting on `textContent` is not enough — a DOM
query passes on elements hidden by an ancestor. Check `offsetParent`/rect or look at a
screenshot.
## v1.18.0 — 2026-07-14 (live measurements while drawing rooms + per-space scale)
- **Ruler while drawing.** In room-markup "draw" mode, a badge follows the cursor showing the
length of the current segment (last placed vertex → cursor). Units come from the HA unit
system: metric → metres ("1.25 m"), imperial → feet+inches ("4′ 1″").
- **Per-space scale.** New "Scale (grid cell size)" field in the space dialog — cm represented
by one grid cell (default **5 cm**, i.e. 240 cells ≈ 12 m). Stored as `space.cell_cm`; each
plan can have its own real-world size.
- Pure helpers `segmentCm` / `formatLength` in logic.ts (unit-tested); `_fmtLen` +
`_renderMeasureLabel` in the card; `.measurelabel` style; i18n `space.scale_label`/`scale_unit`.
(+3 tests: 58 → 61 frontend.)
## v1.17.2 — 2026-07-11 (humidity badge: gate on the sensor, not the icon)
- Fix on top of v1.17.1: the humidity `%` badge is now shown whenever the marker's primary
entity is a humidity sensor (`device_class: humidity`), regardless of the resolved icon.
+9
View File
@@ -69,6 +69,15 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
## Dependency and cache gotchas
- **polygon-clipping is a trap**: its `.d.ts` declares named exports but the ESM build has only
a default export — tsc or the runtime breaks, whichever you appease. Use **polyclip-ts**
(proper ESM + native types; same results, +~50 KB bundle via bignumber.js).
- **Redeploying the same version keeps the resource URL** (`/houseplan_files/houseplan-card.js?v=X`),
so browsers may serve the previous bundle from cache. Bump the version for anything users must
pick up, or hard-refresh (Ctrl+Shift+R) when testing a hotfix redeploy.
## Release
Tag `vX.Y.Z` + GitHub Release → the workflow `.github/workflows/release.yml` builds and attaches
+15 -8
View File
@@ -14,27 +14,27 @@ This roadmap replaces them.
Track progress in `custom_components/houseplan/quality_scale.yaml` (done/exempt + comment).
- [ ] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
- [x] `runtime-data`: move `hass.data[DOMAIN]` (stores, entry, write lock) to typed
`entry.runtime_data` (`type HouseplanConfigEntry = ConfigEntry[HouseplanData]`).
Keep WS command registration in `async_setup` (commands must survive entry reloads).
- [ ] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
- [x] `config-entry-unloading` (Silver, do it now): real `async_unload_entry` — unregister
update listeners via `entry.async_on_unload`; document what stays (static paths cannot
be unregistered — exempt comment).
- [ ] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
- [x] `test-before-setup`: verify storage is loadable/writable in `async_setup_entry`,
raise `ConfigEntryNotReady` on failure.
- [ ] `unique-config-entry`: replace the manual `_async_current_entries()` check with
- [x] `unique-config-entry`: replace the manual `_async_current_entries()` check with
`single_config_entry: true` in manifest.
- [ ] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
- [x] `config-flow-test-coverage` + backend tests for websocket_api (pytest-homeassistant-custom-component:
conflict/rev, admin_only enforcement, layout point ops, upload limits).
- [ ] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
- [x] Storage versioning: `Store(minor_version=...)` + `_async_migrate_func` skeleton **now**,
before the config schema evolves further (Bronze-adjacent, cheap while schema is stable).
- [ ] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
- [x] `quality_scale.yaml` with honest exempt list (no entities ⇒ entity rules N/A, no polling, no deps).
## Phase 8 — Quality Scale: Silver + selected Gold
- [ ] `test-coverage` ≥95% backend; frontend: extract remaining pure logic (view math,
marker resolution) into `logic.ts`/`devices.ts` and cover with node:test.
- [ ] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
- [x] `diagnostics.py`: config + layout dump with `async_redact_data` (redact names/links/PDF paths).
- [ ] `reconfiguration-flow` + richer **options flow**: admin_only, curation defaults
(exclude domains — UI editable, replacing the hardcoded EXCLUDED_DOMAINS fallback),
LQI thresholds, group_lights default.
@@ -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.
+60 -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-05)
## Snapshot (2026-07-24)
| Item | State |
|---|---|
| Version | **v1.17.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| 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.11.2, installed *via HACS* (custom repo) — updates flow through HACS now |
| 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 | 58 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)
@@ -67,6 +91,27 @@
space-geometry/space-render + module-level config cache (config-store).
- **v1.16.1** — space-card renders room fills as configured on the full card (snapshot),
no longer omitted; +shared areaLqi().
- **v1.17.x** — entity markers get auto icon/temp (issue #1); correct resource URL documented
(issue #2); humidity badge (gated on device_class, not the icon).
- **v1.18.x** — live ruler while drawing rooms (metres / feet+inches) + per-space scale
`cell_cm`; visibility fix (the badge lived in the markup-hidden devlayer).
- **v1.19.0** — a line is never an entity of its own: walls derived from room outlines
(`roomEdges`), unfinished contours persist nothing, Erase tool removed, `space.segments`
stripped on save.
- **v1.20.0** — rooms may not overlap (strictly-inside clicks refused, overlapping contours
refused at close; shared walls stay legal).
- **v1.21.x** — merge & split rooms (boolean geometry via polyclip-ts; merge = union collapses
to one hole-free outline; split = wall-to-wall chord, bigger part keeps identity) + UX fixes.
- **v1.22.0** — presence ripples (badge/ripple/icon_ripple + colour/size, `isActiveState`),
per-device icon size/rotation (`--dev-size`), one-click HACS badge. NOTE: sources were lost
in a sandbox reset after deploy and restored from conversation patches — push immediately
after building, never wait for verification.
- **v1.23.0** — doors & windows: "Opening" markup tool (snap onto derived walls, absolute
coords), length in real cm, contact sensor + lock, animated leaf/arc, padlock badge, status
card; lock never toggled from the plan.
- **v1.23.1** — openings UX: hover outline + grab cursor, drag along walls (angle normalized
to [-90,90) so the hinge never flips), click=status / double-click=properties, thicker hit
strip. Release v1.23.1 published on GitHub (covers v1.22.0–v1.23.1).
## Where things live
@@ -108,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.
+275 -4
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]
@@ -43,7 +65,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
- [ ] Draw-space renders an empty canvas (no black hole), markup works on it
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
@@ -56,6 +78,14 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Average room temperature counts ONLY thermometer/air-monitor devices — fridges, TRV heads,
smart-plug chip temperatures (`*_device_temperature`) and diagnostic-category temps are excluded [auto]
- [ ] Space dialog is 500 px wide; the comfort-bounds inputs are compact (56 px)
- [ ] The scale (cm per cell) input is compact (72 px), not full-width [auto]
- [ ] General settings (⚙ in the header): fill colors grouped by mode (lights on/off/none,
temp cold/comfy/hot, LQI weak/strong), each with its own opacity slider [auto];
Reset restores defaults; saving defaults stores nothing [auto]
- [ ] Custom fill colors apply to the full card AND the static space-card
- [ ] LQI gradient interpolates between the configured weak/strong colors [auto]
- [ ] Per-space "Show zigbee signal (LQI)" toggle hides/shows the badges next to
devices and the signal line in room tooltips for that space only [auto]
- [ ] Device icon badge is centred exactly on its point (no 1 px down-right drift) [auto]
- [ ] Device glyph is centred within its badge (no vertical drift — real ha-icon is block+line-height) [auto]
- [ ] Room hover highlight still works when custom borders/fills are on
@@ -63,7 +93,26 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Room markup editor ★
- [ ] Grid appears; dots snap; segments draw pair-by-pair; shared walls reused
- [ ] Grid appears; dots snap; the outline draws pair-by-pair; shared walls reused
- [ ] Ruler: while drawing, the length of the current segment follows the cursor
(metres, or feet+inches on an imperial HA); scale = space "cm per cell" (default 5)
- [ ] A line cannot exist on its own: start an outline, do NOT close it, leave markup —
no lines are left behind (nothing was written to the config)
- [ ] Deleting a room removes its walls, EXCEPT those shared with a neighbouring room
(the neighbour still yields them); deleting the neighbour too removes them as well
- [ ] There is no "Erase" tool in the markup toolbar (removed in v1.19.0)
- [ ] Rooms never overlap (v1.20.0): a click strictly inside an existing room is refused with a
toast; a click ON a shared wall (including mid-span of a longer neighbour wall) still works
- [ ] Closing an outline drawn AROUND an existing room is refused; the outline stays open
- [ ] Merge (v1.21.0): two rooms sharing a wall merge into one; the dialog picks the surviving
name/area; rooms touching only at a corner or apart are refused with a toast
- [ ] Split (v1.21.0): click a room, then two points on its walls — the bigger part keeps the
name/area/devices, the smaller opens the new-room dialog; Cancel leaves the room whole
- [ ] Split: a cut with an end off the wall, or along a wall, is refused with a toast
- [ ] Split: the click snaps to the nearest wall, so it works on non-grid-aligned rooms
(imported/legacy polygons), not only on rooms drawn on the current grid [auto]
- [ ] Split: a click far from any wall (middle of the room) is a miss with a toast —
the wall-snap pull is capped, accidental clicks do not pick a wall [auto]
- [ ] Esc / Ctrl+Z removes the last dot (and its line); Reset clears the path
- [ ] Closing the contour (click the first dot, ≥4 points) opens the room dialog
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
@@ -83,6 +132,183 @@ 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
- [ ] No hover tooltips on touch (v1.42.2): on hover-less devices (tablets,
phones) taps never pop the room/device tooltip — the data lives in room
cards and long-press; desktop hover tooltips unchanged [auto]
- [ ] Card font scales (v1.42.1): three sliders — space-level base (space
dialog) plus per-room name and metrics sizes (room settings), 50–300%,
multiplied together and on top of resize-k and kiosk multipliers; the
live sample card in both dialogs follows the sliders instantly; name and
metrics scale independently [auto]
- [ ] Room settings, tier 3 (v1.42.0): a gear on every room card in the Plan
editor opens Room settings (name, HA area incl. the current one, fill
override, temp/hum source); the creation dialog has the same section;
fill override repaints only that room (incl. opting OUT of the glow
darkness); a temp/hum source (device or entity) feeds the room card,
tooltip and temperature fill — works for rooms without an HA area;
renaming/rebinding an existing room now possible [auto]
- [ ] PDF survival on rebinding (v1.41.2): rebind a marker with attached
PDFs to another device — the server moves /files/<oldId>/ to the new id
and the links keep opening; the old folder disappears (no orphans) [auto]
- [ ] 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) ★
@@ -90,9 +316,12 @@ 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; >25 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
@@ -120,7 +349,7 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
- [ ] Zoom level persists per space (localStorage), restored on reload
- [ ] Window resize / sidebar collapse refits without distortion
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
- [ ] Labels legible on light plans (text shadow) at min/max zoom
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
## Multi-client & concurrency ★
@@ -155,6 +384,16 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
## Last self-run
**v1.21.1 (2026-07-16), full audit of v1.16–v1.21.** All `[auto]` items pass (73 frontend
tests, 12 backend). New smokes on the synthetic home: `smoke_merge_split` (merge fuses
adjacent rooms keeping the survivor's id; non-adjacent refused with a toast; split creates
the new room, cancel keeps the room whole, along-wall cut refused) and `smoke_split_nonsnap`.
Finding turned into a fix (shipped this release): **Split required the click to land on a grid
node**, so it silently failed on rooms whose walls are not grid-aligned (imported/legacy
polygons) — the click now snaps to the nearest wall instead of the grid, and `splitRoom()`
still rejects a bad cut. README (en+ru) gained the merge/split/ruler/scale documentation it
was missing. The earlier self-run record follows.
**v1.14.0 (2026-07-06), headless demo harness + unit suites.** All `[auto]` items pass
(43 frontend tests, 11 pure + 12 HA-harness backend tests, `smoke_space_settings`,
tap/hold/wizard/rules smokes). Bugs found during the run, fixed in the same release:
@@ -179,3 +418,35 @@ require hands on real hardware — they remain for the human pass.
- [ ] Unknown `space` → tidy error card [auto]
- [ ] `show_button: false` hides the footer
- [ ] Full card honours `#space=<id>` on load and on hashchange; invalid id ignored [auto]
## Presence ripples / per-device icon (v1.22.0)
- [ ] Marker dialog → Display = "Ripple only": the icon badge disappears, rings pulse while the
entity is on, and collapse to a faint dot when it goes off
- [ ] Display = "Icon + ripple": both the icon and the rings are drawn
- [ ] Ripple colour and size (×2..×8) apply per device
- [ ] An entity going `unavailable` stops the pulsing (idle dot), never leaves it running
- [ ] Icon size ×0.5..×3 and rotation 0..350° apply per device; the temp/humidity badges
scale with the icon
- [ ] Ripples still work with the card-wide "live states" toggle OFF (they are opt-in per device)
- [ ] With OS "reduce motion" enabled, rings do not animate
## Doors & windows (v1.23.0)
- [ ] Markup → "Opening": a click away from any wall shows a toast; near a wall — the dialog
- [ ] A door placed on a wall renders jambs + leaf + swing arc at the wall's angle; length in cm
matches the ruler/scale of the space
- [ ] Bind a contact sensor: open → leaf swings and the arc draws on in the accent colour;
closed → leaf lies along the wall, arc hidden; invert flips this
- [ ] Sensor unavailable → the opening freezes at its static default (door open / window closed)
- [ ] A door with a lock shows the padlock badge: green locked / orange unlocked / grey unknown
- [ ] Clicking an opening (or the padlock) in view mode opens the info card with both states;
the lock can NOT be toggled from the plan
- [ ] Flip toggles mirror the hinge side and the swing side
- [ ] Click an existing opening with the tool → edit dialog; Delete removes it
- [ ] (v1.23.1) Hovering an opening in view mode shows the accent outline + grab cursor
- [ ] Dragging an opening slides it along walls (re-snapping, incl. around corners) and saves
on release; dragging far away from walls leaves it in place; hinge does not flip while
crossing wall-segment boundaries
- [ ] Single click still opens the status card; double click opens the properties dialog;
a drag does NOT open either
+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

+39 -13
View File
@@ -1,15 +1,16 @@
{
"name": "houseplan-card",
"version": "1.15.0",
"version": "1.41.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.15.0",
"version": "1.41.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3"
"lit": "^3.1.3",
"polyclip-ts": "^0.16.8"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
@@ -17,7 +18,7 @@
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"playwright": "^1.62.0",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
@@ -597,6 +598,15 @@
"node": ">=0.4.0"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -751,35 +761,35 @@
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
@@ -797,6 +807,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/polyclip-ts": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz",
"integrity": "sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.1.0",
"splaytree-ts": "^1.0.2"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -936,6 +956,12 @@
"source-map": "^0.6.0"
}
},
"node_modules/splaytree-ts": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz",
"integrity": "sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==",
"license": "BDS-3-Clause"
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.17.2",
"version": "1.42.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -16,12 +16,13 @@
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"playwright": "^1.62.0",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
},
"dependencies": {
"lit": "^3.1.3"
"lit": "^3.1.3",
"polyclip-ts": "^0.16.8"
}
}
+85 -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,69 @@ export function areaLights(hass: any, devices: { area: string; entities: string[
return seen ? 'off' : 'none';
}
/**
* Explicit room measurement source: 'entity:<eid>' reads the state as a
* number; 'device:<id>' aggregates over that device's entities (tempFor /
* humFor). Used by the room-settings override (tier 3).
*/
export function sourceValue(hass: any, src: string | null | undefined, kind: 'temp' | 'hum'): number | null {
if (!src) return null;
const i = src.indexOf(':');
if (i < 0) return null;
const k = src.slice(0, i);
const ref = src.slice(i + 1);
if (!ref) return null;
if (k === 'entity') {
const v = parseFloat(hass.states[ref]?.state);
if (!Number.isFinite(v)) return null;
return kind === 'temp' ? Math.round(v * 10) / 10 : Math.round(v);
}
if (k === 'device') {
const entIds = Object.entries(hass.entities as Record<string, any>)
.filter(([, r]) => (r as any).device_id === ref)
.map(([eid]) => eid);
return kind === 'temp' ? tempFor(hass, entIds) : humFor(hass, entIds);
}
return null;
}
/** Average humidity across the area's climate-ish devices (integer %, or null). */
export function areaHum(
hass: any,
devices: { area: string; icon?: string; entities: string[] }[],
area: string,
): number | null {
const vals: number[] = [];
for (const dv of devices) {
if (dv.area !== area) continue;
// same curation idea as areaTemp: climate sensors only, not fridges/plugs
if (dv.icon !== 'mdi:thermometer' && dv.icon !== 'mdi:air-filter' && dv.icon !== 'mdi:water-percent') continue;
const h = humFor(hass, dv.entities);
if (h != null) vals.push(h);
}
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
/** 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}
+2650 -244
View File
File diff suppressed because it is too large Load Diff
+151 -8
View File
@@ -22,18 +22,40 @@
"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",
"title.markup_add": "Add a room: connect grid dots with lines until the outline closes",
"title.markup_erase": "Erase a line: click the line",
"title.markup_merge": "Merge rooms: click one room, then the neighbour it shares a wall with",
"title.markup_split": "Split a room: click the room, then two points on its walls",
"title.markup_delroom": "Delete a room: click inside the room",
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
"title.choose_area": "Select a Home Assistant area",
"title.need_plan": "Upload a floor-plan image",
"markup.add": "Add",
"markup.erase": "Erase",
"markup.merge": "Merge",
"markup.split": "Split",
"markup.opening": "Opening",
"title.markup_opening": "Doors & windows: click a wall to place, click an opening to edit",
"opening.new": "New opening",
"opening.edit": "Door / window",
"opening.door": "Door",
"opening.window": "Window",
"opening.type_label": "Type",
"opening.length_label": "Length, cm",
"opening.contact_label": "Open/close sensor",
"opening.lock_label": "Lock",
"opening.none": "— none —",
"opening.invert": "Invert open/closed",
"opening.flip_h": "Hinge on the other jamb",
"opening.flip_v": "Opens to the other side",
"opening.open": "Open",
"opening.closed": "Closed",
"opening.locked": "Locked",
"opening.unlocked": "Unlocked",
"opening.state_unknown": "unavailable",
"opening.no_entities": "No sensors bound — a static symbol on the plan.",
"toast.opening_no_wall": "Click next to a room wall — openings sit on walls",
"markup.delete": "Delete",
"markup.hint_points": "points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one",
"markup.hint_start": "click a grid dot to start the outline",
@@ -58,6 +80,13 @@
"marker.room_auto": "— by device area (auto) —",
"marker.icon_label": "Icon",
"marker.icon_ph": "mdi:… (empty = auto)",
"marker.display_label": "Display",
"display.badge": "Icon badge",
"display.ripple": "Ripple only",
"display.icon_ripple": "Icon + ripple",
"marker.ripple_size": "Ripple size",
"marker.size_label": "Icon size / rotation",
"marker.angle_label": "Rotate",
"marker.model_label": "Model",
"marker.model_ph": "e.g. Aqara T&H",
"marker.link_label": "Link",
@@ -85,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?",
@@ -94,6 +122,16 @@
"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.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": "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",
"merge.no_area": "no area",
"room.split_header": "New room from the split",
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
@@ -138,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",
@@ -152,6 +188,8 @@
"import.progress": "Floor {i} of {n}",
"import.done": "Spaces created. Outline the rooms: click grid dots and close the contour.",
"btn.skip": "Skip",
"space.scale_label": "Scale (grid cell size)",
"space.scale_unit": "cm per cell",
"space.display_section": "Display",
"space.show_borders": "Always show room borders",
"space.show_names": "Show room names (drag to move)",
@@ -179,5 +217,110 @@
"editor.button_label": "Button label",
"editor.button_target": "Target dashboard path",
"editor.aspect_ratio": "Aspect ratio (e.g. 16:9 or auto)",
"marker.sub_entity": "entity"
"marker.sub_entity": "entity",
"title.general_settings": "General settings",
"gs.title": "General settings",
"gs.hint": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
"gs.light_group": "Fill: lights",
"gs.light_on": "Lights on",
"gs.light_off": "All lights off",
"gs.temp_group": "Fill: temperature",
"gs.temp_cold": "Cold",
"gs.temp_ok": "Comfortable",
"gs.temp_hot": "Hot",
"gs.lqi_group": "Fill: zigbee signal",
"gs.lqi_low": "Weak signal",
"gs.lqi_high": "Strong signal",
"gs.reset": "Reset to defaults",
"gs.saved": "General settings saved",
"space.show_lqi": "Show zigbee signal (LQI) next to devices",
"gs.light_none": "No light sources",
"mode.plan": "Plan editor",
"mode.devices": "Device editor",
"display.value": "Value instead of an icon",
"marker.subarea": "no area, manual",
"device.new": "New device — open its editor to dismiss",
"opening.unlock_action": "Unlock",
"opening.lock_action": "Lock",
"opening.lock_pending": "Working…",
"title.close_editor": "Close editor (back to view)",
"devbar.add": "Add",
"devbar.show_all": "Show all",
"devbar.rules": "Icon rules",
"space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature",
"space.label_hum": "Humidity",
"space.label_lqi": "Average Zigbee signal",
"space.label_light": "Lights on/off",
"roomcard.light_on": "On",
"roomcard.light_off": "Off",
"roomcard.light_partial": "{on} of {total}",
"toast.split_pick_inside": "Intermediate cut points must be inside the room",
"mode.decor": "Background editor",
"decor.select": "Select",
"decor.line": "Line",
"decor.rect": "Rectangle",
"decor.ellipse": "Oval",
"decor.text": "Text",
"decor.erase": "Erase",
"decor.color": "Color",
"decor.width": "Line width",
"decor.w_thin": "Thin",
"decor.w_mid": "Medium",
"decor.w_thick": "Thick",
"decor.fill": "Fill",
"decor.text_title": "Text label",
"decor.text_label": "Text",
"decor.text_size": "Size",
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
"mode.decor_tip": "Background editor — purely visual decor under the plan: lines, rectangles, ovals and text labels that never react to clicks",
"fill.glow": "Light sources (dark house, glowing lamps)",
"gs.glow_group": "Light-sources fill",
"gs.glow_base": "House darkness",
"gs.glow_light": "Default light color / intensity",
"gs.glow_radius": "Glow radius",
"gs.unit_m": "m",
"gs.unit_ft": "ft",
"marker.controls_label": "Controls light sources",
"marker.controls_hint": "With tap action “Toggle”, a click flips all bound lights at once (any on → all off). The icon mirrors their state.",
"marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_hint": "empty = default from general settings",
"markup.openwall": "Open boundary",
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
"toast.openwall_pick": "Click a wall shared by two rooms",
"toast.openwall_opened": "Boundary “{a}” ↔ “{b}” is now open",
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again",
"marker.from_ha_option": "Pick from the HA list",
"marker.show_entities": "Show entities",
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…",
"room.open_area": "Open the HA area",
"kiosk.title": "This screen's sizes",
"kiosk.hint": "Stored on this device only — every wall tablet or TV can have its own comfortable sizes.",
"kiosk.icon_scale": "Device icon size",
"kiosk.font_scale": "Room card text size",
"editor.kiosk": "Wall device (kiosk) mode",
"editor.cycle": "Auto-switch spaces every N seconds (kiosk, 0 = off)",
"room.settings_title": "Room settings",
"room.settings_section": "Room settings (override the space)",
"room.fill_label": "Fill in THIS room",
"fill.inherit": "As the space",
"room.temp_src_label": "Temperature source",
"room.hum_src_label": "Humidity source",
"room.src_average": "Average over the room's sensors (default)",
"room.src_pick": "A specific HA device or entity",
"room.src_ph": "Choose a source…",
"toast.room_updated": "Room updated",
"space.card_font": "Room-card font size (whole space)",
"room.sizes_section": "Font sizes",
"room.name_scale": "Room name size",
"room.label_scale": "Metrics size",
"preview.room_name": "Living room"
}
+152 -9
View File
@@ -22,18 +22,40 @@
"title.zoom_reset": "Сбросить масштаб",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показывать все устройства зоны (без курирования)",
"title.reset_layout": "Сбросить позиции значков к авто-раскладке",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
"title.markup_add": "Добавить комнату: соединяйте точки сетки линиями до замкнутого контура",
"title.markup_erase": "Стереть линию: клик по линии",
"title.markup_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить",
"markup.erase": "Стереть",
"markup.merge": "Объединить",
"markup.split": "Разделить",
"markup.opening": "Проём",
"title.markup_opening": "Двери и окна: клик по стене — добавить, клик по проёму — редактировать",
"opening.new": "Новый проём",
"opening.edit": "Дверь / окно",
"opening.door": "Дверь",
"opening.window": "Окно",
"opening.type_label": "Тип",
"opening.length_label": "Длина, см",
"opening.contact_label": "Датчик открытия",
"opening.lock_label": "Замок",
"opening.none": "— нет —",
"opening.invert": "Инвертировать открыто/закрыто",
"opening.flip_h": "Петли с другой стороны",
"opening.flip_v": "Открывается в другую сторону",
"opening.open": "Открыто",
"opening.closed": "Закрыто",
"opening.locked": "Заперто",
"opening.unlocked": "Не заперто",
"opening.state_unknown": "недоступно",
"opening.no_entities": "Датчики не привязаны — статичный символ на плане.",
"toast.opening_no_wall": "Кликните рядом со стеной комнаты — проёмы ставятся на стены",
"markup.delete": "Удалить",
"markup.hint_points": "точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой",
"markup.hint_start": "кликните точку сетки, чтобы начать контур",
@@ -58,6 +80,13 @@
"marker.room_auto": "— по зоне устройства (авто) —",
"marker.icon_label": "Иконка",
"marker.icon_ph": "mdi:… (пусто = авто)",
"marker.display_label": "Отображение",
"display.badge": "Значок",
"display.ripple": "Только пульсация",
"display.icon_ripple": "Значок + пульсация",
"marker.ripple_size": "Размер пульсации",
"marker.size_label": "Размер / поворот значка",
"marker.angle_label": "Поворот",
"marker.model_label": "Модель",
"marker.model_ph": "напр. Aqara T&H",
"marker.link_label": "Ссылка",
@@ -85,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}» со всеми комнатами и разметкой?",
@@ -94,6 +122,16 @@
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»",
"toast.split_pick_wall": "Начните разрез на стене комнаты",
"toast.split_bad_cut": "Разрез — от стены до стены внутри комнаты, без пересечения стен и самого себя",
"merge.header": "Объединение комнат",
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
"merge.keep": "Оставить",
"merge.no_area": "без зоны",
"room.split_header": "Новая комната после разделения",
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
@@ -138,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 уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
@@ -152,6 +188,8 @@
"import.progress": "Этаж {i} из {n}",
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"btn.skip": "Пропустить",
"space.scale_label": "Масштаб (размер клетки сетки)",
"space.scale_unit": "см на клетку",
"space.display_section": "Отображение",
"space.show_borders": "Всегда отображать границы комнат",
"space.show_names": "Отображать названия комнат (перетаскиваются)",
@@ -179,5 +217,110 @@
"editor.button_label": "Текст кнопки",
"editor.button_target": "Путь дашборда (куда вести)",
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
"marker.sub_entity": "сущность"
"marker.sub_entity": "сущность",
"title.general_settings": "Общие настройки",
"gs.title": "Общие настройки",
"gs.hint": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
"gs.light_group": "Заливка: освещение",
"gs.light_on": "Свет включён",
"gs.light_off": "Весь свет выключен",
"gs.temp_group": "Заливка: температура",
"gs.temp_cold": "Холодно",
"gs.temp_ok": "Комфорт",
"gs.temp_hot": "Жарко",
"gs.lqi_group": "Заливка: зигби-сигнал",
"gs.lqi_low": "Слабый сигнал",
"gs.lqi_high": "Сильный сигнал",
"gs.reset": "Сбросить к умолчаниям",
"gs.saved": "Общие настройки сохранены",
"space.show_lqi": "Показывать зигби-сигнал (LQI) у устройств",
"gs.light_none": "Нет источников света",
"mode.plan": "Редактор плана",
"mode.devices": "Редактор устройств",
"display.value": "Значение вместо иконки",
"marker.subarea": "без зоны, вручную",
"device.new": "Новое устройство — откройте его редактор, чтобы снять отметку",
"opening.unlock_action": "Открыть замок",
"opening.lock_action": "Закрыть замок",
"opening.lock_pending": "Выполняется…",
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить",
"devbar.show_all": "Показать все",
"devbar.rules": "Правила иконок",
"space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура",
"space.label_hum": "Влажность",
"space.label_lqi": "Средний Zigbee-сигнал",
"space.label_light": "Свет вкл/выкл",
"roomcard.light_on": "Вкл",
"roomcard.light_off": "Выкл",
"roomcard.light_partial": "{on} из {total}",
"toast.split_pick_inside": "Промежуточные точки разреза — внутри комнаты",
"mode.decor": "Редактор подложки",
"decor.select": "Выбрать",
"decor.line": "Линия",
"decor.rect": "Прямоугольник",
"decor.ellipse": "Овал",
"decor.text": "Надпись",
"decor.erase": "Стереть",
"decor.color": "Цвет",
"decor.width": "Толщина линии",
"decor.w_thin": "Тонкая",
"decor.w_mid": "Средняя",
"decor.w_thick": "Толстая",
"decor.fill": "Залить",
"decor.text_title": "Надпись",
"decor.text_label": "Текст",
"decor.text_size": "Размер",
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
"mode.decor_tip": "Редактор подложки — чисто визуальный декор под планом: линии, прямоугольники, овалы и надписи, не реагирующие на клики",
"fill.glow": "Свет по источникам (тёмный дом, пятна света)",
"gs.glow_group": "Заливка «Свет по источникам»",
"gs.glow_base": "Темнота дома",
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
"gs.glow_radius": "Радиус свечения",
"gs.unit_m": "м",
"gs.unit_ft": "фут",
"marker.controls_label": "Управляет источниками света",
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
"marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
"markup.openwall": "Открытая граница",
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
"toast.openwall_opened": "Граница «{a}» ↔ «{b}» теперь открыта",
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта",
"marker.from_ha_option": "Выбрать из списка HA",
"marker.show_entities": "Отображать сущности",
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…",
"room.open_area": "Открыть зону в HA",
"kiosk.title": "Размеры на этом экране",
"kiosk.hint": "Хранится только на этом устройстве — у каждого настенного планшета или ТВ свои удобные размеры.",
"kiosk.icon_scale": "Размер значков устройств",
"kiosk.font_scale": "Размер текста карточек комнат",
"editor.kiosk": "Режим настенного устройства (киоск)",
"editor.cycle": "Автосмена пространств каждые N секунд (киоск, 0 = выкл)",
"room.settings_title": "Настройки комнаты",
"room.settings_section": "Настройки комнаты (переопределяют пространство)",
"room.fill_label": "Заливка в ЭТОЙ комнате",
"fill.inherit": "Как у пространства",
"room.temp_src_label": "Источник температуры",
"room.hum_src_label": "Источник влажности",
"room.src_average": "Средняя по датчикам комнаты (по умолчанию)",
"room.src_pick": "Конкретное устройство или сущность HA",
"room.src_ph": "Выберите источник…",
"toast.room_updated": "Комната обновлена",
"space.card_font": "Размер шрифта карточек комнат (всё пространство)",
"room.sizes_section": "Размеры шрифтов",
"room.name_scale": "Размер названия",
"room.label_scale": "Размер подписей",
"preview.room_name": "Гостиная"
}
+899 -6
View File
@@ -1,6 +1,7 @@
/**
* Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
*/
import { union } from 'polyclip-ts';
/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
export function lqiColor(lqi: number): string {
@@ -13,10 +14,124 @@ export function snapToGrid(v: number, pitch: number): number {
return Math.round(v / pitch) * pitch;
}
/** Canonical key of a segment (independent of direction). */
export function segKey(a: number[], b: number[]): string {
/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
export function segmentCm(a: number[], b: number[], gridPitch: number, cellCm: number): number {
const cells = Math.hypot(b[0] - a[0], b[1] - a[1]) / gridPitch;
return cells * cellCm;
}
/** Format a length (cm) for display: metric metres ("1.25 m") or imperial feet+inches ("4′ 1″"). */
export function formatLength(cm: number, imperial: boolean): string {
if (imperial) {
const totalIn = cm / 2.54;
let ft = Math.floor(totalIn / 12);
let inch = Math.round(totalIn - ft * 12);
if (inch === 12) { ft += 1; inch = 0; }
return `${ft}′ ${inch}″`;
}
return `${(cm / 100).toFixed(2)} m`;
}
/**
* Canonical key of a segment (independent of direction).
* `prec` = decimals used to compare coordinates: the default 1 suits render units,
* normalized (0..1) coordinates need more (see roomEdges).
*/
export function segKey(a: number[], b: number[], prec = 1): string {
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
return `${p[0].toFixed(1)},${p[1].toFixed(1)}-${q[0].toFixed(1)},${q[1].toFixed(1)}`;
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
}
/**
* Wall segments derived from room outlines (normalized coordinates in and out).
*
* A line has no independent existence on the plan: it can only be an edge of a closed
* room. Shared walls are emitted once, which is what makes deleting a room keep the
* borders its neighbours still contribute — the neighbour's polygon still yields them.
*/
export function roomPoly(r: any): number[][] | null {
if (r?.poly?.length >= 3) return r.poly;
if (r && r.x != null && r.y != null && r.w != null && r.h != null)
return [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
return null;
}
export function roomEdges(rooms: any[]): number[][] {
const out: number[][] = [];
const seen = new Set<string>();
for (const r of rooms || []) {
const pts = roomPoly(r);
if (!pts) continue;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
const k = segKey(a, b, 5);
if (seen.has(k)) continue;
seen.add(k);
out.push([a[0], a[1], b[0], b[1]]);
}
}
return out;
}
/**
* Snap a point onto the nearest wall of any room, returning the snapped point and
* the wall's angle (degrees), or null when no wall is within maxDist. Walls are the
* DERIVED room edges (a line has no independent existence on the plan), so an opening
* placed with this stays valid however rooms are later edited — it keeps absolute
* coordinates and is not tied to a room id or edge index.
*/
export function snapToWall(
p: number[], rooms: any[], maxDist: number,
): { x: number; y: number; angle: number } | null {
let best: { x: number; y: number; angle: number } | null = null;
let bestD = maxDist;
for (const e of roomEdges(rooms)) {
const [x1, y1, x2, y2] = e;
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (!len2) continue;
let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const q = [x1 + t * dx, y1 + t * dy];
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
if (d < bestD) {
bestD = d;
// normalize to [-90, 90): two rooms sharing a wall yield the same edge in
// OPPOSITE directions — without this, dragging an opening across segment
// boundaries would flip its hinge side back and forth
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (angle >= 90) angle -= 180;
else if (angle < -90) angle += 180;
best = { x: q[0], y: q[1], angle };
}
}
return best;
}
/**
* How far open an opening is drawn, 0..1, from its contact sensor state.
* No sensor bound → doors default to open (the familiar swing symbol), windows to
* closed (intact glass) — same convention as a static architectural plan.
* `unavailable`/`unknown` freeze the default too: an outage must not fake motion.
*/
export function openingAmount(
type: 'door' | 'window', state: string | null | undefined, invert = false,
): number {
if (state == null || state === 'unavailable' || state === 'unknown')
return type === 'door' ? 1 : 0;
const open = isActiveState(state) !== !!invert;
return open ? 1 : 0;
}
/**
* Is an entity "active / detected"? Used by presence ripples, which are opted into per
* device and therefore must not depend on the card-wide live_states toggle.
* Anything unknown — including `unavailable` — counts as idle: a sensor outage should
* calm the plan down, never leave a ring pulsing forever.
*/
export function isActiveState(state?: string | null): boolean {
return ['on', 'open', 'home', 'detected', 'playing', 'cleaning'].includes(String(state));
}
/** Point equality within a tolerance. */
@@ -35,6 +150,243 @@ export function pointInPolygon(p: number[], poly: number[][]): boolean {
return inside;
}
/** Distance from p to segment ab. */
function distToSeg(p: number[], a: number[], b: number[]): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}
/**
* Is p on the outline itself (within eps)? This is the normal case, not an anomaly:
* neighbouring rooms share walls, so their vertices sit on each other's outlines —
* including mid-span, since real walls overlap collinearly rather than match exactly.
*/
/**
* Project a point onto the nearest edge of a polygon and return that point,
* or null when the polygon has no edges. Used to snap a Split click onto the
* actual wall (rooms may not be grid-aligned — imported polygons, older configs),
* so cutting no longer requires hitting a grid node exactly on the outline.
*/
export function closestPointOnBoundary(p: number[], poly: number[][]): number[] | null {
if (!poly || poly.length < 2) return null;
let best: number[] | null = null;
let bestD = Infinity;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const q = [a[0] + t * dx, a[1] + t * dy];
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
if (d < bestD) { bestD = d; best = q; }
}
return best;
}
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
if (!poly || poly.length < 2) return false;
for (let i = 0; i < poly.length; i++)
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return true;
return false;
}
/** Inside the outline AND not on it — a point on a shared wall is not "inside". */
export function pointStrictlyInside(p: number[], poly: number[][], eps = 1e-6): boolean {
if (!poly || poly.length < 3) return false;
if (pointOnBoundary(p, poly, eps)) return false;
return pointInPolygon(p, poly);
}
function cross3(a: number[], b: number[], c: number[]): number {
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}
/**
* Do two segments cross transversally? Touching at an endpoint and collinear overlap
* deliberately do NOT count — that is what sharing a wall looks like.
*/
export function segmentsProperlyCross(
p1: number[], p2: number[], p3: number[], p4: number[], eps = 1e-9,
): boolean {
const d1 = cross3(p3, p4, p1);
const d2 = cross3(p3, p4, p2);
const d3 = cross3(p1, p2, p3);
const d4 = cross3(p1, p2, p4);
return (
((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps))
);
}
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
function coversArea(a: number[][], b: number[][], eps: number): boolean {
let allOnBoundary = true;
for (const v of a) {
if (pointStrictlyInside(v, b, eps)) return true;
if (!pointOnBoundary(v, b, eps)) allOnBoundary = false;
}
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
if (allOnBoundary) {
const c = [
a.reduce((s, p) => s + p[0], 0) / a.length,
a.reduce((s, p) => s + p[1], 0) / a.length,
];
return pointStrictlyInside(c, b, eps);
}
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 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;
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
function closedRing(poly: number[][]): number[][][] {
return [[...poly.map((p) => [p[0], p[1]]), [poly[0][0], poly[0][1]]]];
}
/**
* Union of two room outlines, or null when they may not be merged.
*
* "Adjacent" is decided by the result rather than by a separate heuristic: only rooms that
* genuinely share a wall (fully or partially — real walls overlap collinearly rather than
* match exactly) collapse into ONE hole-free outline. Rooms that merely touch at a corner,
* that are apart, or whose union would enclose a hole do not, and are refused.
*/
export function mergeRooms(a: number[][], b: number[][]): number[][] | null {
if (!a || !b || a.length < 3 || b.length < 3) return null;
const res = union(closedRing(a) as any, closedRing(b) as any);
if (res.length !== 1) return null; // two pieces → not adjacent
if (res[0].length !== 1) return null; // a ring plus holes → not a simple room
const pts = res[0][0].slice(0, -1).map((p: number[]) => [p[0], p[1]]); // drop the closing point
return pts.length >= 3 ? pts : null;
}
/** Index of the outline edge that p sits on, or -1. */
function edgeIndexOf(poly: number[][], p: number[], eps: number): number {
for (let i = 0; i < poly.length; i++)
if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return i;
return -1;
}
function dropRepeats(pts: number[][], eps: number): number[][] {
const out: number[][] = [];
for (const p of pts) if (!out.length || !samePoint(out[out.length - 1], p, eps)) out.push(p);
if (out.length > 1 && samePoint(out[0], out[out.length - 1], eps)) out.pop();
return out;
}
/**
* Cut a room in two with a straight chord between two points on its walls.
* Returns the two parts, or null when the cut is not a clean wall-to-wall chord:
* an end that is not on a wall, a chord that leaves the room (concave outlines) or that
* runs along a wall and would carve off a zero-area sliver.
*/
export function splitRoom(
poly: number[][], a: number[], b: number[], eps = 1e-6,
): [number[][], number[][]] | 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
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 acc: number[][] = [from];
let i = (fromIdx + 1) % poly.length;
for (let guard = 0; guard <= poly.length; guard++) {
acc.push(poly[i]);
if (i === toIdx) break;
i = (i + 1) % poly.length;
}
acc.push(to);
return dropRepeats(acc, eps);
};
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];
}
/**
* Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
* virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
@@ -140,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';
@@ -183,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 {
@@ -194,6 +550,15 @@ export interface SpaceDisplay {
fill: RoomFillMode;
tempMin: number; // comfort range lower bound, °C
tempMax: number; // comfort range upper bound, °C
/** Per-space LQI badges near zigbee devices; null = follow the card option. */
showLqi: boolean | null;
/** Base font multiplier for room cards (tier 2; rooms multiply on top). */
cardFontScale: number;
/** Room-card metrics under the room name (all default off). */
labelTemp: boolean;
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -210,12 +575,122 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
showNames: s.show_names ?? noPlan,
color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
fill: ['lqi', 'light', 'temp'].includes(s.fill_mode) ? s.fill_mode : 'none',
fill: ['lqi', 'light', 'temp', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
cardFontScale: typeof s.card_font_scale === 'number' && s.card_font_scale > 0
? Math.min(3, Math.max(0.5, s.card_font_scale))
: 1,
labelTemp: s.label_temp === true,
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
};
}
// ---------------- global fill colors ----------------
export interface FillColorEntry {
c: string; // #rrggbb
a: number; // 0..1 fill opacity
}
/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
export interface FillColors {
light_on: FillColorEntry;
light_off: FillColorEntry;
/** Rooms with no light sources at all; alpha 0 (default) = no fill, as before. */
light_none: FillColorEntry;
temp_cold: FillColorEntry;
temp_ok: FillColorEntry;
temp_hot: FillColorEntry;
lqi_low: FillColorEntry;
lqi_high: FillColorEntry;
/** Glow mode: uniform "darkness" over every room + default light color. */
glow_base: FillColorEntry;
glow_light: FillColorEntry;
}
export const DEFAULT_FILL_COLORS: FillColors = {
light_on: { c: '#ffd45c', a: 0.18 },
light_off: { c: '#9aa0a6', a: 0.14 },
light_none: { c: '#6b7480', a: 0 },
temp_cold: { c: '#4fc3f7', a: 0.18 },
temp_ok: { c: '#66d17a', a: 0.18 },
temp_hot: { c: '#ffd45c', a: 0.18 },
lqi_low: { c: '#f25a4a', a: 0.18 },
lqi_high: { c: '#4bd28f', a: 0.18 },
glow_base: { c: '#0d1b2a', a: 0.5 },
glow_light: { c: '#ffd9a0', a: 0.85 },
};
const HEX_RE = /^#[0-9a-f]{6}$/i;
/** Merge stored overrides over the defaults, dropping malformed entries. */
export function fillColorsOf(settings: any): FillColors {
const out: any = {};
const src = settings?.fill_colors || {};
for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
const d = DEFAULT_FILL_COLORS[k];
const v = src[k];
out[k] = {
c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
};
}
return out as FillColors;
}
/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
export function lerpColor(a: string, b: string, t: number): string {
const tt = Math.min(1, Math.max(0, t));
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
}
/**
* Room fill (color + opacity) for the selected mode, or null for "no fill",
* using the global palette. The LQI gradient interpolates lqi_low → lqi_high
* over the 40..180 LQI window (same thresholds as the badge color).
*/
export function roomFillStyle(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp: number | null | undefined,
tempMin: number,
tempMax: number,
colors: FillColors,
): FillColorEntry | null {
if (mode === 'lqi') {
if (lqi == null) return null;
const t = (lqi - 40) / 140;
return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
}
if (mode === 'light') {
if (lights === 'none') {
// configurable "no light sources" color; alpha 0 keeps the historical
// no-fill behavior (and the unfilled hover), so nothing changes until
// the user assigns an opacity
return colors.light_none.a > 0 ? colors.light_none : null;
}
return lights === 'on' ? colors.light_on : colors.light_off;
}
if (mode === 'temp') {
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return colors.temp_cold;
if (temp > hi) return colors.temp_hot;
return colors.temp_ok;
}
return null;
}
/**
* Room fill color for the selected mode, or null for "no fill".
* - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
@@ -248,3 +723,421 @@ 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);
}
// ---------------- room-level settings (tier 3) ----------------
/**
* Effective fill mode of a room: its own override wins, otherwise the space's.
* Four settings tiers (owner's principle, 2026-07-26): global > space > room >
* device; the more specific tier overrides the more general one. A room may
* override even in a glow space ('none' pulls it out of the darkness).
*/
export function roomFillModeOf(
spaceFill: RoomFillMode,
room: { settings?: { fill_mode?: string | null } | null } | null | undefined,
): RoomFillMode {
const o = room?.settings?.fill_mode;
return o === 'none' || o === 'lqi' || o === 'light' || o === 'temp' ? o : spaceFill;
}
// ---------------- marker files ----------------
/**
* Rewrite attached-file urls when a marker's id changes (rebinding): the
* server moves /files/<oldId>/ to /files/<newId>/, the urls must follow.
*/
export function migratePdfUrls<T extends { url: string }>(
pdfs: T[], oldId: string, newId: string,
): T[] {
if (!oldId || !newId || oldId === newId) return pdfs;
const from = '/files/' + oldId + '/';
const to = '/files/' + newId + '/';
return pdfs.map((p) => (p.url.includes(from) ? { ...p, url: p.url.split(from).join(to) } : p));
}
// ---------------- 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' },
+4 -3
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillColor } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
@@ -71,18 +71,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
// fill rendered exactly as configured on the full card (snapshot of current states)
const fillC = r.area
? roomFillColor(
? roomFillStyle(
disp.fill,
disp.fill === 'lqi' ? areaLqi(o.hass, devs, r.area) : null,
disp.fill === 'light' ? areaLights(o.hass, devs, r.area) : 'none',
disp.fill === 'temp' ? areaTemp(o.hass, devs, r.area) : null,
disp.tempMin,
disp.tempMax,
fillColorsOf(o.cfg?.settings),
)
: null;
if (fillC) {
cls += ' filled';
parts.push(`--room-fill:${fillC}`, `--room-fill-op:${(0.3 * disp.opacity).toFixed(3)}`);
parts.push(`--room-fill:${fillC.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else {
parts.push('--room-fill:transparent', '--room-fill-op:0');
}
+576 -17
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;
@@ -134,6 +136,9 @@ export const cardStyles = css`
opacity: 0.5;
pointer-events: none;
}
.stage.noplan {
background: #ffffff;
}
.stage {
position: relative;
width: 100%;
@@ -184,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;
@@ -222,20 +227,412 @@ export const cardStyles = css`
fill-opacity: 0.22;
stroke-opacity: 1;
}
.roomlabel {
/* doors & windows */
.op-leaf {
transition: transform 0.6s ease;
}
.op-arc {
stroke-width: 1.5;
transition: stroke-dashoffset 0.6s ease;
}
/* hover affordance: a rounded outline hugging the wall strip + a grab cursor */
.op-outline {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.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;
pointer-events: none;
cursor: default;
}
.stage.markup .op-hit {
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%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
width: calc(var(--icon-size, 2.5cqw) * 0.62);
height: calc(var(--icon-size, 2.5cqw) * 0.62);
border-radius: 50%;
display: flex;
align-items: center;
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;
}
.oplock ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.4);
display: flex;
line-height: 0;
}
.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;
gap: 8px;
padding: 6px 0;
}
.oprow b { margin-left: auto; }
.oprow.ok b { color: #66d17a; }
.oprow.warn b { color: var(--hp-open); }
@media (prefers-reduced-motion: reduce) {
.op-leaf, .op-arc { transition: none; }
}
/* presence ripples: opted into per device, drawn around the anchor point */
.dev.noicon {
background: transparent;
border-color: transparent;
box-shadow: none;
}
.dev ha-icon {
position: relative;
z-index: 1;
}
.ripple {
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--dev-size) * var(--ripple-scale, 3));
height: calc(var(--dev-size) * var(--ripple-scale, 3));
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 0;
}
.ripple i {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--ripple-color, var(--hp-accent));
opacity: 0;
}
.ripple.active i {
animation: hp-ripple 2.4s ease-out infinite;
}
.ripple.active i:nth-child(2) { animation-delay: 0.8s; }
.ripple.active i:nth-child(3) { animation-delay: 1.6s; }
/* idle: a faint dot keeps the spot marked without pulling the eye */
.ripple:not(.active) i:nth-child(n + 2) { display: none; }
.ripple:not(.active) i {
inset: calc(50% - 0.15 * var(--dev-size));
opacity: 0.3;
animation: none;
}
@keyframes hp-ripple {
0% { transform: scale(0.18); opacity: 0.7; }
70% { opacity: 0.22; }
100% { transform: scale(1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ripple.active i { animation: none; opacity: 0.3; }
.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 * var(--rl-scale, 1) * var(--rl-font, 1) * var(--rl-space, 1));
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: grab;
pointer-events: auto;
user-select: none;
text-shadow: 0 0 4px var(--card-background-color, rgba(0, 0, 0, 0.6));
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;
font-size: calc(1em * var(--rl-name, 1));
}
.rlgear {
--mdc-icon-size: 0.9em;
display: inline-flex;
margin-right: 0.2em;
opacity: 0.6;
cursor: pointer;
pointer-events: auto;
}
.rlgear:hover { opacity: 1; }
.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: calc(0.62em * var(--rl-meta, 1));
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; }
.cardpreview {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin: 8px 0 2px;
padding: 10px;
border: 1px dashed var(--hp-muted);
border-radius: 8px;
}
.cardpreview .cpname { font-weight: 700; letter-spacing: 0.04em; }
.cardpreview .cpmeta {
display: inline-flex;
align-items: center;
gap: 0.3em;
font-weight: 600;
opacity: 0.85;
}
.cardpreview .cpmeta ha-icon { --mdc-icon-size: 1.05em; }
.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%);
font-size: 12px;
font-weight: 600;
padding: 1px 6px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
white-space: nowrap;
pointer-events: none;
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;
@@ -249,16 +646,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 */
}
/* 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;
@@ -305,6 +809,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;
@@ -312,10 +863,12 @@ export const cardStyles = css`
}
.dev {
position: absolute;
/* per-device multiplier on top of the card-wide icon size */
--dev-size: calc(var(--icon-size, 2.5cqw) * var(--dev-scale, 1));
/* центр квадрата (включая рамку 1px) точно на точке привязки: -(size/2 + border) */
width: var(--icon-size, 2.5cqw);
height: var(--icon-size, 2.5cqw);
margin: calc(var(--icon-size, 2.5cqw) / -2 - 1px) 0 0 calc(var(--icon-size, 2.5cqw) / -2 - 1px);
width: var(--dev-size);
height: var(--dev-size);
margin: calc(var(--dev-size) / -2 - 1px) 0 0 calc(var(--dev-size) / -2 - 1px);
border-radius: 22%;
background: var(--hp-bg);
border: 1px solid var(--hp-line);
@@ -323,7 +876,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);
@@ -336,9 +889,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);
@@ -376,7 +928,7 @@ export const cardStyles = css`
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: var(--hp-txt);
@@ -393,7 +945,7 @@ export const cardStyles = css`
border: 1px solid #4fc3f7;
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
font-size: calc(var(--icon-size, 2.5cqw) * 0.45);
font-size: calc(var(--dev-size, var(--icon-size, 2.5cqw)) * 0.45);
font-weight: 700;
line-height: calc(var(--icon-size, 2.5cqw) * 0.68);
color: var(--hp-txt);
@@ -424,9 +976,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;
@@ -478,6 +1031,7 @@ export const cardStyles = css`
}
/* beat the generic .dialog .body .namein { width:100% } rule */
.dialog .body .temprange .tempin { width: 56px; flex: none; padding: 3px 6px; }
.dialog .body .colorrow .tempin { width: 72px; flex: none; }
.srcrow { flex-wrap: nowrap; }
.srcrow > span:first-of-type { white-space: nowrap; }
.colorrow .opl { color: var(--hp-muted); font-size: 12px; }
@@ -730,6 +1284,11 @@ export const cardStyles = css`
.rrow .ract:hover { color: var(--hp-txt); }
.rrow .ract.del:hover { color: #ff7a5c; }
.gsrow .gsl {
min-width: 150px;
font-size: 12.5px;
color: var(--hp-muted);
}
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
+43 -1
View File
@@ -1,6 +1,19 @@
/** 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;
/** Room-level settings (tier 3 of 4: global > space > ROOM > device). */
settings?: {
/** Fill override; unset = inherit the space fill mode. */
fill_mode?: 'none' | 'lqi' | 'light' | 'temp' | null;
/** 'device:<id>' or 'entity:<eid>'; unset = average over the room sensors. */
temp_source?: string | null;
hum_source?: string | null;
/** Font multipliers for THIS room's card (0.5-3, unset = 1). */
name_scale?: number | null;
label_scale?: number | null;
} | null;
id?: string;
name: string;
area: string | null;
@@ -38,6 +51,31 @@ export interface Marker {
description?: string | null;
pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
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. */
export interface OpeningCfg {
id: string;
type: 'door' | 'window';
x: number; // center, normalized by plan width
y: number; // center, normalized by plan height
angle: number; // wall angle, degrees
length: number; // along the wall, normalized by plan width
contact?: string | null; // binary_sensor / cover driving open-closed
lock?: string | null; // lock entity (doors only)
invert?: boolean;
flip_h?: boolean; // hinge on the other jamb
flip_v?: boolean; // opens to the other side of the wall
}
export interface ServerConfig {
@@ -81,5 +119,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;
}
+81 -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, sourceValue } 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,82 @@ 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');
});
test('sourceValue: explicit entity and device sources (tier 3)', () => {
const hass = {
states: {
'sensor.custom_temp': { state: '23.456' },
'sensor.custom_hum': { state: '47.8' },
'sensor.bad': { state: 'unknown' },
'sensor.dev_t': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
},
entities: {
'sensor.dev_t': { device_id: 'dev1' },
'sensor.custom_temp': {}, 'sensor.custom_hum': {}, 'sensor.bad': {},
},
};
assert.equal(sourceValue(hass, 'entity:sensor.custom_temp', 'temp'), 23.5);
assert.equal(sourceValue(hass, 'entity:sensor.custom_hum', 'hum'), 48);
assert.equal(sourceValue(hass, 'entity:sensor.bad', 'temp'), null);
assert.equal(sourceValue(hass, 'device:dev1', 'temp'), 21.5);
assert.equal(sourceValue(hass, 'device:nope', 'temp'), null);
assert.equal(sourceValue(hass, '', 'temp'), null);
assert.equal(sourceValue(hass, 'garbage', 'temp'), null);
});
+553 -2
View File
@@ -3,6 +3,17 @@ 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,
migratePdfUrls,
roomFillModeOf,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
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,
@@ -168,8 +179,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');
});
@@ -258,3 +270,542 @@ test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () =>
assert.equal(o.tempMin, 18.5);
assert.equal(o.tempMax, 23);
});
test('segmentCm: cells scaled by cm-per-cell', () => {
assert.equal(segmentCm([0, 0], [30, 40], 10, 5), 25); // 50 units / pitch 10 = 5 cells * 5cm
assert.ok(Math.abs(segmentCm([0, 0], [240, 0], 1000 / 240, 5) - 288) < 1e-9);
});
test('formatLength: metric metres with 2 decimals', () => {
assert.equal(formatLength(25, false), '0.25 m');
assert.equal(formatLength(125, false), '1.25 m');
assert.equal(formatLength(0, false), '0.00 m');
});
test('formatLength: imperial feet + inches, with inch rollover', () => {
assert.equal(formatLength(124.46, true), '4′ 1″');
assert.equal(formatLength(30.48, true), '1′ 0″');
assert.equal(formatLength(29.464, true), '1′ 0″');
});
test('roomEdges: a line exists only as a room edge; polygons and rects both yield walls', () => {
const sq = { poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
assert.equal(roomEdges([sq]).length, 4); // closed outline → 4 walls
assert.equal(roomEdges([{ x: 0, y: 0, w: 1, h: 1 }]).length, 4); // legacy rect room
assert.equal(roomEdges([]).length, 0); // no rooms → no lines at all
assert.equal(roomEdges([{ poly: [[0, 0], [1, 1]] }]).length, 0); // not a closed room → nothing
});
test('roomEdges: a wall shared by two rooms is emitted once, and survives deleting either room', () => {
const left = { id: 'a', poly: [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] };
const right = { id: 'b', poly: [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] }; // shares x=0.5 wall
const both = roomEdges([left, right]);
assert.equal(both.length, 7); // 4 + 4 - 1 shared, deduped regardless of direction
const shared = (segs) => segs.some((s) => s[0] === 0.5 && s[2] === 0.5);
assert.ok(shared(both));
// deleting 'left' → the shared wall stays, because 'right' still contributes it
assert.ok(shared(roomEdges([right])));
// deleting both → no lines remain
assert.equal(roomEdges([]).length, 0);
});
const SQ = [[0, 0], [2, 0], [2, 2], [0, 2]];
test('pointStrictlyInside: a point on a wall is NOT inside (shared walls are normal)', () => {
assert.ok(pointStrictlyInside([1, 1], SQ)); // middle
assert.ok(!pointStrictlyInside([2, 1], SQ)); // on a wall mid-span (T-junction vertex)
assert.ok(!pointStrictlyInside([0, 0], SQ)); // on a corner
assert.ok(!pointStrictlyInside([3, 1], SQ)); // outside
assert.ok(pointOnBoundary([2, 1], SQ));
assert.ok(!pointOnBoundary([1, 1], SQ));
});
test('roomsOverlap: sharing a wall or a corner is legal; real overlap is not', () => {
const right = [[2, 0], [4, 0], [4, 2], [2, 2]]; // shares the whole x=2 wall
assert.ok(!roomsOverlap(SQ, right));
// neighbour's wall is LONGER than ours — the real dacha case (collinear partial overlap)
const tall = [[2, -1], [4, -1], [4, 3], [2, 3]];
assert.ok(!roomsOverlap(SQ, tall));
// touching only at a corner
assert.ok(!roomsOverlap(SQ, [[2, 2], [3, 2], [3, 3], [2, 3]]));
// apart
assert.ok(!roomsOverlap(SQ, [[5, 5], [6, 5], [6, 6], [5, 6]]));
// genuine partial overlap
assert.ok(roomsOverlap(SQ, [[1, 1], [3, 1], [3, 3], [1, 3]]));
});
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', () => {
assert.equal(roomPoly({ poly: SQ }), SQ);
assert.deepEqual(roomPoly({ x: 0, y: 0, w: 2, h: 2 }), SQ);
assert.equal(roomPoly({}), null);
});
test('polygonArea: shoelace, orientation-independent', () => {
assert.equal(polygonArea([[0, 0], [2, 0], [2, 2], [0, 2]]), 4);
assert.equal(polygonArea([[0, 0], [0, 2], [2, 2], [2, 0]]), 4); // reversed winding
assert.equal(polygonArea([[0, 0], [1, 1]]), 0);
});
test('mergeRooms: only rooms sharing a wall merge; the union is one simple outline', () => {
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
const full = mergeRooms(a, [[2, 0], [4, 0], [4, 2], [2, 2]]); // whole wall shared
assert.equal(polygonArea(full), 8);
assert.equal(full.length, 4); // collapses to one rectangle
// the neighbour's wall is LONGER than ours — the real dacha case
const partial = mergeRooms(a, [[2, -1], [4, -1], [4, 3], [2, 3]]);
assert.equal(polygonArea(partial), 4 + 8);
assert.ok(partial.length >= 6); // an L/T-shaped outline
});
test('mergeRooms: refuses a corner touch, rooms apart, and a union with a hole', () => {
const a = [[0, 0], [2, 0], [2, 2], [0, 2]];
assert.equal(mergeRooms(a, [[2, 2], [4, 2], [4, 4], [2, 4]]), null); // corner only
assert.equal(mergeRooms(a, [[5, 5], [6, 5], [6, 6], [5, 6]]), null); // apart
const u = [[0, 0], [6, 0], [6, 2], [4, 2], [4, 6], [6, 6], [6, 8], [0, 8]];
assert.equal(mergeRooms(u, [[6, 2], [8, 2], [8, 6], [6, 6]]), null); // would enclose a hole
});
test('splitRoom: a wall-to-wall chord cuts the room in two, areas are preserved', () => {
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
const parts = splitRoom(sq, [0, 2], [4, 2]); // straight across
assert.ok(parts);
assert.equal(polygonArea(parts[0]) + polygonArea(parts[1]), polygonArea(sq));
assert.equal(polygonArea(parts[0]), 8);
assert.equal(polygonArea(parts[1]), 8);
// an off-centre cut → a bigger and a smaller part (the bigger one keeps the room)
const off = splitRoom(sq, [0, 1], [4, 1]);
const areas = [polygonArea(off[0]), polygonArea(off[1])].sort((x, y) => x - y);
assert.deepEqual(areas, [4, 12]);
});
test('splitRoom: refuses cuts that are not clean wall-to-wall chords', () => {
const sq = [[0, 0], [4, 0], [4, 4], [0, 4]];
assert.equal(splitRoom(sq, [1, 1], [3, 3]), null); // ends not on a wall
assert.equal(splitRoom(sq, [0, 2], [0, 2]), null); // same point
assert.equal(splitRoom(sq, [0, 0], [4, 0]), null); // along a wall → zero-area sliver
// an L-shaped room: a chord that would leave the room is refused
const L = [[0, 0], [4, 0], [4, 2], [2, 2], [2, 4], [0, 4]];
assert.equal(splitRoom(L, [4, 1], [1, 4]), null);
});
test('closestPointOnBoundary: projects a click onto the nearest wall', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
assert.deepEqual(closestPointOnBoundary([5, -3], sq), [5, 0]); // above the bottom edge
assert.deepEqual(closestPointOnBoundary([13, 5], sq), [10, 5]); // right of the right edge
assert.deepEqual(closestPointOnBoundary([5, 4], sq), [5, 0]); // inside → nearest edge (bottom)
assert.equal(closestPointOnBoundary([0, 0], [[0, 0]]), null); // no edges
});
test('isActiveState: a sensor outage calms the plan down, it never pulses forever', () => {
assert.ok(isActiveState('on'));
assert.ok(isActiveState('open'));
assert.ok(isActiveState('home'));
assert.ok(isActiveState('detected'));
assert.ok(!isActiveState('off'));
assert.ok(!isActiveState('unavailable'));
assert.ok(!isActiveState('unknown'));
assert.ok(!isActiveState(undefined));
assert.ok(!isActiveState(null));
});
test('snapToWall: projects onto the nearest derived wall with its angle; misses return null', () => {
const rooms = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
const s = snapToWall([4, 0.6], rooms, 1); // near the top wall
assert.deepEqual([s.x, s.y], [4, 0]);
assert.equal(Math.abs(s.angle) % 180, 0); // horizontal wall
const v = snapToWall([10.4, 7], rooms, 1); // near the right wall
assert.deepEqual([v.x, v.y], [10, 7]);
assert.equal(Math.abs(v.angle), 90);
assert.equal(snapToWall([5, 5], rooms, 1), null); // middle of the room: no wall within reach
});
test('openingAmount: doors default open, windows closed; outages freeze the default', () => {
assert.equal(openingAmount('door', null), 1);
assert.equal(openingAmount('window', null), 0);
assert.equal(openingAmount('door', 'unavailable'), 1);
assert.equal(openingAmount('window', 'unknown'), 0);
assert.equal(openingAmount('door', 'on'), 1);
assert.equal(openingAmount('door', 'off'), 0);
assert.equal(openingAmount('window', 'open'), 1);
// invert flips on/off but never the outage default
assert.equal(openingAmount('door', 'on', true), 0);
assert.equal(openingAmount('door', 'off', true), 1);
assert.equal(openingAmount('door', 'unavailable', true), 1);
});
test('snapToWall: the angle is normalized to [-90, 90) so opposite edge directions cannot flip a dragged opening', () => {
// the same wall as seen from two neighbouring rooms (opposite winding)
const a = [{ poly: [[0, 0], [10, 0], [10, 10], [0, 10]] }];
const b = [{ poly: [[10, 0], [0, 0], [0, 10], [10, 10]] }]; // reversed
const sa = snapToWall([5, 0.4], a, 1);
const sb = snapToWall([5, 0.4], b, 1);
assert.equal(sa.angle, sb.angle);
assert.ok(sa.angle >= -90 && sa.angle < 90);
const v = snapToWall([10.3, 5], a, 1); // vertical wall
assert.ok(v.angle >= -90 && v.angle < 90);
});
test('fillColorsOf: defaults, merge, malformed entries dropped', () => {
const d = fillColorsOf({});
assert.equal(d.light_on.c, '#ffd45c');
const o = fillColorsOf({ fill_colors: { light_on: { c: '#ff0000', a: 0.5 }, temp_hot: { c: 'javascript:x', a: 9 } } });
assert.equal(o.light_on.c, '#ff0000');
assert.equal(o.light_on.a, 0.5);
assert.equal(o.temp_hot.c, '#ffd45c'); // malformed hex → default
assert.equal(o.temp_hot.a, 1); // clamped
});
test('lerpColor: endpoints and midpoint', () => {
assert.equal(lerpColor('#000000', '#ffffff', 0), '#000000');
assert.equal(lerpColor('#000000', '#ffffff', 1), '#ffffff');
assert.equal(lerpColor('#000000', '#ffffff', 0.5), '#808080');
assert.equal(lerpColor('#000000', '#ffffff', -5), '#000000'); // clamped
});
test('roomFillStyle: palette-driven fills, lqi gradient between custom endpoints', () => {
const colors = fillColorsOf({ fill_colors: { lqi_low: { c: '#000000', a: 0.1 }, lqi_high: { c: '#ffffff', a: 0.3 } } });
assert.equal(roomFillStyle('lqi', 110, 'none', null, 20, 25, colors).c, '#808080'); // mid of 40..180
assert.equal(roomFillStyle('lqi', null, 'none', null, 20, 25, colors), null);
assert.deepEqual(roomFillStyle('light', null, 'on', null, 20, 25, colors), colors.light_on);
assert.deepEqual(roomFillStyle('temp', 18, 'none', 18, 20, 25, colors), colors.temp_cold);
assert.equal(roomFillStyle('none', 100, 'on', 22, 20, 25, colors), null);
});
test('spaceDisplayOf: show_lqi tri-state (null = follow the card option)', () => {
assert.equal(spaceDisplayOf({}).showLqi, null);
assert.equal(spaceDisplayOf({ settings: { show_lqi: false } }).showLqi, false);
assert.equal(spaceDisplayOf({ settings: { show_lqi: true } }).showLqi, true);
});
test('roomFillStyle light_none: alpha 0 keeps no-fill; a custom color fills lightless rooms', () => {
const def = fillColorsOf({});
assert.equal(roomFillStyle('light', null, 'none', null, 20, 25, def), null); // default: unchanged
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);
});
test('migratePdfUrls: rebinding rewrites file urls', () => {
const pdfs = [
{ name: 'a.pdf', url: '/houseplan_files/files/v_old1/a.pdf?v=1' },
{ name: 'b.pdf', url: '/houseplan_files/files/other/b.pdf?v=2' },
];
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99');
assert.equal(out[0].url, '/houseplan_files/files/dev99/a.pdf?v=1');
assert.equal(out[1].url, pdfs[1].url); // чужие пути не трогаем
// без смены id — как есть
assert.equal(migratePdfUrls(pdfs, 'x', 'x'), pdfs);
});
test('roomFillModeOf: tier-3 override beats the space, junk inherits', () => {
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'light' } }), 'light');
assert.equal(roomFillModeOf('glow', { settings: { fill_mode: 'none' } }), 'none'); // выход из тьмы
assert.equal(roomFillModeOf('temp', {}), 'temp');
assert.equal(roomFillModeOf('temp', null), 'temp');
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
});