Compare commits

...
48 Commits
Author SHA1 Message Date
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
Matysh 6cbf4ece4e fix v1.17.2: gate the humidity badge on device_class:humidity (primary entity), not the water-percent icon — so a humidity sensor whose name forces another icon (e.g. Myheat) still shows its %. +1 test.
Validate / hacs (push) Has been cancelled
Validate / hassfest (push) Has been cancelled
Validate / frontend (push) Has been cancelled
Validate / backend (push) Has been cancelled
2026-07-11 15:06:51 +03:00
Matysh 6609bfadd5 feat v1.17.1: show humidity value (%) next to the icon like temperature — isHumEntity/humFor, DevItem.hum, .hval badge + tooltip, for humidity devices and standalone humidity entities. +2 tests. 2026-07-11 15:02:33 +03:00
Matysh 498b852e86 feat v1.17.0 (issue #1): place individual entities as their own icons (not just whole devices) — binding picker surfaces entities while searching; entity markers get an auto icon (name/device_class) + temperature. +1 test. 2026-07-11 14:53:40 +03:00
Matysh fabeab81aa docs+log v1.16.2 (issue #2): document correct card resource URL /houseplan_files/houseplan-card.js and warn against the /custom_components/... on-disk path (text/plain → 'Custom element doesn't exist'); integration logs the served URL + YAML-mode manual-add hint. README en+ru troubleshooting note. 2026-07-11 14:46:28 +03:00
Matysh 246de26122 fix v1.16.1: houseplan-space-card renders room fills exactly as configured on the full card (temp/signal/lights snapshot from hass); +shared areaLqi(). Still non-interactive, no state subscription. 2026-07-08 19:46:19 +03:00
Matysh 1847c7b8ed feat v1.16.0: new read-only houseplan-space-card (static single-space schematic, pointer-events:none, deep-link button) + #space=<id> deep-link in the full card. Shared space-geometry/space-render + module-level config-store cache. +6 unit tests, smoke_space_card & smoke_deeplink. Docs in same commit. 2026-07-08 19:38:31 +03:00
Matysh fbd3887e14 feat v1.15.6: room hover also reveals the border — keep stroke colour, hide via --room-stroke-op, so hover's stroke-opacity:1 shows the outline even when borders are off 2026-07-08 18:13:23 +03:00
Matysh 5f9cfb9e10 fix v1.15.5: room hover darkens the fill instead of always grey — scope legacy overlay/yard hover with :not(.styled) so filled rooms use brightness(0.78), unfilled stay grey 2026-07-08 17:56:48 +03:00
Matysh cc27a6b2fa fix v1.15.4: device icon glyph centering — real ha-icon is block+line-height (glyph sat ~1.8px low); .dev ha-icon now zero-line-height flex. Revert v1.15.3 border-box (shrank badge); anchor centered via margin. Demo stub made faithful; smoke guards glyph-in-badge. Verified live. 2026-07-08 17:44:41 +03:00
Matysh 927e7d7054 fix v1.15.3: center device icon badge on its anchor (box-sizing: border-box; 1px border no longer shifts it down-right). +demo/smoke_icon_center. Docs in same commit. 2026-07-08 17:05:51 +03:00
Matysh dc8cf3070d fix v1.15.2: average room temperature counts only thermometer/air-monitor devices; isTempEntity excludes chip (*_device_temperature) and diagnostic-category temps (+3 tests). Docs: CHANGELOG/STATUS/TESTING in same commit. 2026-07-08 16:42:16 +03:00
Matysh 3e8b1c3778 Merge ux-fixes: display-settings UX round v1.15.1 2026-07-08 14:17:51 +03:00
Matysh 72b2492554 fix+ux v1.15.1: display-settings round from live usage
- comfort bounds: parseFloat+isFinite guard (empty input collapsed a bound to 0
  and the auto-swap made 'comfort from 25' behave as 0-25 → green at 24°)
- avg room temperature in the room tooltip (what the temp fill actually uses)
- hover darkens the current fill instead of recoloring blue; grey when unfilled
- fill mode as a radio group with short labels; compact inline bounds inputs
- space dialog widened to 500px; TESTING.md updated in the same commit
2026-07-08 14:15:37 +03:00
Matysh 2ee0b2c352 Merge temp-fill: temperature room fill v1.15.0 2026-07-07 18:25:33 +03:00
Matysh 3eb5b4a14d feat v1.15.0: temperature room fill (blue/green/yellow) with editable comfort bounds
- fill_mode 'temp': below comfort → #4fc3f7, inside → #66d17a, above → #ffd45c;
  bounds default 20-25°C, editable in the space dialog (visible only for this
  mode), swapped bounds tolerated; rooms without a temperature reading unfilled
- areaTemp(): average of the area devices' temperatures
- tests: roomFillColor temp bands + bounds, spaceDisplayOf defaults, areaTemp,
  backend schema (fill_mode temp + temp_min/temp_max); smoke_temp_fill.mjs
- TESTING.md updated in the same commit (policy)
2026-07-07 18:23:22 +03:00
Matysh eaa722679f Merge space-settings: per-space display settings, hand-drawn spaces, TESTING.md v1.14.0 2026-07-07 13:44:16 +03:00
Matysh 4f8e98cdc7 feat v1.14.0: per-space display settings, hand-drawn spaces, manual-testing checklist
- space dialog 'Display' section: room borders/names toggles, color+opacity,
  fill by zigbee signal or lights (tri-state: on/off/no-lights)
- draggable room name labels persisted as layout rl_<roomId>
- 'no image, outline by hand' space source with canvas orientation; image
  optional now; switching an existing space to draw detaches its plan
- demo/ synthetic-home harness lives in the repo (serve.mjs + smokes + icons gen)
- docs/TESTING.md checklist (same-commit update policy); self-run found and
  fixed: plan_url not detached on image->draw, _stateClass crash on states
  without entity_id; perf measured (162 devices ~14ms build)
- backend: SPACE_DISPLAY_SCHEMA validation (+test)
2026-07-07 13:41:51 +03:00
Matysh 6c8b509da2 docs: hacs/default submission is PR #9004 (bot-accepted); #8995 was closed for not using the exact PR template 2026-07-06 09:22:59 +03:00
Matysh 4146e5998e docs: replace all README screenshots with the synthetic demo home (privacy) — real-house captures removed from the tree; drop unreferenced 01-plan.png 2026-07-06 07:43:46 +03:00
Matysh 34d073f484 chore v1.13.3: remove legacy assets/ with real-house plan sources (privacy; unused since server-side config) 2026-07-06 07:39:43 +03:00
Matysh fff7368696 Merge audit-3: fixes + buildDevices test suite v1.13.2 2026-07-06 03:39:33 +03:00
Matysh 37604fa17e fix+test v1.13.2: audit round 3
- 12-test buildDevices suite on a fake hass (curation, markers, groups, dedup,
  icon rules, lock override, device_class fallback, primary/LQI/temp)
- t() substitutes all placeholder occurrences (pure subst() + regression test)
- _saveConfigNow resyncs config on rev conflict before rethrowing
- pointercancel clears the long-press timer (phantom info card on touch)
- repairs.py: plan-file check re-runs after every config save
- test build compiles devices.ts/types.ts; fix-test-build.mjs adds .js to ESM imports
2026-07-06 03:37:30 +03:00
Matysh 8a4f1a3033 feat v1.13.1: distribution — synthetic-home demo GIF, issue templates, CONTRIBUTING, Discussions (phase 10) 2026-07-06 01:03:38 +03:00
Matysh a12f731df8 Merge phase-9: universality (floors import, icon rules, tap actions) v1.13.0 2026-07-06 00:56:12 +03:00
Matysh afa8398602 feat v1.13.0: universality — floors-import wizard, editable icon rules, tap actions (phase 9)
- floors wizard: spaces from the HA floor registry, step-by-step with mandatory
  plan upload per floor, skip support, auto-markup after the last one
- icon rules as data (settings.icon_rules) + in-card editor with live test,
  invalid-regex guard, bilingual defaults, device_class fallback chain
- tap_action (card) + per-device override; security model: card-wide toggle only
  for light/switch/fan/humidifier, explicit override for covers, never lock/alarm;
  600ms long-press always opens the info card
- i18n dictionaries → src/i18n/*.json (+parity tests); light-theme badge fixes
- frontend tests 15→28
2026-07-06 00:54:13 +03:00
80 changed files with 9967 additions and 819 deletions
+36
View File
@@ -0,0 +1,36 @@
name: Bug report
description: Something in House Plan does not work as expected
labels: [bug]
body:
- type: input
id: version
attributes:
label: House Plan version
placeholder: v1.13.0
validations:
required: true
- type: input
id: ha_version
attributes:
label: Home Assistant version
placeholder: "2026.6"
validations:
required: true
- type: textarea
id: what
attributes:
label: What happened / what did you expect?
description: Steps to reproduce help a lot.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / browser console errors
description: "Settings → System → Logs (search: houseplan) and the browser console (F12)."
render: text
- type: textarea
id: diagnostics
attributes:
label: Diagnostics
description: "Settings → Devices & services → House Plan → ⋯ → Download diagnostics (personal fields are redacted automatically)."
@@ -0,0 +1,14 @@
name: Feature request
description: An idea to make House Plan better
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What problem would this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: How do you imagine it working?
+39
View File
@@ -0,0 +1,39 @@
# Contributing to House Plan
Thanks for your interest! The project is one HACS package: a storage **integration**
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
## Five-minute setup
```bash
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
npm ci # frontend toolchain
npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
npm run build # tsc + rollup → dist/houseplan-card.js
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
```
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
every push — locally they are skipped when `homeassistant` is not importable.
## Ground rules
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
see `resolveTapAction` in `src/logic.ts`; don't weaken it.
- Follow the Integration Quality Scale where applicable —
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
## Architecture
Start with `docs/ARCHITECTURE.md` (data model, WS API, coordinate system) and
`docs/STATUS.md` (current state). Release: bump the version in `package.json`,
`manifest.json`, `const.py`, `CARD_VERSION`, tag `vX.Y.Z`, publish a GitHub release —
the workflow attaches the card bundle.
+71 -1
View File
@@ -2,7 +2,7 @@
**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.**
![Interactive house plan with devices](docs/images/01-plan.png)
![House Plan demo](docs/images/demo.gif)
🇷🇺 [Документация на русском](README.ru.md)
@@ -51,6 +51,11 @@ Key advantages in short:
## 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**.
@@ -60,6 +65,21 @@ Key advantages in short:
The card is registered automatically — no need to add a Lovelace resource manually.
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
> and the element never registers). The correct, integration-served URL is
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
### Manually
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
@@ -84,6 +104,8 @@ Nothing else needs to be specified — everything else is configured right on th
### Step 1. Add a space (floor)
On first open the plan is still empty — House Plan immediately offers to create the first space.
If your Home Assistant already has **floors** configured, a wizard offers to create a space
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
@@ -103,6 +125,29 @@ As soon as the outline is closed, the room-save dialog appears. Here you need to
![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.
@@ -125,10 +170,35 @@ Device icons can be **dragged with the mouse at any time** — no separate "edit
![Dragging icons — available at all times](docs/images/06-edit.png)
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. In the card settings you can switch
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
specific device (except locks and alarms — those never toggle from the plan) in its
edit dialog. A **long press** always opens the info card.
### Icon rules
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
### Step 6. Adding your own devices manually
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
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)
---
+75 -1
View File
@@ -2,7 +2,7 @@
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.**
![Интерактивный план дома с устройствами](docs/images/01-plan.png)
![House Plan demo](docs/images/demo.gif)
🇬🇧 [English documentation](README.md)
@@ -51,6 +51,11 @@ House Plan показывает ваш умный дом так, как он в
## Установка
В один клик, если у вас уже есть 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**.
@@ -60,6 +65,21 @@ House Plan показывает ваш умный дом так, как он в
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
>
> ```yaml
> resources:
> - url: /houseplan_files/houseplan-card.js
> type: module
> ```
>
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
> не нужен.
### Вручную
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
@@ -85,6 +105,10 @@ title: План дома
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
пространство для каждого: названия подставятся сами, план попросит по очереди,
любой этаж можно пропустить.
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
@@ -103,6 +127,29 @@ title: План дома
![Разметка комнаты и её сохранение](docs/images/05-room-dialog.png)
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
стен** (в том числе за угол), а **двойной клик открывает свойства**.
### Шаг 3. Устройства появляются сами
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
@@ -125,10 +172,37 @@ title: План дома
![Перетаскивание значков — доступно всегда](docs/images/06-edit.png)
### Управление с плана (tap actions)
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
нажатие** всегда открывает инфо-карточку.
### Правила иконок
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
правило не подошло — решает *device class* сущности (термометр для датчиков
температуры и т.п.).
### Шаг 6. Добавление своих устройств вручную
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
может быть маленьким и повёрнутым так, как он установлен.
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
---
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 138 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 76 KiB

+17 -41
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from . import websocket_api as hp_ws
from .const import (
@@ -19,6 +18,7 @@ from .const import (
PLANS_URL,
VERSION,
)
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data
_LOGGER = logging.getLogger(__name__)
@@ -79,48 +79,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# start of the mobile app). If the resource registry is unavailable (YAML-mode
# Lovelace, old versions) — fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}"
if not await _register_lovelace_resource(hass, module_url):
registered = await _register_lovelace_resource(hass, module_url)
if not registered:
add_extra_js_url(hass, module_url)
await _check_plan_files(hass, entry)
return True
async def _check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
"""Repairs: raise an issue for every space whose plan file is missing on disk."""
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
spaces = cfg_raw.get("config", {}).get("spaces", [])
plans_dir = Path(hass.config.path(PLANS_DIR))
def _missing() -> list[tuple[str, str]]:
res = []
for sp in spaces:
url = sp.get("plan_url") or ""
if not url.startswith(PLANS_URL + "/"):
continue # external/legacy URL — not ours to verify
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
if not (plans_dir / fname).is_file():
res.append((sp.get("id", "?"), fname))
return res
missing = await hass.async_add_executor_job(_missing)
seen = set()
for space_id, fname in missing:
seen.add(space_id)
ir.async_create_issue(
hass,
DOMAIN,
f"broken_plan_{space_id}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
# Tell the user exactly where the card lives — the #1 support issue is people adding a
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
if registered:
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
else:
_LOGGER.info(
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
module_url, module_url,
)
# clear stale issues for spaces that are fine again (or gone)
for sp in spaces:
sid = sp.get("id", "?")
if sid not in seen:
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
await async_check_plan_files(hass, entry)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
+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.12.0"
VERSION = "1.24.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.12.0"
"version": "1.24.2"
}
+52
View File
@@ -0,0 +1,52 @@
"""Repair issues for House Plan.
The check runs at entry setup AND after every config save (ws_config_set),
so a plan file that goes missing — or gets re-uploaded — is reflected in the
Repairs UI without waiting for a restart.
"""
from __future__ import annotations
from pathlib import Path
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, PLANS_DIR, PLANS_URL
from .store import HouseplanConfigEntry
async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
"""Raise an issue for every space whose plan file is missing on disk."""
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
spaces = cfg_raw.get("config", {}).get("spaces", [])
plans_dir = Path(hass.config.path(PLANS_DIR))
def _missing() -> list[tuple[str, str]]:
res = []
for sp in spaces:
url = sp.get("plan_url") or ""
if not url.startswith(PLANS_URL + "/"):
continue # external/legacy URL — not ours to verify
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
if not (plans_dir / fname).is_file():
res.append((sp.get("id", "?"), fname))
return res
missing = await hass.async_add_executor_job(_missing)
broken = set()
for space_id, fname in missing:
broken.add(space_id)
ir.async_create_issue(
hass,
DOMAIN,
f"broken_plan_{space_id}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
)
# clear stale issues for spaces that are fine again (or gone)
for sp in spaces:
sid = sp.get("id", "?")
if sid not in broken:
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
+58 -2
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._-]+")
@@ -78,14 +78,50 @@ ROOM_SCHEMA = vol.All(
),
_require_geometry,
)
SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
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("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
},
extra=vol.ALLOW_EXTRA,
)
SPACE_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
vol.Required("title"): str,
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
vol.Required("rooms"): [ROOM_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,
@@ -103,6 +139,12 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("model"): vol.Any(str, None),
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("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)
],
@@ -113,7 +155,21 @@ 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("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
)
@@ -180,6 +180,12 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
# refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
+17
View File
@@ -0,0 +1,17 @@
# Synthetic demo home
A fully fictional house (plans, devices, states) used for README screenshots,
the demo GIF and headless smoke tests — so no real home data ever appears in
public materials.
- `srv/demo.html` — self-contained host page: `<ha-icon>`/`<ha-card>` stubs and a
fake `hass` (registries, states, `callWS`, `callService`, floors).
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
needs the repo's devDependencies). The card bundle is copied from `dist/`:
`cp dist/houseplan-card.js demo/srv/assets/`.
- `serve.mjs` — playwright launcher (route interception, no web server).
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
Note for sandboxed sessions: `/tmp` does not survive; this directory is the
persistent home of the harness (docs/DEVELOPMENT.md has the LD_LIBRARY_PATH recipe).
+22
View File
@@ -0,0 +1,22 @@
// Generate demo/srv/assets/icons.js: an { "mdi:name": "<svg path>" } map for every
// mdi: icon referenced in src/ and demo/ (the demo host stubs <ha-icon> with it).
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import * as mdi from '@mdi/js';
const names = new Set();
const scan = (dir) => {
for (const f of readdirSync(dir, { withFileTypes: true })) {
if (f.isDirectory()) { scan(`${dir}/${f.name}`); continue; }
if (!/\.(ts|json|html|mjs)$/.test(f.name) || f.name === 'icons.js') continue;
const txt = readFileSync(`${dir}/${f.name}`, 'utf8');
for (const m of txt.matchAll(/mdi:([a-z0-9-]+)/g)) names.add(m[1]);
}
};
scan('src'); scan('demo');
const map = {};
for (const n of [...names].sort()) {
const camel = 'mdi' + n.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
if (mdi[camel]) map['mdi:' + n] = mdi[camel];
}
writeFileSync('demo/srv/assets/icons.js', 'window.__ICONS=' + JSON.stringify(map) + ';\n');
console.log('icons:', Object.keys(map).length, 'of', names.size, 'referenced');
+25
View File
@@ -0,0 +1,25 @@
// Репро №3: пользовательский путь — открыть диалог, выставить «комфорт от 25», сохранить
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;
// living room: единственный термометр 22.4 → подменим на 24.0 как у пользователя
const h = window.__mkHass();
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '24.0' } };
c.hass = h; c._regSignature=''; c._maybeRebuildDevices(); await c.updateComplete;
// путь пользователя: диалог → режим temp, min=25 (макс не трогаем) → сохранить
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.dialogInit = { min: c._spaceDialog.tempMin, max: c._spaceDialog.tempMax, fill: c._spaceDialog.fillMode };
c._spaceDialog = { ...c._spaceDialog, fillMode: 'temp', tempMin: 25 };
await c._saveSpaceDialog(); await c.updateComplete;
const sp = c._serverCfg.spaces.find(s=>s.id==='f1');
out.savedSettings = sp.settings;
const styles = [...sr().querySelectorAll('.room.styled')].map(r => (r.getAttribute('style')||'').match(/--room-fill:([^;]+)/)?.[1]);
out.livingFill = styles[0];
out.expected = '#4fc3f7 (blue: 24 < 25)';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
+28
View File
@@ -0,0 +1,28 @@
// Shared launcher for demo captures: starts headless Chromium serving demo/srv/
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
import { chromium } from 'playwright';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
page.on('pageerror', (e) => console.log('EXC', e.message));
await page.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
if (p === '/') p = '/demo.html';
const f = ROOT + p;
existsSync(f)
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
: r.fulfill({ status: 404, body: 'nf' });
});
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
// hass flows continuously in production; the stub sets it once — nudge a rebuild
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
return { page, browser };
}
+26
View File
@@ -0,0 +1,26 @@
// Smoke: the full houseplan-card honours the #space=<id> deep-link (hashchange).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 1);
const res = await page.evaluate(async () => {
const card = window.__card;
const ids = card._model.map((s) => s.id);
const target = ids[1] || ids[0];
const before = card._space;
// simulate a deep-link arriving on the already-open dashboard
window.location.hash = '#space=' + target;
window.dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
const after = card._space;
// invalid hash must be ignored
window.location.hash = '#space=__nope__';
window.dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
const afterBad = card._space;
window.location.hash = '';
return { ids, before, target, after, afterBad };
});
await browser.close();
const ok = res.after === res.target && res.afterBad === res.target;
console.log(JSON.stringify(res));
if (!ok) { console.error('FAIL deeplink smoke'); process.exit(1); }
console.log('OK deep-link: full card switches to #space=<id>, ignores invalid ids');
+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 mk = () => document.createElement('houseplan-card');
const sr = (c) => c.shadowRoot || c.renderRoot;
// 1) пустая инсталляция: ноль устройств/зон, конфиг с пространством без комнат
const c1 = mk();
c1.setConfig({ type: 'custom:houseplan-card' });
document.body.appendChild(c1);
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150));
c1.hass = { ...c1.hass };
await c1.updateComplete; await new Promise(r=>setTimeout(r,50)); await c1.updateComplete;
out.emptyDevices = c1._devices.length;
out.emptyRenders = !!sr(c1).querySelector('.stage');
out.emptyCount = sr(c1).querySelector('.count')?.textContent.trim();
c1.remove();
// 2) XSS в именах комнат и устройств
const c2 = mk();
c2.setConfig({ type: 'custom:houseplan-card' });
document.body.appendChild(c2);
const evil = '<img src=x onerror=window.__pwned=1><b>bold</b>';
c2.hass = { language:'en', locale:{language:'en'},
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1],
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150));
c2.hass = { ...c2.hass };
await c2.updateComplete; await new Promise(r=>setTimeout(r,50)); await c2.updateComplete;
out.xssPwned = !!window.__pwned;
const lbl = sr(c2).querySelector('.roomlabel');
out.xssLabelIsText = lbl ? lbl.innerHTML.includes('&lt;img') || lbl.textContent.includes('<img') : 'no label';
out.xssDeviceRendered = sr(c2).querySelectorAll('.dev').length;
c2.remove();
// 3) легаси-записи layout v1 {x,y} без ключа s — игнорируются в norm-режиме
const c = window.__card;
c._layout = { ...c._layout, d_light1_legacy_test: { x: 500, y: 300 } };
const fake = { id: 'd_light1_legacy_test', space: 'f1' };
const p = c._pos(fake);
out.legacyIgnored = p.y !== 300; // centre y=400 if ignored; raw 300 would mean it leaked through
delete c._layout.d_light1_legacy_test;
// 4) перф: 150 устройств
const t0 = performance.now();
const devices = {}; const entities = {}; const states = {};
for (let i = 0; i < 150; i++) {
devices['p'+i] = { id:'p'+i, name:'Plug '+i, model:'Smart Plug', area_id:'living_room', identifiers:[['demo','p'+i]] };
entities['switch.p'+i] = { entity_id:'switch.p'+i, device_id:'p'+i, platform:'demo' };
states['switch.p'+i] = { state: i%2?'on':'off', attributes:{ linkquality: 50+i } };
}
const bigHass = { ...window.__mkHass(), devices:{...window.__mkHass().devices, ...devices},
entities:{...window.__mkHass().entities, ...entities}, states:{...window.__mkHass().states, ...states} };
c.hass = bigHass; c._regSignature=''; c._maybeRebuildDevices();
out.bigBuildMs = Math.round(performance.now() - t0);
out.bigCount = c._devices.length;
await c.updateComplete;
const t1 = performance.now(); c.requestUpdate(); await c.updateComplete;
out.bigRenderMs = Math.round(performance.now() - t1);
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();
+28
View File
@@ -0,0 +1,28 @@
// Asserts device icon badges sit exactly on their anchor point (no content-box/border drift).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 2);
const bad = await page.evaluate(() => {
const c = window.__card;
c._space = 'f1'; c._resetZoom(); c.requestUpdate();
const layer = c.renderRoot.querySelector('.devlayer');
const lr = layer.getBoundingClientRect();
const bad = [];
for (const d of layer.querySelectorAll('.dev')) {
const ax = lr.left + (parseFloat(d.style.left) / 100) * lr.width;
const ay = lr.top + (parseFloat(d.style.top) / 100) * lr.height;
const r = d.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const offX = cx - ax, offY = cy - ay;
// glyph centred within the badge
const icon = d.querySelector('ha-icon');
const svg = icon?.shadowRoot?.querySelector('svg') || icon?.shadowRoot?.querySelector('ha-svg-icon')?.shadowRoot?.querySelector('svg');
let gX = 0, gY = 0;
if (svg) { const sr = svg.getBoundingClientRect(); gX = sr.left + sr.width / 2 - cx; gY = sr.top + sr.height / 2 - cy; }
if (Math.abs(offX) > 0.6 || Math.abs(offY) > 0.6 || Math.abs(gX) > 0.6 || Math.abs(gY) > 0.6)
bad.push({ icon: icon?.getAttribute('icon'), offX: +offX.toFixed(2), offY: +offY.toFixed(2), glyphX: +gX.toFixed(2), glyphY: +gY.toFixed(2) });
}
return bad;
});
await browser.close();
if (bad.length) { console.error('FAIL icon-center: badges off anchor', JSON.stringify(bad.slice(0, 5))); process.exit(1); }
console.log('OK icon-center: all device badges centred on their anchor');
+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._toggleMarkup(); 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===4;
// redo + confirm with a name (no area)
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
s = await S();
out.splitRooms = s.rooms.length; // 5
out.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();
+53
View File
@@ -0,0 +1,53 @@
// Smoke: houseplan-space-card renders a static, non-interactive schematic + deep-link button.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const hass = window.__card.hass;
const spaceId = window.__card._model[0].id;
const host = document.createElement('div');
document.body.appendChild(host);
const mk = (cfg) => { const el = document.createElement('houseplan-space-card'); el.setConfig(cfg); el.hass = hass; host.appendChild(el); return el; };
const card = mk({ type: 'custom:houseplan-space-card', space: spaceId, button_target: '/plan-doma' });
// wait for the static stage to render (config loads via WS)
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) { await new Promise(r => setTimeout(r, 80)); }
await card.updateComplete;
const stage = card.renderRoot.querySelector('.hp-static-stage');
const pe = stage ? getComputedStyle(stage).pointerEvents : null;
const markers = card.renderRoot.querySelectorAll('.hp-static-stage .devlayer .dev').length;
const btn = card.renderRoot.querySelector('.hp-static-btn');
// deep-link: clicking the button pushes #space=<id>
let pushed = null;
const orig = history.pushState;
history.pushState = function (a, b, url) { pushed = url; return orig.apply(this, arguments); };
btn?.click();
history.pushState = orig;
// error card for an unknown space
const bad = mk({ type: 'custom:houseplan-space-card', space: '__nope__' });
await bad.updateComplete;
const errCard = bad.renderRoot.querySelector('.hp-static-error');
return {
stagePointerEvents: pe,
markers,
hasButton: !!btn,
deepLink: pushed,
errorShown: !!errCard,
errorText: errCard?.textContent?.trim() || null,
};
});
await browser.close();
const ok =
res.stagePointerEvents === 'none' &&
res.markers > 0 &&
res.hasButton &&
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
res.errorShown;
console.log(JSON.stringify(res));
if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); }
console.log('OK space-card: static (pointer-events:none), markers rendered, deep-link button, error card');
+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;
// 1) дефолт (план есть): границ и лейблов нет
out.defaultStyled = sr().querySelectorAll('.room.styled').length;
out.defaultLabels = sr().querySelectorAll('.roomlabel').length;
// 2) включаем границы+имена+заливку по свету
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { show_borders: true, show_names: true, room_color: '#ff8800', room_opacity: 0.8, fill_mode: 'light' },
})};
c.requestUpdate(); await c.updateComplete;
out.styled = sr().querySelectorAll('.room.styled').length;
out.labels = [...sr().querySelectorAll('.roomlabel')].map((l) => l.textContent.trim());
const liv = [...sr().querySelectorAll('.room.styled')][0];
out.livingStyle = liv.getAttribute('style');
// living: ceiling on → жёлтая; kitchen: нет light-сущностей → без заливки; bedroom light off → серая
const styles = [...sr().querySelectorAll('.room.styled')].map((r) => r.getAttribute('style'));
out.hasYellow = styles.some((s) => s.includes('#ffd45c'));
out.hasGrey = styles.some((s) => s.includes('#9aa0a6'));
out.kitchenNoFill = styles.some((s) => s.includes('--room-fill:transparent'));
// 3) lqi-заливка
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { ...s.settings, fill_mode: 'lqi' },
})};
c.requestUpdate(); await c.updateComplete;
out.lqiFills = [...sr().querySelectorAll('.room.styled')].filter((r) => (r.getAttribute('style') || '').includes('hsl(')).length;
// 4) drag лейбла → layout rl_
const lbl = sr().querySelector('.roomlabel');
c._labelDown({ preventDefault(){}, stopPropagation(){}, clientX: 100, clientY: 100, target: { setPointerCapture(){} }, pointerId: 5 },
c._spaceModel().rooms[0], 'f1');
c._labelMove({ clientX: 160, clientY: 140 }, c._spaceModel().rooms[0], 'f1');
c._labelUp(c._spaceModel().rooms[0]);
out.labelSaved = !!c._layout['rl_r1'];
// 5) диалог: create + draw
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticAspect = attic?.aspect;
out.atticSettings = attic?.settings;
out.atticNoPlan = attic ? attic.plan_url === null : null;
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._toggleMarkup(); 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();
+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;
const setFill = async (settings) => {
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s, settings })) };
c.requestUpdate(); await c.updateComplete;
return [...sr().querySelectorAll('.room.styled')].map((r) => {
const st = r.getAttribute('style') || '';
const m = st.match(/--room-fill:([^;]+)/);
return m ? m[1] : null;
});
};
// living room avg temp = 22.4 (единственный термометр в demo)
out.comfy = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 });
out.cold = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 23, temp_max: 26 });
out.hot = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 18, temp_max: 21 });
out.swapped = await setFill({ show_borders: true, fill_mode: 'temp', temp_min: 25, temp_max: 20 });
// диалог: поля границ видны только в режиме temp
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.dialogTempFields = sr().querySelectorAll('.tempin').length;
c._spaceDialog = { ...c._spaceDialog, fillMode: 'none' }; await c.updateComplete;
out.dialogHiddenWhenNone = sr().querySelectorAll('.tempin').length;
c._spaceDialog = null;
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({ width: 640, height: 980 }, 2);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c.hass = { ...c.hass, locale: { language: 'ru' } };
await c.updateComplete;
// заливка temp вкл → класс filled и тултип с температурой
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({ ...s,
settings: { show_borders: true, show_names: true, fill_mode: 'temp', temp_min: 20, temp_max: 25 } })) };
c._regSignature=''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
out.filledClass = sr().querySelectorAll('.room.styled.filled').length; // только living (термометр)
out.unfilled = sr().querySelectorAll('.room.styled:not(.filled)').length;
// тултип комнаты: средняя температура
const room = sr().querySelector('.room');
room.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, composed: true, clientX: 200, clientY: 200 }));
await c.updateComplete;
out.tipTemp = c._tip?.temp;
out.tipHasTempLine = (sr().querySelector('.tip')?.textContent || '').includes('средняя температура');
c._tip = null;
// диалог: радио заливки, компактные поля, ширина
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
// NaN-защита: пустой ввод не ломает границы
const before = c._spaceDialog.tempMax;
const fakeEv = { target: { value: '' } };
// симулируем input с пустым значением через обработчик радио-строки — парсер должен сохранить старое
const n = parseFloat('');
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
return out;
});
await page.screenshot({ path: '/tmp/ux_dialog.png' });
console.log(JSON.stringify(res, null, 1));
await browser.close();
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 800">
<rect width="1000" height="800" fill="#f4f1ea"/>
<g opacity="0.35" stroke="#d9d2c4" stroke-width="1"><line x1="0" y1="0" x2="0" y2="800"/><line x1="50" y1="0" x2="50" y2="800"/><line x1="100" y1="0" x2="100" y2="800"/><line x1="150" y1="0" x2="150" y2="800"/><line x1="200" y1="0" x2="200" y2="800"/><line x1="250" y1="0" x2="250" y2="800"/><line x1="300" y1="0" x2="300" y2="800"/><line x1="350" y1="0" x2="350" y2="800"/><line x1="400" y1="0" x2="400" y2="800"/><line x1="450" y1="0" x2="450" y2="800"/><line x1="500" y1="0" x2="500" y2="800"/><line x1="550" y1="0" x2="550" y2="800"/><line x1="600" y1="0" x2="600" y2="800"/><line x1="650" y1="0" x2="650" y2="800"/><line x1="700" y1="0" x2="700" y2="800"/><line x1="750" y1="0" x2="750" y2="800"/><line x1="800" y1="0" x2="800" y2="800"/><line x1="850" y1="0" x2="850" y2="800"/><line x1="900" y1="0" x2="900" y2="800"/><line x1="950" y1="0" x2="950" y2="800"/><line x1="0" y1="0" x2="1000" y2="0"/><line x1="0" y1="50" x2="1000" y2="50"/><line x1="0" y1="100" x2="1000" y2="100"/><line x1="0" y1="150" x2="1000" y2="150"/><line x1="0" y1="200" x2="1000" y2="200"/><line x1="0" y1="250" x2="1000" y2="250"/><line x1="0" y1="300" x2="1000" y2="300"/><line x1="0" y1="350" x2="1000" y2="350"/><line x1="0" y1="400" x2="1000" y2="400"/><line x1="0" y1="450" x2="1000" y2="450"/><line x1="0" y1="500" x2="1000" y2="500"/><line x1="0" y1="550" x2="1000" y2="550"/><line x1="0" y1="600" x2="1000" y2="600"/><line x1="0" y1="650" x2="1000" y2="650"/><line x1="0" y1="700" x2="1000" y2="700"/><line x1="0" y1="750" x2="1000" y2="750"/></g>
<rect x="40" y="40" width="920" height="720" fill="none" stroke="#3b4753" stroke-width="14"/>
<line x1="550" y1="40" x2="550" y2="300" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="380" x2="550" y2="620" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="700" x2="550" y2="760" stroke="#3b4753" stroke-width="10"/>
<line x1="550" y1="360" x2="820" y2="360" stroke="#3b4753" stroke-width="10"/>
<line x1="900" y1="360" x2="960" y2="360" stroke="#3b4753" stroke-width="10"/>
<line x1="40" y1="480" x2="300" y2="480" stroke="#3b4753" stroke-width="10"/>
<line x1="380" y1="480" x2="550" y2="480" stroke="#3b4753" stroke-width="10"/>
<rect x="120" y="46" width="180" height="10" fill="#a8c6e8"/>
<rect x="640" y="46" width="140" height="10" fill="#a8c6e8"/>
<rect x="700" y="744" width="140" height="10" fill="#a8c6e8"/>
<text x="295" y="270" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">LIVING ROOM</text>
<text x="775" y="200" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">KITCHEN</text>
<text x="775" y="560" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">BEDROOM</text>
<text x="295" y="640" font-family="Arial" font-size="30" letter-spacing="4" fill="#8b95a1" text-anchor="middle" opacity="0.55">HALLWAY</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 700">
<rect width="1000" height="700" fill="#eef3e6"/>
<rect x="30" y="30" width="940" height="640" fill="none" stroke="#7c9c6b" stroke-width="10" stroke-dasharray="24 14"/>
<circle cx="260" cy="240" r="90" fill="#dbe8cd" stroke="#9dba8a" stroke-width="4"/>
<circle cx="700" cy="450" r="130" fill="#d3e4f2" stroke="#8fb6d8" stroke-width="4"/>
<rect x="600" y="80" width="260" height="150" fill="#e7ddc8" stroke="#b8a888" stroke-width="5"/>
<text x="730" y="165" font-family="Arial" font-size="26" letter-spacing="3" fill="#8b95a1" text-anchor="middle" opacity="0.6">GARAGE</text>
<text x="260" y="248" font-family="Arial" font-size="22" letter-spacing="3" fill="#7d9169" text-anchor="middle" opacity="0.7">LAWN</text>
<text x="700" y="458" font-family="Arial" font-size="22" letter-spacing="3" fill="#6f93ad" text-anchor="middle" opacity="0.7">POOL</text>
</svg>

After

Width:  |  Height:  |  Size: 920 B

+1775
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+148
View File
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>House Plan demo</title>
<style>
:root{
--primary-color:#3ea6ff; --primary-text-color:#e1e1e1; --secondary-text-color:#9aa4ad;
--card-background-color:#1c2530; --ha-card-background:#1c2530;
--divider-color:#33404d; --text-primary-color:#fff; --mdc-icon-size:24px;
--header-height:0px;
}
html,body{margin:0;background:#0e1621;color:#e1e1e1;font-family:'Segoe UI',Roboto,Arial,sans-serif;}
#host{width:780px;margin:0 auto;padding:8px;}
</style>
</head>
<body>
<div id="host"></div>
<script src="/assets/icons.js"></script>
<script>
class HaIcon extends HTMLElement{
static get observedAttributes(){return ['icon'];}
connectedCallback(){this._r();}
attributeChangedCallback(){this._r();}
set icon(v){this.setAttribute('icon',v);}
get icon(){return this.getAttribute('icon');}
_r(){
const p=(window.__ICONS||{})[this.getAttribute('icon')]||'';
if(!this.shadowRoot) this.attachShadow({mode:'open'});
// Faithful to HA's ha-icon: host is display:block with a large inherited line-height,
// which pushes the (smaller) svg off-centre unless the consumer neutralises it.
this.shadowRoot.innerHTML='<style>:host{display:block;line-height:1.8;'+
'width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);'+
'fill:currentColor;vertical-align:middle}'+
'svg{width:var(--mdc-icon-size,24px);height:var(--mdc-icon-size,24px);fill:currentColor;'+
'display:inline-block;vertical-align:baseline}</style>'+
'<svg viewBox="0 0 24 24"><path d="'+p+'"></path></svg>';
}
}
customElements.define('ha-icon',HaIcon);
class HaCard extends HTMLElement{
connectedCallback(){
if(this.shadowRoot)return;
this.attachShadow({mode:'open'});
this.shadowRoot.innerHTML='<style>:host{display:block;background:var(--ha-card-background,#1c2530);'+
'border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.4);overflow:visible;color:var(--primary-text-color)}</style><slot></slot>';
}
}
customElements.define('ha-card',HaCard);
</script>
<script type="module">
const CFG = {
spaces: [
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25,
view_box:[0,0,1,1],
rooms:[
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]},
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]},
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]},
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]}
], segments:[] },
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286,
view_box:[0,0,1,1],
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] }
],
markers: [],
settings: {}
};
const LAYOUT = {
d_light1:{s:'f1',x:0.22,y:0.22}, d_lamp:{s:'f1',x:0.42,y:0.5}, d_tv:{s:'f1',x:0.13,y:0.5},
d_temp:{s:'f1',x:0.33,y:0.35}, d_kettle:{s:'f1',x:0.72,y:0.15}, d_leak:{s:'f1',x:0.88,y:0.34},
d_bedlight:{s:'f1',x:0.75,y:0.6}, d_window:{s:'f1',x:0.9,y:0.85}, d_lock:{s:'f1',x:0.1,y:0.88},
d_motion:{s:'f1',x:0.4,y:0.75}, d_mower:{s:'garden',x:0.25,y:0.35}, d_gate:{s:'garden',x:0.73,y:0.22}
};
const AREAS={living_room:{area_id:'living_room',name:'Living room'},kitchen:{area_id:'kitchen',name:'Kitchen'},
bedroom:{area_id:'bedroom',name:'Bedroom'},hallway:{area_id:'hallway',name:'Hallway'},garden:{area_id:'garden',name:'Garden'}};
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],entry_type:null,via_device_id:null}];}
const DEVICES=Object.fromEntries([
dev('d_light1','Ceiling light','Smart Bulb E27','living_room'),
dev('d_lamp','Floor lamp','Smart Bulb E14','living_room'),
dev('d_tv','TV','55U8HQ','living_room'),
dev('d_temp','Temperature sensor','TH01','living_room'),
dev('d_kettle','Kettle plug','Smart Plug','kitchen'),
dev('d_leak','Sink leak sensor','SJCGQ11LM','kitchen'),
dev('d_bedlight','Bedroom light','Smart Bulb E27','bedroom'),
dev('d_window','Window sensor','MCCGQ11LM','bedroom'),
dev('d_lock','Front door lock','Smart Lock S2','hallway'),
dev('d_motion','Motion sensor','RTCGQ11LM','hallway'),
dev('d_mower','Robot mower','Automower 305','garden'),
dev('d_gate','Gate','GDO-4','garden'),
]);
const ENTITIES={
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo'},
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo'},
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo'},
'sensor.living_temp':{entity_id:'sensor.living_temp',device_id:'d_temp',platform:'demo'},
'switch.kettle':{entity_id:'switch.kettle',device_id:'d_kettle',platform:'demo'},
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',device_id:'d_leak',platform:'demo'},
'light.bedroom':{entity_id:'light.bedroom',device_id:'d_bedlight',platform:'demo'},
'binary_sensor.window':{entity_id:'binary_sensor.window',device_id:'d_window',platform:'demo'},
'lock.front_door':{entity_id:'lock.front_door',device_id:'d_lock',platform:'demo'},
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',device_id:'d_motion',platform:'demo'},
'vacuum.mower':{entity_id:'vacuum.mower',device_id:'d_mower',platform:'demo'},
'cover.gate':{entity_id:'cover.gate',device_id:'d_gate',platform:'demo'},
};
let STATES={
'light.ceiling':{entity_id:'light.ceiling',state:'on',attributes:{friendly_name:'Ceiling light'}},
'light.floor_lamp':{entity_id:'light.floor_lamp',state:'off',attributes:{friendly_name:'Floor lamp',linkquality:196}},
'media_player.tv':{entity_id:'media_player.tv',state:'playing',attributes:{friendly_name:'TV'}},
'sensor.living_temp':{entity_id:'sensor.living_temp',state:'22.4',attributes:{friendly_name:'Temperature',device_class:'temperature',unit_of_measurement:'°C',linkquality:154}},
'switch.kettle':{entity_id:'switch.kettle',state:'off',attributes:{friendly_name:'Kettle plug',linkquality:182}},
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',state:'off',attributes:{friendly_name:'Leak',device_class:'moisture',linkquality:117}},
'light.bedroom':{entity_id:'light.bedroom',state:'off',attributes:{friendly_name:'Bedroom light'}},
'binary_sensor.window':{entity_id:'binary_sensor.window',state:'on',attributes:{friendly_name:'Window',device_class:'window',linkquality:88}},
'lock.front_door':{entity_id:'lock.front_door',state:'locked',attributes:{friendly_name:'Front door',linkquality:143}},
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',state:'on',attributes:{friendly_name:'Motion',device_class:'motion',linkquality:64}},
'vacuum.mower':{entity_id:'vacuum.mower',state:'cleaning',attributes:{friendly_name:'Mower'}},
'cover.gate':{entity_id:'cover.gate',state:'closed',attributes:{friendly_name:'Gate'}},
};
function mkHass(){
return {
language:'en', locale:{language:'en'},
devices:DEVICES, entities:ENTITIES, areas:AREAS, states:STATES,
floors:{g:{floor_id:'g',name:'Ground floor',level:0},u:{floor_id:'u',name:'Upstairs',level:1}},
callWS:async (m)=>{
if(m.type==='houseplan/config/get')return{config:CFG,rev:1};
if(m.type==='houseplan/layout/get')return{layout:LAYOUT};
return {ok:true,rev:2};
},
callService:async (d,s,data)=>{
const eid=data.entity_id, st=STATES[eid];
if(st){const on=['on','playing','unlocked','open'].includes(st.state);
STATES={...STATES,[eid]:{...st,state:on?'off':'on'}};
card.hass=mkHass();}
},
connection:{subscribeEvents:async()=>()=>{}},
formatEntityState:(st)=>st.state,
};
}
await import('/assets/houseplan-card.js');
const card=document.createElement('houseplan-card');
card.setConfig({type:'custom:houseplan-card', title:'House Plan', icon_size:3.4});
document.getElementById('host').appendChild(card);
card.hass=mkHass();
window.__card=card;
window.__mkHass=mkHass;
</script>
</body></html>
+842 -111
View File
File diff suppressed because one or more lines are too long
+84 -4
View File
@@ -21,7 +21,6 @@ houseplan-card/
│ ├─ config_flow.py # single entry; admin_only option (editing restricted to admins)
│ ├─ const.py # DOMAIN, STORAGE_KEY, VERSION, FRONTEND_URL
│ └─ frontend/houseplan-card.js # copy of dist, served as /houseplan_files/houseplan-card.js
├─ assets/ # plan sources: f1_plan.svg, f2_plan.svg (REMPLANNER), *_bg.png (old raster versions)
├─ hacs.json # HACS manifest
└─ docs/ # this documentation
```
@@ -87,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),
@@ -110,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 |
@@ -132,3 +182,33 @@ rectangles are rendered uniformly (hit-test: point-in-polygon / rect).
**File uploads go over HTTP** (not WS, which has a message-size limit): `POST /api/houseplan/upload`
(multipart: marker_id + file), HomeAssistantView, requires_auth. Served from `/houseplan_files/files/`.
## Second card: houseplan-space-card (read-only, v1.16.0)
The bundle registers **two** custom elements from one entry (`src/houseplan-card.ts`
imports `./space-card`):
- `houseplan-card` — the full interactive card.
- `houseplan-space-card` — a static, read-only schematic of ONE space for embedding.
Shared, framework-light modules keep the two views from diverging:
- `src/space-geometry.ts` — pure model/position math (`spaceModels`, `roomBounds`,
`roomCenter`, `defaultPositions`, `markerPos`, `labelPos`; no Lit import) — unit-tested,
mirrors the full card's private geometry.
- `src/space-render.ts` — `renderSpaceStatic()` draws the plan + configured room
borders/names + device markers (via `buildDevices`, same curation) with NO handlers,
NO live states, NO status/temperature fills. Uses the same CSS classes as the full card
(the space-card imports `cardStyles`) for visual parity.
- `src/config-store.ts` — module-level `{config, rev, layout}` cache shared by all embedded
cards (dedupes `houseplan/config/get`), seeded synchronously from the full card's
localStorage snapshot (`houseplan_card_cfg_v1`) and invalidated on `houseplan_config_updated`.
**Static contract:** the schematic layer (`.hp-static-stage`) is `pointer-events:none`; the
footer button lives outside it and stays clickable.
**Deep-link contract:** the footer button calls `navigate(button_target + "#space=<id>")`
(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.
+378
View File
@@ -1,5 +1,383 @@
# Changelog
## 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.
Previously it required the `mdi:water-percent` icon, so a humidity sensor whose name matched
another icon rule (e.g. a "Myheat Влажность …" sensor → boiler icon) showed no value.
Verified live (45.2 → 45%). (+1 test: 57 → 58.)
## v1.17.1 — 2026-07-11 (humidity value next to the icon, like temperature)
- **Humidity sensors now show their value (%) next to the icon**, mirroring the temperature
badge. Any marker resolved to the humidity icon (`mdi:water-percent`) — a humidity device or a
humidity entity placed on its own (v1.17.0) — gets an integer `%` badge and the value in its
tooltip. New `isHumEntity`/`humFor` (diagnostic entities excluded), `DevItem.hum`, `.hval`
badge, gated by the same "sensor values" option as temperature. (+2 tests: 55 → 57.)
## v1.17.0 — 2026-07-11 (place individual entities, not just whole devices — issue #1)
- **You can now put a single entity on the plan as its own icon** — e.g. a climate sensor
exposes temperature AND humidity; add the device (shows temperature) and separately add the
humidity entity as a second icon. In the "add device" dialog, start typing in the binding
search and individual entities now appear alongside devices/helpers (surfaced only while
searching, so the default list stays clean); the sub-label shows the domain and parent device.
- Entity markers now get a **sensible auto icon** (name rules → `device_class` → chip) instead of
the generic shape, and a **temperature value** when the entity is a thermometer/air-monitor.
The `entity:<eid>` binding already existed (used by helpers/light groups); this exposes it for
any entity. (+1 test: 54 → 55.)
## v1.16.2 — 2026-07-11 (docs+log: correct card resource URL — fixes "Custom element doesn't exist")
- **Support issue #2**: users adding a Lovelace resource that points at the on-disk path
`/custom_components/houseplan/frontend/houseplan-card.js` get a `text/plain` MIME error and
"Custom element doesn't exist: houseplan-card" — HA does not serve `custom_components/` over
HTTP. The integration serves the card at **`/houseplan_files/houseplan-card.js`** (verified:
`200 text/javascript`) and auto-registers it as a Lovelace resource in storage mode.
- README (en+ru) now documents the correct URL and the common mistake, incl. a YAML-mode
`resources:` snippet.
- On setup the integration logs (INFO) the exact served URL and, when Lovelace resources are
YAML-managed, how to add it manually — so the fix is discoverable from the logs.
## v1.16.1 — 2026-07-08 (space-card shows room fills as configured)
- **The static `houseplan-space-card` now renders room fills exactly as configured on the
full card** (temperature / signal / lights coloring), as a snapshot of the states passed
in via `hass` — reverting the v1.16.0 omission. The card still does not subscribe to state
changes itself and stays non-interactive (`pointer-events:none`); fills refresh when HA
hands the card an updated `hass`. Added shared `areaLqi()` in devices.ts.
## v1.16.0 — 2026-07-08 (new: houseplan-space-card + deep-link)
- **New second card `custom:houseplan-space-card`** — a READ-ONLY, static schematic of a
single space, embeddable on any dashboard. Draws the configured plan + room borders/names +
device markers at their saved positions, with **no interactivity** (the schematic layer is
`pointer-events:none` — no clicks/hover/tooltips/drag/more-info) and **no live states**
(no state subscription, no status/temperature fills). A footer button opens the space in the
full component via a **deep-link** (`#space=<id>`). Config: `space` (required), `title`,
`show_button`, `button_label`, `button_target`, `aspect_ratio`, `icon_size`; unknown space → a
tidy error card. GUI editor with a space dropdown from the integration config.
- **Deep-link in the full card**: on load it reads `#space=<id>` (valid id wins over
`default_floor`) and listens to `hashchange`, without blocking manual space switching.
- **Shared rendering**: pure geometry in `space-geometry.ts` (unit-tested), the static drawer in
`space-render.ts`, and a **module-level config cache** (`config-store.ts`, rev-keyed, seeded from
the full card's localStorage snapshot, invalidated on `houseplan_config_updated`) so N embedded
cards on one board share a single WS request. Both cards ship in the one bundle.
- Tests: 48 → 54 frontend (space geometry) + demo smokes `smoke_space_card` and `smoke_deeplink`.
- Note: status/temperature fills are intentionally omitted from the static card (they are live);
it shows configured room borders/names + neutral icons. Marker/position edits reflect after the
config event or a reload.
## v1.15.6 — 2026-07-08 (room hover also reveals the border)
- Hovering a room now **shows its outline** even when borders are turned off. The stroke
colour (`--room-stroke`) is now always set to the room colour and only hidden via
`--room-stroke-op` — so the existing `stroke-opacity: 1` on hover reveals a crisp border
(previously the stroke was `transparent` when borders were off, so hover showed nothing).
## v1.15.5 — 2026-07-08 (fix: room hover was always grey even when filled)
- Room hover now **darkens the current fill** for filled rooms and only greys **unfilled**
ones (as intended since v1.15.1). The legacy `.room.overlay:hover` / `.room.yard:hover`
grey rules were still matching styled rooms and, being applied to `fill` directly, beat
the `--room-fill` variable — so a temperature/light/zigbee-filled room turned grey on
hover. Scoped those legacy rules with `:not(.styled)`; styled rooms are now governed only
by `.styled.filled:hover` (brightness 0.78) and `.styled:not(.filled):hover` (grey).
## v1.15.4 — 2026-07-08 (fix: device icon vertical centering — proper root cause)
- **Root cause of the off-centre icon, found in the live app** (the demo stub hid it):
HA's real `<ha-icon>` host is `display:block` with a large inherited `line-height`
(~22 px for a ~12 px glyph), so the SVG sat ~1.8 px **below** the badge centre.
Fix: `.dev ha-icon` is now a zero-line-height flex box — the glyph centres exactly.
- Reverted the v1.15.3 `box-sizing: border-box` (it shrank the badge by 2 px and made the
vertical offset *more* visible — "worse"). The 1 px anchor drift is instead corrected by
the centering margin (`-(size/2 + 1px)`), keeping the original badge size.
- Verified in the real dashboard: anchor offset 0, glyph offset 0, badge size unchanged.
- Demo `ha-icon` stub made faithful to HA (block + line-height) so `smoke_icon_center`
now actually reproduces and guards this; the smoke also asserts glyph-in-badge centering.
## v1.15.3 — 2026-07-08 (fix: device icon 1px off its anchor point)
- Device icon badges were sitting **1 px down-and-right of their true point**: `.dev`
used the default `content-box`, so the 1 px border made the rendered square 2 px
wider than the width the centering margin assumed. Added `box-sizing: border-box` —
the badge centre now lands exactly on the device coordinate (verified in the demo:
anchor offset 1 px → 0). The glyph itself was already centred within the square.
## v1.15.2 — 2026-07-08 (fix: room average temperature counted non-thermometers)
- **Average room temperature now uses only devices the card treats as thermometers**
(`mdi:thermometer` / `mdi:air-filter`). Previously `areaTemp` swept every device in the
area through `tempFor`, so fridges, TRV heads and smart plugs leaked their readings into
the average (e.g. a 8.3 °C valve/appliance temperature dragging a room down).
- **`isTempEntity` now excludes chip/diagnostic temperatures**: `*_device_temperature`
sensors and any entity in the `config`/`diagnostic` category are no longer treated as a
room temperature — so even a genuine thermometer no longer reports its chip temperature.
- Affects the temperature room fill and the room tooltip average. (+3 frontend tests: 46 → 48.)
## v1.15.1 — 2026-07-06 (display-settings UX round from live usage)
- **Comfort-bounds input hardening**: clearing a temperature field can no longer
collapse a bound to 0 (`Number('') === 0` — this silently turned "comfort from
25" into a 0–25 range after the auto-swap, showing green at 24°). Inputs now
parse with `parseFloat` + `isFinite` guard and the save path falls back to the
defaults for non-finite values.
- Room tooltip now shows the **average room temperature** (what the temperature
fill actually uses — averages every thermometer in the area, including TRVs).
- **Hover no longer recolors rooms blue**: filled rooms darken their current fill
(`brightness(0.78)`), unfilled rooms get a light grey tint.
- Fill mode selector is a **radio group** with short labels (no color legend);
the comfort bounds sit compactly inline on the temperature row (56 px inputs).
- The space dialog is wider (500 px) — the settings no longer feel cramped.
## v1.15.0 — 2026-07-06 (temperature room fill)
- New room fill mode **"Temperature"**: light blue below the comfort range, green
inside it, warm yellow above. The comfort bounds (default 20–25 °C) are editable
right in the space dialog and appear only when the mode is selected; bounds
entered in the wrong order are swapped automatically. A room's temperature is
the average of its devices' temperature readings; rooms without a reading stay
unfilled. (+`areaTemp` helper, 3 new frontend tests, backend schema fields.)
## v1.14.0 — 2026-07-06 (per-space display settings, hand-drawn spaces, testing checklist)
- **Per-space "Display" settings** (space dialog): always-visible room borders,
room name labels, a border/name color picker with an opacity slider, and a room
fill mode — none / by zigbee signal (red→green) / by lights (yellow = something
is on, grey = all lights off; rooms without lights stay unfilled).
- **Room name labels are draggable** like device icons; positions persist server-side
(layout keys `rl_<roomId>`), defaults to the room centre; hidden in markup mode.
- **Hand-drawn spaces**: the space dialog got a "No image — I'll outline rooms by
hand" option with a canvas orientation choice (landscape/portrait/square). Such
spaces default to visible borders and names; switching an existing space to this
mode detaches its background image. The plan image is no longer mandatory.
- Backend: explicit validation schema for the new per-space settings (+test).
- **`demo/` harness moved into the repository** (synthetic home, host page, capture
and smoke scripts, icon-map generator) — public materials and smoke tests no
longer depend on a perishable sandbox.
- **`docs/TESTING.md`**: a comprehensive manual-testing checklist (environments
matrix, every feature, edge cases); policy — updated in the same commit as any
functional change. The first self-run found and fixed two bugs:
`plan_url` not detached on image→draw switch, and a `_stateClass` crash on
state objects without `entity_id`.
- Tests: 43 frontend + 11 pure backend; new smokes `smoke_space_settings` and
`smoke_edge_cases` (empty install, XSS names, legacy layout entries, 150-device perf).
## v1.13.3 — 2026-07-06 (privacy: drop legacy real-house plan sources)
- Removed the legacy `assets/` directory (real floor-plan sources from the pre-v1.3
bundled-data era). Nothing in the build referenced it; instance data lives in
server-side config. Note: the files remain in old git history and release archives.
## v1.13.2 — 2026-07-05 (audit round 3: fixes + buildDevices test suite)
- **buildDevices finally has a direct unit-test suite** (12 tests on a fake hass):
area filtering, curation incl. show-all, duplicate numbering, light-group folding
and its `group_lights=false` inverse, marker claim/metadata/hidden/virtual/entity
paths, custom icon rules + the deliberate lock override, device_class fallback,
primary-entity priority, LQI/temperature extraction. Frontend tests: 28 → 41.
- Fix: `t()` now substitutes **every** occurrence of a placeholder (extracted as the
pure `subst()` helper with a regression test).
- Fix: `_saveConfigNow` refreshes the local config on a rev conflict before
rethrowing — a retry no longer hits the same conflict (the debounced path already
did this; the immediate path did not).
- Fix: `pointercancel` on a device icon clears the long-press timer — no phantom
info card after an aborted touch gesture.
- Repairs check moved to `repairs.py` and now **re-runs after every config save**,
so a missing/restored plan file is reflected in the Repairs UI without a restart.
- Documented the deliberate `mdi:lock` override in `devices.ts` (wins over custom
rules — a mislabeled lock icon is safety-relevant confusion).
- Test infra: `tsconfig.test.json` also compiles `devices.ts`/`types.ts`;
`scripts/fix-test-build.mjs` appends `.js` to tsc's extensionless ESM imports.
## v1.13.1 — 2026-07-05 (distribution materials)
- **Demo GIF** in the README — recorded on a fully synthetic demo home (no real
floor plans): live states, tap-to-toggle, drag, zoom, info card, space tabs.
- GitHub issue templates (bug report with diagnostics hint, feature request),
`CONTRIBUTING.md` (5-minute setup, ground rules, architecture pointers),
Discussions enabled.
## v1.13.0 — 2026-07-05 (universality: floors import, icon rules, tap actions)
- **Floors import wizard**: on first run, if the HA registry has floors, the card
offers to create a space per floor — names prefilled, the (mandatory) plan image is
requested step by step, any floor can be skipped; after the last one the room-markup
mode opens automatically. No floors / old HA → the old single-dialog onboarding.
- **Editable icon rules**: the built-in "name pattern → MDI icon" rules are now data
(`settings.icon_rules`) with an in-card editor (⬡ in the header): reorder, delete,
add, live test field, invalid-regex highlighting, one-click reset to the bilingual
(EN/RU) defaults. Fallback chain: rules → entity `device_class` → generic chip.
Invalid user regexes are skipped safely.
- **Tap actions**: `tap_action` card option (`info` default | `more-info` | `toggle`)
plus a per-device override in the device dialog. Safety model: a card-wide toggle
only affects lights/switches/fans/humidifiers; covers/valves need a conscious
per-device toggle; locks and alarm panels never toggle from the plan. A long press
(600 ms) always opens the info card.
- **i18n dictionaries moved to JSON** (`src/i18n/{en,ru}.json`) — new languages can be
contributed without touching TypeScript; tests enforce key and placeholder parity.
- Light-theme pass: hardcoded dark badge backgrounds replaced with theme variables.
- Tests: 28 frontend (was 15) — tap-action resolver incl. security cases, icon-rule
compilation/overrides/device-class fallback, floors sorting, i18n parity.
## v1.12.0 — 2026-07-05 (Quality Scale: Bronze + selected Silver/Gold)
Backend brought to Integration Quality Scale patterns (custom integrations are not
formally graded; progress is tracked in `custom_components/houseplan/quality_scale.yaml`):
+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
+8 -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.
+72 -10
View File
@@ -9,18 +9,18 @@
> (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-17)
| Item | State |
|---|---|
| Version | **v1.12.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…v1.11.2 |
| Version | **v1.24.2** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/8995** (queue ≈2 months as of 2026-07) |
| 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 |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.23.1**, installed *via HACS* (custom repo). The user has real rooms, 12 openings (doors/windows with TTLock locks and contact sensors) and live fills configured |
| Localization | UI en/ru (`src/i18n.ts`), auto by `hass.locale` + `language` card option; codebase and docs are English-first (`README.ru.md` is the Russian copy) |
| Tests | 15 frontend (node:test) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13 + pytest-homeassistant-custom-component; skipped locally — sandbox has py3.10) |
| Tests | 77 frontend (node:test, incl. a 12-test buildDevices suite on a fake hass) + 10 pure backend (anywhere) + 12 HA-harness backend (CI only, py3.13; skipped locally — sandbox has py3.10) |
## Recent milestones (details in CHANGELOG.md)
@@ -35,6 +35,59 @@
- **v1.12.0** — Quality Scale conformance: runtime_data, test-before-setup, unloading,
single_config_entry, Store migrations hook, diagnostics, repairs, system health,
uninstall cleanup, HA-harness tests in CI, quality_scale.yaml.
- **v1.13.0** — universality: floors-import wizard, editable icon rules (+device_class
fallback), tap actions with a security model, i18n dictionaries in JSON, light-theme pass.
- **v1.13.1** — distribution: synthetic-home demo GIF in the README, issue templates,
CONTRIBUTING.md, Discussions. Forum/Reddit drafts are in the user folder
(`posts_drafts.md`) awaiting manual posting.
- **v1.13.2** — audit round 3: buildDevices unit-test suite, multi-placeholder t(),
conflict resync in _saveConfigNow, pointercancel long-press fix, repairs re-check
on config save (repairs.py).
- **v1.13.3** — privacy: legacy real-house assets/ removed; README screenshots synthetic.
- **v1.14.0** — per-space display settings (borders/names/color/opacity/fills),
draggable room labels, hand-drawn spaces (no image required), demo/ harness in-repo,
docs/TESTING.md manual checklist (update with every functional change!).
- **v1.15.0** — temperature room fill (blue/green/yellow) with editable comfort bounds.
- **v1.15.1** — display-settings UX: radio fill selector, inline compact bounds
(with the Number('')→0 bound-collapse bug fixed), avg room temp in the tooltip,
darken-on-hover, wider space dialog.
- **v1.15.2** — fix: average room temperature (fill + tooltip) counted non-thermometers
(fridges/TRVs/chip `*_device_temperature`/diagnostic); now thermometer/air-monitor only.
- **v1.15.3** — fix: device icon badge sat 1 px off its anchor (content-box + 1 px
border); `box-sizing: border-box` centres it exactly on the device point.
- **v1.15.4** — fix: real `ha-icon` (block + big line-height) put the glyph ~1.8 px low;
`.dev ha-icon` is now a zero-line-height flex box. Reverted v1.15.3 border-box (shrank the
badge). Verified live. Demo stub made faithful so the smoke guards it.
- **v1.15.5** — fix: room hover was always grey; legacy overlay/yard hover rules scoped
with `:not(.styled)` so filled rooms darken their fill, unfilled ones grey.
- **v1.15.6** — room hover also reveals the border (stroke colour kept, hidden via
opacity) even when borders are off.
- **v1.16.0** — NEW read-only `houseplan-space-card` (static single-space schematic,
pointer-events:none, deep-link button) + `#space=<id>` deep-link in the full card; shared
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
@@ -47,13 +100,22 @@
## Open items / watchlist
1. **hacs/default PR #8995** — waiting for moderation (bot may draft the PR for minor fixes —
fix and re-ready; wrongly filled template ⇒ silent close).
1. **hacs/default PR #9004** — accepted by the bot into the review queue ('New default
repository' label). Minor issues ⇒ the bot drafts the PR (fix and re-ready).
2. GitHub PAT `houseplan-card-publish` (repo+workflow) expires ~2026-07-12; in sandbox
`~/.git-credentials`. Revoke after the HACS queue clears, or re-issue when needed.
3. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
3. Privacy: legacy real-house plan sources (`assets/`) removed from the tree in
v1.13.3, but they persist in git history and old release archives; 8 README
screenshots in docs/images are still from the real house (replacement with
synthetic ones is a standing watchlist item). History rewrite deliberately NOT
done — it would break existing release tags/HACS installs.
4. Stale files on the mount that cannot be deleted from the sandbox: `src/data/` leftovers,
`brand_preview.png`, old nested bundle copies — ignore, git is authoritative.
4. Roadmap next: see ROADMAP.md (quality-scale conformance is the next big theme).
4. Roadmap: phases 7–10 are DONE (v1.12.0 quality scale, v1.13.0 universality,
v1.13.1 distribution). Next candidates: replace the remaining real-house README
screenshots with synthetic ones; measure backend coverage (>95% goal); mypy strict.
5. The demo harness lives in /tmp/demo (synthetic home: demo.html + capture.mjs) —
rebuildable from this repo + docs/DEVELOPMENT.md notes; frames → PIL → GIF.
## How to resume work in a fresh session (checklist)
+250
View File
@@ -0,0 +1,250 @@
# Manual testing checklist
> **Policy:** this checklist is updated **in the same commit** as any functional
> change (like CHANGELOG.md). Every release: run at least the smoke column on the
> synthetic demo (`demo/`), and the full list before major releases. Items marked
> `[auto]` are covered by unit tests or the headless smokes in `demo/` — they still
> deserve an occasional eyeball. File every failure as a GitHub issue before fixing.
## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
- [ ] Chrome / Edge (desktop, Windows or Linux)
- [ ] Firefox (desktop) — SVG viewBox math and container queries differ historically
- [ ] Safari (macOS) — pointer events / pinch behavior
- [ ] HA Companion app, Android (cold start! the v1.7.2 race lived here)
- [ ] HA Companion app, iOS
- [ ] Tablet in kiosk/panel mode (wall-tablet scenario, landscape)
- [ ] Phone portrait, narrow ≤400 px (adaptive header ≤620 px)
- [ ] Dark theme and light theme (badges, dialogs, plan contrast)
- [ ] RU profile locale and EN profile locale (+ `language:` card option forcing each)
## Installation / upgrade / removal
- [ ] Fresh install via HACS custom repository → integration appears, card auto-registers as a Lovelace resource (`?v=` matches manifest); no manual resource setup
- [ ] `single_config_entry`: adding a second entry is impossible [auto]
- [ ] Upgrade via HACS: `?v=` bumps after HA restart, browser picks the new bundle without cache clearing
- [ ] YAML-mode Lovelace: falls back to `extra_module_url` (card loads)
- [ ] Removal: delete entry → Lovelace resource entry disappears; `.storage/houseplan.*` survives; reinstall picks the old config up
- [ ] Diagnostics download works; personal fields (name/link/description/pdfs) are `**REDACTED**` [auto]
## Onboarding ★
- [ ] Empty config, HA has floors → floors-import wizard offers them sorted by level [auto]
- [ ] Wizard: uncheck all → "Create" disabled; "Start from scratch" → classic dialog
- [ ] Wizard: N floors → space dialog per floor with prefilled name and progress "i of N"; Skip skips one; Cancel aborts the whole queue [auto]
- [ ] After the last wizard space (or first manual space) → markup mode auto-opens with a toast
- [ ] Empty config, no floors → classic "New space" dialog auto-opens once per session
- [ ] All floors skipped, nothing created → empty state with "Add space" button remains usable
## Spaces ★
- [ ] Create with an image (SVG, PNG, JPG, WebP) → correct aspect, crisp at zoom (SVG)
- [ ] Oversized plan (>8 MB) → readable error toast, dialog stays open
- [ ] Create with "No image — I'll outline rooms by hand": orientation landscape/portrait/square respected [auto]; borders+names default ON [auto]
- [ ] Draw-space (no background) renders a WHITE canvas (paper-like), markup works on it; room borders/names stay legible on white [auto]
- [ ] Edit: rename; replace image; **switch image→draw detaches the plan** [auto]
- [ ] Delete space with rooms/devices → tab disappears, layout of other spaces untouched
- [ ] Display settings: borders toggle, names toggle, color picker + opacity slider live-preview after save, fill selector [auto]
- [ ] Fill "zigbee": rooms tint red→green by average LQI; rooms without zigbee stay unfilled [auto]
- [ ] Fill "lights": yellow when any light on, grey when all off, unfilled when the room has no lights [auto]; toggling a light from the plan recolors the room
- [ ] Fill "temperature": blue below the comfort range, green inside, yellow above [auto]; comfort bounds editable inline on the radio row (swapped bounds tolerated [auto], clearing a field cannot zero a bound [auto]); rooms without a temperature reading stay unfilled [auto]
- [ ] Fill mode is a radio group (no dropdown); labels carry no color legend
- [ ] Room hover darkens the current fill (no recolor to blue); unfilled rooms hover light grey
- [ ] Room tooltip shows the average room temperature when any thermometer reports [auto]
- [ ] 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
- [ ] Settings persist across reload and other browsers (server-side)
## Room markup editor ★
- [ ] 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
- [ ] "No area" room (decorative) requires a name; saves with `area: null`
- [ ] Cancel in the dialog reopens the contour (last point undone)
- [ ] Saving a room with an area: area devices appear with icons; positions are fixed into the layout [auto]
- [ ] Erase tool removes exactly the clicked line; Delete-room removes the polygon after confirm
- [ ] Device icons hidden during markup; visible again on exit
## Devices on the plan ★
- [ ] Auto devices appear only in rooms bound to their area [auto]
- [ ] Curation hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [auto]
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [auto]
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [auto]
- [ ] Drag anywhere (no edit mode), snaps to grid, persists after reload, per space
- [ ] ↺ reset restores auto layout after confirm
- [ ] Temperature badge on thermometers; LQI value under zigbee icons with red→green color
- [ ] Live states: light on = yellow, open cover/lock/door = orange, unavailable = faded
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
## Device dialog (markers) ★
- [ ] Open via info card → Edit; all fields persist (name, icon, model, link, description)
- [ ] Rebind to another device/entity/helper: search filters; already-placed candidates excluded; old position cleaned up [auto backend]
- [ ] Virtual device: requires name; room required; renders dashed
- [ ] Room override moves the icon to the room center
- [ ] Tap-action override select (default/info/more-info/toggle) saves and applies
- [ ] PDF/manual upload: ok path; >50 MB → readable error; .exe → bad-ext error [auto backend]; traversal names sanitized [auto backend]
- [ ] `javascript:` in the link field is not rendered as a clickable link [auto]
- [ ] Remove: auto device → hidden marker (reappears via dialog "show all"? no — stays hidden until re-added); virtual → gone incl. its layout entry [auto backend]
## Icon rules ★
- [ ] ⬡ opens the editor with current rules (defaults if none saved)
- [ ] Test field resolves live; add/delete/reorder rows; first match wins [auto]
- [ ] Invalid regex highlights red and is skipped at runtime (other rules still work) [auto]
- [ ] Reset to defaults; saving defaults stores nothing (settings key removed) [auto]
- [ ] Custom rules re-icon existing devices immediately; per-device icon override still wins; lock devices keep mdi:lock [auto]
- [ ] Rules survive reload; second browser sees them after live-sync
## Tap actions & gestures ★
- [ ] Default: tap → info card; card option `toggle`: tap toggles lights/switches/fans/humidifiers only [auto]
- [ ] Locks/alarms never toggle, even with per-device override [auto]; covers/valves toggle only with explicit per-device override [auto]
- [ ] Long-press (600 ms) always opens the info card, also when tap=toggle [auto]
- [ ] Drag > 3 px cancels both tap and long-press; pinch/pan never triggers taps
- [ ] `pointercancel` (touch interrupted) does not leave a phantom info card [auto]
## Zoom / pan / labels
- [ ] Wheel zoom at cursor; +/− buttons; fit button resets; badge shows %
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
- [ ] Zoom level persists per space (localStorage), restored on reload
- [ ] Window resize / sidebar collapse refits without distortion
- [ ] Room name labels: default at room center; dragging moves and persists (server layout, `rl_*`) [auto]; hidden in markup mode
- [ ] Labels legible on light and dark plans (no text shadow) at min/max zoom
## Multi-client & concurrency ★
- [ ] Two browser tabs: drag in A → position appears in B without reload (live event)
- [ ] Config edit collision: stale tab saving gets a conflict toast, auto-resyncs, retry works [auto backend]
- [ ] Point layout updates from two windows don't overwrite each other's icons [auto backend]
- [ ] `admin_only` ON: non-admin user gets readable "administrators only" errors on every write path
## Edge cases
- [ ] HA instance with zero devices/areas → onboarding works, rooms can be drawn, no crashes [auto]
- [ ] Space with zero rooms → renders; markup hint visible
- [ ] Room without area + borders ON → drawn, click does nothing, no area tooltip signal
- [ ] No zigbee devices anywhere → no LQI badges, lqi fill leaves all rooms unfilled [auto]
- [ ] 100+ devices in one space → build under ~50 ms [auto], drag stays smooth
- [ ] Very long device/room names → ellipsis/wrap, no layout explosion
- [ ] HTML/emoji in names (`<b>xss</b>`, 🚿) → rendered as text, never as markup [auto]
- [ ] Plan file deleted from disk → Repairs issue appears after config save/restart; re-upload clears it [auto backend]
- [ ] Corrupted `.storage/houseplan.config` → entry retries (ConfigEntryNotReady), no crash loop [auto backend]
- [ ] HA restart while a dialog is open → next save gets a clean error/conflict, no data loss
- [ ] Legacy layout entries (v1 {x,y} without space) are ignored gracefully
- [ ] Kiosk cold start on mobile app: card defined before dashboard render (resource registration)
## Release regression quickies
- [ ] Browser console has zero errors from houseplan-card.js on: dashboard load, markup, dialogs, zoom
- [ ] HA log has zero houseplan errors/warnings after restart
- [ ] `npm test` (frontend), `pytest tests_backend` (pure), CI HA-harness — all green
- [ ] README screenshots/GIF still match the current UI (synthetic home only)
---
## 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:
1. Edit dialog: switching an existing space from image to "draw" kept the old
background (`plan_url` not detached) — fixed.
2. `_stateClass` crashed on state objects without `entity_id` (domain is now
derived from `d.primary`, which the state was looked up by) — fixed; found by
the 150-device perf item of this checklist.
3. Perf item measured: 162 devices build in ~14 ms, re-render ~1 ms — well within budget.
4. (earlier rounds) long-press phantom after `pointercancel`; `_saveConfigNow`
conflict without resync — fixed in v1.13.2.
Unchecked boxes above (real browsers/devices, multi-tab live sync, Companion apps)
require hands on real hardware — they remain for the human pass.
## houseplan-space-card (read-only embedded)
- [ ] `type: custom:houseplan-space-card, space: <id>` renders the space identical to the full
card's plan (background + configured borders/names + room fills + icons), no header/controls [auto]
- [ ] The schematic is fully non-interactive: click/hover anywhere does nothing — no more-info,
no tooltip, no drag (`.hp-static-stage` is pointer-events:none) [auto]
- [ ] Footer button opens the full component already showing that space (deep-link `#space=<id>`) [auto]
- [ ] Several cards with different `space` coexist on one board; one shared config WS request
- [ ] 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

+107 -3
View File
@@ -1,20 +1,24 @@
{
"name": "houseplan-card",
"version": "1.0.0",
"version": "1.23.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.0.0",
"version": "1.23.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3"
"lit": "^3.1.3",
"polyclip-ts": "^0.16.8"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"rollup": "^4.18.0",
"tslib": "^2.6.2",
"typescript": "^5.4.5"
@@ -85,6 +89,34 @@
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
@@ -566,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",
@@ -719,6 +760,63 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"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==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"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",
@@ -858,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",
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.12.0",
"version": "1.24.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
@@ -8,17 +8,21 @@
"build": "tsc --noEmit && rollup -c",
"watch": "rollup -c --watch",
"typecheck": "tsc --noEmit",
"test": "tsc -p tsconfig.test.json && node --test test/*.test.mjs"
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs"
},
"devDependencies": {
"@mdi/js": "^7.4.47",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.6",
"playwright": "^1.61.1",
"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"
}
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import typescript from '@rollup/plugin-typescript';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
@@ -9,5 +10,5 @@ export default {
format: 'es',
sourcemap: false,
},
plugins: [resolve(), typescript(), terser({ format: { comments: false } })],
plugins: [resolve(), json(), typescript(), terser({ format: { comments: false } })],
};
+8
View File
@@ -0,0 +1,8 @@
// tsc keeps extensionless relative imports; Node ESM requires explicit ".js".
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
for (const f of readdirSync('test-build')) {
if (!f.endsWith('.js')) continue;
const p = `test-build/${f}`;
const s = readFileSync(p, 'utf8').replace(/from\s+'(\.\/[\w-]+)'/g, "from '$1.js'");
writeFileSync(p, s);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Module-level houseplan config cache shared by every embedded card on a board,
* so N cards do NOT issue N identical `houseplan/config/get` requests. The cache
* is invalidated when the integration emits `houseplan_config_updated`
* (fired on config/set); subscribers are then notified to reload.
*
* The full `houseplan-card` already persists a `{config, rev, layout}` snapshot in
* localStorage (`houseplan_card_cfg_v1`) for its own instant start — we seed from it
* so embedded cards paint immediately, then refresh from the server in the background.
*/
const LS_CFG = 'houseplan_card_cfg_v1';
export interface HpConfigSnapshot {
config: any | null;
rev: number;
layout: Record<string, any>;
}
let cache: HpConfigSnapshot | null = null;
let inflight: Promise<HpConfigSnapshot> | null = null;
let subscribed = false;
const listeners = new Set<() => void>();
/** Instant, synchronous best-effort snapshot from the full card's localStorage cache. */
export function cachedSnapshot(): HpConfigSnapshot | null {
if (cache) return cache;
try {
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
if (c && c.config && Array.isArray(c.config.spaces)) {
return { config: c.config, rev: c.rev || 0, layout: c.layout || {} };
}
} catch {
/* ignore */
}
return null;
}
async function fetchFresh(hass: any): Promise<HpConfigSnapshot> {
const [cfgResp, layResp] = await Promise.all([
hass.callWS({ type: 'houseplan/config/get' }),
hass.callWS({ type: 'houseplan/layout/get' }),
]);
cache = {
config: cfgResp?.config ?? null,
rev: cfgResp?.rev ?? 0,
layout: layResp?.layout ?? {},
};
if (!subscribed && hass.connection?.subscribeEvents) {
subscribed = true;
try {
await hass.connection.subscribeEvents(() => {
cache = null; // invalidate; listeners reload
listeners.forEach((l) => l());
}, 'houseplan_config_updated');
} catch {
subscribed = false;
}
}
return cache;
}
/** Get the shared config snapshot (cached, deduped across cards). */
export function getConfig(hass: any): Promise<HpConfigSnapshot> {
if (cache) return Promise.resolve(cache);
if (inflight) return inflight;
inflight = fetchFresh(hass).finally(() => {
inflight = null;
});
return inflight;
}
/** Subscribe to config-changed notifications; returns an unsubscribe function. */
export function onConfigChange(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
+103 -6
View File
@@ -2,7 +2,7 @@
* Building the device list from HA registries: curation, light groups,
* markers (overrides/virtual). No Lit/DOM — only the hass object.
*/
import { iconFor, DOMAIN_PRIORITY } from './rules';
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule } from './rules';
import { averageLqi } from './logic';
import type { DevItem, Marker, ServerConfig } from './types';
@@ -18,6 +18,8 @@ export interface BuildCtx {
firstSpaceId: string;
/** Localized display strings for generated device names. */
loc: (key: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => string;
/** Compiled icon rules (instance overrides or built-in defaults). */
iconRules?: CompiledIconRule[];
}
export function entitiesByDevice(hass: any): Record<string, string[]> {
@@ -38,6 +40,10 @@ export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
}
export function isTempEntity(hass: any, eid: string): boolean {
// Температура чипа самого устройства — это диагностика, не комнатная температура.
if (/_device_temperature$/.test(eid)) return false;
// Диагностические/конфигурационные сущности (чип, батарея, калибровки) — не комната.
if (hass.entities?.[eid]?.entity_category) return false;
const st = hass.states[eid];
if (!st) return /_temperature$/.test(eid);
const a = st.attributes || {};
@@ -98,6 +104,27 @@ export function tempFor(hass: any, entIds: string[]): number | null {
return null;
}
/** A humidity-measuring entity (device_class humidity or *_humidity), excluding diagnostics. */
export function isHumEntity(hass: any, eid: string): boolean {
if (hass.entities?.[eid]?.entity_category) return false;
const st = hass.states[eid];
if (!st) return /_humidity$/.test(eid);
const a = st.attributes || {};
return a.device_class === 'humidity' || (a.unit_of_measurement === '%' && /_humidity$/.test(eid)) || /_humidity$/.test(eid);
}
/** First readable humidity value (integer %) among the entities, or null. */
export function humFor(hass: any, entIds: string[]): number | null {
for (const eid of entIds) {
if (!isHumEntity(hass, eid)) continue;
const st = hass.states[eid];
if (!st) continue;
const v = parseFloat(st.state);
if (!isNaN(v)) return Math.round(v);
}
return null;
}
/** Group light entities: HA light-group (platform=group) and Z2M groups (device model=Group). */
export function lightGroups(hass: any, enabled: boolean): { eid: string; name: string; area: string }[] {
if (!enabled) return [];
@@ -121,6 +148,18 @@ export function lightGroups(hass: any, enabled: boolean): { eid: string; name: s
return res;
}
/** Icon with the full fallback chain: name rules → entity device_class → chip. */
function resolveIcon(hass: any, name: string, model: string | undefined, entIds: string[], rules?: CompiledIconRule[]): string {
const byRules = iconFor(name, model, rules);
if (byRules !== FALLBACK_ICON) return byRules;
const classes: string[] = [];
for (const eid of entIds) {
const dc = hass.states[eid]?.attributes?.device_class;
if (dc) classes.push(dc);
}
return iconFromDeviceClasses(classes) ?? FALLBACK_ICON;
}
function applyMarker(item: DevItem, m: Marker): void {
item.marker = m;
if (m.name) item.name = m.name;
@@ -129,11 +168,12 @@ function applyMarker(item: DevItem, m: Marker): void {
item.link = m.link ?? null;
item.description = m.description ?? null;
item.pdfs = m.pdfs || [];
item.tapAction = m.tap_action ?? null;
}
/** Curation + light groups + markers (metadata/rebinding) + virtual ones. A hybrid. */
export function buildDevices(ctx: BuildCtx): DevItem[] {
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc } = ctx;
const { hass: h, areaToSpace, markers, settings, excluded, showAll, firstSpaceId, loc, iconRules } = ctx;
const groupLights = settings.group_lights !== false;
const groups = lightGroups(h, groupLights);
const groupedAreas = new Set(groups.map((g) => g.area));
@@ -167,7 +207,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
}
const name = (dev.name_by_user || dev.name || loc('device.unnamed')).trim();
const key = name + '|' + area;
let icon = iconFor(name, dev.model);
let icon = resolveIcon(h, name, dev.model, entIds, iconRules);
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
if (!showAll && groupLights && icon === 'mdi:lightbulb' && groupedAreas.has(area)) continue;
// duplicates by “name|zone” are numbered rather than hidden
@@ -187,6 +227,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
rest.push(item);
}
@@ -218,7 +259,9 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const area = m.area || dev?.area_id || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const entIds = dev ? entsBy[dev.id] || [] : [];
let icon = dev ? iconFor(dev.name_by_user || dev.name || '', dev.model) : 'mdi:help-circle';
let icon = dev
? resolveIcon(h, dev.name_by_user || dev.name || '', dev.model, entIds, iconRules)
: 'mdi:help-circle';
if (entIds.some((e) => e.startsWith('lock.'))) icon = 'mdi:lock';
const item: DevItem = {
id: m.id,
@@ -233,6 +276,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
};
item.primary = primaryEntity(h, entIds, icon);
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
if (item.primary && isHumEntity(h, item.primary)) item.hum = humFor(h, entIds);
applyMarker(item, m);
rest.push(item);
} else if (kind === 'entity') {
@@ -240,18 +285,23 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
const area = m.area || reg?.area_id || (reg?.device_id && h.devices[reg.device_id]?.area_id) || '';
const space = (area && areaToSpace[area]) || m.space || firstSpaceId;
const st = h.states[ref];
const nm = reg?.name || st?.attributes?.friendly_name || ref;
let icon = resolveIcon(h, nm, '', [ref], iconRules);
if (ref.startsWith('lock.')) icon = 'mdi:lock';
const item: DevItem = {
id: m.id,
name: reg?.name || st?.attributes?.friendly_name || ref,
name: nm,
model: '',
area,
space,
icon: 'mdi:shape-outline',
icon,
entities: [ref],
primary: ref,
bindingKind: 'entity',
bindingRef: ref,
};
if (icon === 'mdi:thermometer' || icon === 'mdi:air-filter') item.temp = tempFor(h, [ref]);
if (isHumEntity(h, ref)) item.hum = humFor(h, [ref]);
applyMarker(item, m);
rest.push(item);
} else {
@@ -275,3 +325,50 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
}
return rest;
}
/**
* Light situation of an area: 'on' if any light entity of the area's devices is on,
* 'off' if lights exist but none is on, 'none' when the area has no lights at all.
*/
export function areaLights(hass: any, devices: { area: string; entities: string[] }[], area: string): 'on' | 'off' | 'none' {
let seen = false;
for (const d of devices) {
if (d.area !== area) continue;
for (const eid of d.entities) {
if (!eid.startsWith('light.')) continue;
seen = true;
if (hass.states[eid]?.state === 'on') return 'on';
}
}
return seen ? 'off' : 'none';
}
/** 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 {
const vals: number[] = [];
for (const d of devices) {
if (d.area !== area || d.virtual) continue;
const l = lqiFor(hass, d.entities);
if (l != null) vals.push(l);
}
return averageLqi(vals);
}
export function areaTemp(
hass: any,
devices: { area: string; icon?: string; entities: string[] }[],
area: string,
): number | null {
const vals: number[] = [];
for (const d of devices) {
if (d.area !== area) continue;
// Учитываем только устройства, которые сама карточка считает термометрами —
// не холодильники, термоголовки, розетки с температурой чипа и т.п.
if (d.icon !== 'mdi:thermometer' && d.icon !== 'mdi:air-filter') continue;
const t = tempFor(hass, d.entities);
if (t != null) vals.push(t);
}
if (!vals.length) return null;
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
}
+14
View File
@@ -63,6 +63,19 @@ 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: {} } },
@@ -78,6 +91,7 @@ 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'),
+1466 -147
View File
File diff suppressed because it is too large Load Diff
+11 -297
View File
@@ -1,315 +1,29 @@
/**
* Card UI localization. Language is resolved from the card config
* (`language: en|ru`) or, by default, from the HA user profile
* (hass.locale.language); anything that is not Russian falls back to English.
* Card UI localization. Dictionaries live in src/i18n/<lang>.json so that new
* languages can be contributed without touching TypeScript. The language is
* resolved from the card config (`language: en|ru`) or, by default, from the
* HA user profile (hass.locale.language); anything that is not a known
* language falls back to English.
*/
import { subst } from './logic';
import en from './i18n/en.json';
import ru from './i18n/ru.json';
export type Lang = 'en' | 'ru';
const en = {
'card.title': 'House plan',
'count.devices': '{n} dev.',
'empty.no_spaces': 'No spaces yet.',
'empty.add_first': 'Add the first space and upload a floor plan.',
'empty.install': 'Install the House Plan integration and add it in "Devices & services".',
'btn.add_space': 'Add space',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.close': 'Close',
'btn.delete': 'Delete',
'btn.remove': 'Remove',
'btn.edit': 'Edit',
'btn.open_in_ha': 'Open in HA',
'btn.reset': 'Reset',
'btn.attach': 'Attach…',
'btn.upload': 'Upload…',
'btn.replace': 'Replace…',
'btn.no_area': 'No area',
'title.zoom_in': 'Zoom in',
'title.zoom_out': 'Zoom out',
'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_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.delete': 'Delete',
'markup.hint_points': 'points: {n} · Esc/Ctrl+Z — undo a dot · close the outline by clicking the first one',
'markup.hint_start': 'click a grid dot to start the outline',
'tip.room': 'room — open the area',
'tip.lqi': 'average zigbee signal:',
'info.device_header': 'Device on the plan',
'info.model': 'Model',
'info.state': 'State',
'info.link': 'Link',
'info.manuals': 'Manuals',
'info.none': 'No additional information',
'marker.new_device': 'New device',
'marker.name_label': 'Name (shown on the plan)',
'marker.name_ph': 'Name',
'marker.binding_label': 'Bind to an HA device',
'marker.virtual_option': 'Virtual device (no binding)',
'marker.search_ph': 'Search device / group…',
'marker.nothing_found': 'nothing found',
'marker.room_label': 'Room',
'marker.room_override': ' (override placement)',
'marker.room_choose': '— select a room —',
'marker.room_auto': '— by device area (auto) —',
'marker.icon_label': 'Icon',
'marker.icon_ph': 'mdi:… (empty = auto)',
'marker.model_label': 'Model',
'marker.model_ph': 'e.g. Aqara T&H',
'marker.link_label': 'Link',
'marker.desc_label': 'Description',
'marker.desc_ph': 'Notes, specs…',
'marker.manuals_label': 'Manuals (PDF etc.)',
'marker.sub_device': 'device',
'marker.sub_z2m_group': ' · Z2M group',
'marker.sub_group': 'group',
'marker.sub_helper': 'helper',
'space.new': 'New space',
'space.header': 'Space',
'space.title_label': 'Title',
'space.title_ph': 'e.g. Garage',
'space.plan_label': 'Floor plan (background)',
'space.no_plan': 'no plan image',
'space.plan_alt': 'plan',
'room.new': 'New room',
'room.name_label': 'Display name',
'room.name_ph': 'e.g. Terrace',
'room.area_label': 'Home Assistant area (unassigned)',
'room.no_area_option': '— no area —',
'room.default_name': 'Room',
'device.unnamed': 'unnamed',
'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?',
'toast.pos_save_failed': 'Failed to save position: {err}',
'toast.no_entity': 'The device has no suitable entity',
'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_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',
'toast.virtual_name_required': 'Enter a name for the virtual device',
'toast.marker_saved': 'Device saved',
'toast.marker_removed': 'Device removed from the plan',
'toast.integration_missing': 'The House Plan integration is not installed — management unavailable',
'toast.plan_formats': 'Supported formats: SVG, PNG, JPG, WebP',
'toast.plan_required': 'Upload a floor plan — it is required',
'toast.space_added_onboard': 'Space added. Outline the rooms: click grid dots and close the contour.',
'toast.space_added': 'Space added',
'toast.space_saved': 'Space saved',
'toast.space_deleted': 'Space deleted',
'toast.delete_failed': 'Delete failed: {err}',
'toast.error': 'Error: {err}',
'toast.file_failed': 'File "{name}" was not uploaded: {err}',
'toast.files_attached': 'Files attached: {n}',
'err.unknown': 'unknown error',
'err.code': 'code {code}',
'err.too_large': 'file larger than {mb} MB',
'err.bad_ext': 'unsupported type (PDF/image expected)',
'err.unauthorized': 'administrator rights required',
'editor.title': 'Title',
'editor.default_floor': 'Default space',
'editor.icon_size': 'Icon size, % of plan width',
'editor.show_temperature': 'Show temperature',
'editor.live_states': 'Live states (on/off, open…)',
'editor.show_signal': 'Show zigbee signal (LQI)',
'editor.language': 'Interface language',
'editor.lang_auto': 'Auto (HA profile)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
type Key = keyof typeof en;
const ru: Record<Key, string> = {
'card.title': 'План дома',
'count.devices': '{n} устр.',
'empty.no_spaces': 'Пространств пока нет.',
'empty.add_first': 'Добавьте первое пространство и загрузите план этажа.',
'empty.install': 'Установите интеграцию House Plan и добавьте запись в «Устройства и службы».',
'btn.add_space': 'Добавить пространство',
'btn.cancel': 'Отмена',
'btn.save': 'Сохранить',
'btn.close': 'Закрыть',
'btn.delete': 'Удалить',
'btn.remove': 'Убрать',
'btn.edit': 'Редактировать',
'btn.open_in_ha': 'Открыть в HA',
'btn.reset': 'Сброс',
'btn.attach': 'Прикрепить…',
'btn.upload': 'Загрузить…',
'btn.replace': 'Заменить…',
'btn.no_area': 'Без зоны',
'title.zoom_in': 'Приблизить',
'title.zoom_out': 'Отдалить',
'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_delroom': 'Удалить комнату: клик внутри комнаты',
'title.no_area_room': 'Декоративная комната без привязки к зоне (например, холл)',
'title.choose_area': 'Выберите зону Home Assistant',
'title.need_plan': 'Загрузите подложку (план этажа)',
'markup.add': 'Добавить',
'markup.erase': 'Стереть',
'markup.delete': 'Удалить',
'markup.hint_points': 'точек: {n} · Esc/Ctrl+Z — убрать точку · замкните контур кликом по первой',
'markup.hint_start': 'кликните точку сетки, чтобы начать контур',
'tip.room': 'комната — открыть зону',
'tip.lqi': 'средний сигнал zigbee:',
'info.device_header': 'Устройство на плане',
'info.model': 'Модель',
'info.state': 'Состояние',
'info.link': 'Ссылка',
'info.manuals': 'Инструкции',
'info.none': 'Нет дополнительной информации',
'marker.new_device': 'Новое устройство',
'marker.name_label': 'Имя (отображается на плане)',
'marker.name_ph': 'Название',
'marker.binding_label': 'Привязка к устройству HA',
'marker.virtual_option': 'Виртуальное устройство (без привязки)',
'marker.search_ph': 'Поиск устройства / группы…',
'marker.nothing_found': 'ничего не найдено',
'marker.room_label': 'Комната',
'marker.room_override': ' (переопределить размещение)',
'marker.room_choose': '— выберите комнату —',
'marker.room_auto': '— по зоне устройства (авто) —',
'marker.icon_label': 'Иконка',
'marker.icon_ph': 'mdi:… (пусто = авто)',
'marker.model_label': 'Модель',
'marker.model_ph': 'напр. Aqara T&H',
'marker.link_label': 'Ссылка',
'marker.desc_label': 'Описание',
'marker.desc_ph': 'Заметки, характеристики…',
'marker.manuals_label': 'Инструкции (PDF и т.п.)',
'marker.sub_device': 'устройство',
'marker.sub_z2m_group': ' · Z2M-группа',
'marker.sub_group': 'группа',
'marker.sub_helper': 'хелпер',
'space.new': 'Новое пространство',
'space.header': 'Пространство',
'space.title_label': 'Название',
'space.title_ph': 'Например: Гараж',
'space.plan_label': 'Подложка (план)',
'space.no_plan': 'нет подложки',
'space.plan_alt': 'план',
'room.new': 'Новая комната',
'room.name_label': 'Отображаемое имя',
'room.name_ph': 'Например: Терраса',
'room.area_label': 'Зона Home Assistant (свободные)',
'room.no_area_option': '— без зоны —',
'room.default_name': 'Комната',
'device.unnamed': 'без имени',
'device.light_group': 'группа света',
'device.fallback': 'устройство',
'device.virtual': 'виртуальное устройство',
'confirm.reset_layout': 'Сбросить позиции всех иконок к авто-раскладке?',
'confirm.delete_room': 'Удалить комнату «{name}»?',
'confirm.remove_marker': 'Убрать «{name}» с плана?',
'confirm.delete_space': 'Удалить пространство «{title}» со всеми комнатами и разметкой?',
'toast.pos_save_failed': 'Не удалось сохранить позицию: {err}',
'toast.no_entity': 'У устройства нет подходящей сущности',
'toast.markup_needs_server': 'Разметка доступна после переноса конфига на сервер',
'toast.conflict': 'Конфиг изменён в другом окне — данные обновлены, повторите последнее действие',
'toast.cfg_save_failed': 'Не удалось сохранить конфиг: {err}',
'toast.room_saved': 'Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.',
'toast.room_saved_no_area': 'Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.',
'toast.marker_needs_server': 'Редактирование устройств доступно после переноса конфига на сервер',
'toast.virtual_name_required': 'Укажите имя виртуального устройства',
'toast.marker_saved': 'Устройство сохранено',
'toast.marker_removed': 'Устройство убрано с плана',
'toast.integration_missing': 'Интеграция House Plan не установлена — управление недоступно',
'toast.plan_formats': 'Поддерживаются SVG, PNG, JPG, WebP',
'toast.plan_required': 'Загрузите подложку — план этажа обязателен',
'toast.space_added_onboard': 'Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.',
'toast.space_added': 'Пространство добавлено',
'toast.space_saved': 'Пространство сохранено',
'toast.space_deleted': 'Пространство удалено',
'toast.delete_failed': 'Ошибка удаления: {err}',
'toast.error': 'Ошибка: {err}',
'toast.file_failed': 'Файл «{name}» не загружен: {err}',
'toast.files_attached': 'Прикреплено файлов: {n}',
'err.unknown': 'неизвестная ошибка',
'err.code': 'код {code}',
'err.too_large': 'файл больше {mb} МБ',
'err.bad_ext': 'недопустимый тип (нужен PDF/изображение)',
'err.unauthorized': 'нужны права администратора',
'editor.title': 'Заголовок',
'editor.default_floor': 'Пространство по умолчанию',
'editor.icon_size': 'Размер иконок, % ширины плана',
'editor.show_temperature': 'Показывать температуру',
'editor.live_states': 'Живые состояния (вкл/выкл, открыто…)',
'editor.show_signal': 'Показывать сигнал zigbee (LQI)',
'editor.language': 'Язык интерфейса',
'editor.lang_auto': 'Авто (профиль HA)',
'editor.lang_en': 'English',
'editor.lang_ru': 'Русский',
};
const DICTS: Record<Lang, Record<Key, string>> = { en, ru };
const DICTS: Record<Lang, Record<string, string>> = { en, ru };
/** Resolve the UI language: explicit config option wins, then the HA profile. */
export function langOf(hass: any, configLang?: string | null): Lang {
if (configLang === 'ru' || configLang === 'en') return configLang;
if (configLang && configLang in DICTS) return configLang as Lang;
const l = (hass?.locale?.language || hass?.language || 'en').toLowerCase();
return l.startsWith('ru') ? 'ru' : 'en';
}
/** Translate a key with optional {placeholder} substitution. */
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
let s = DICTS[lang][key] ?? en[key] ?? key;
if (vars) for (const [k, v] of Object.entries(vars)) s = s.replace('{' + k + '}', String(v));
return s;
return subst(DICTS[lang][key] ?? en[key] ?? key, vars);
}
export type { Key as I18nKey };
+243
View File
@@ -0,0 +1,243 @@
{
"card.title": "House plan",
"count.devices": "{n} dev.",
"empty.no_spaces": "No spaces yet.",
"empty.add_first": "Add the first space and upload a floor plan.",
"empty.install": "Install the House Plan integration and add it in \"Devices & services\".",
"btn.add_space": "Add space",
"btn.cancel": "Cancel",
"btn.save": "Save",
"btn.close": "Close",
"btn.delete": "Delete",
"btn.remove": "Remove",
"btn.edit": "Edit",
"btn.open_in_ha": "Open in HA",
"btn.reset": "Reset",
"btn.attach": "Attach…",
"btn.upload": "Upload…",
"btn.replace": "Replace…",
"btn.no_area": "No area",
"title.zoom_in": "Zoom in",
"title.zoom_out": "Zoom out",
"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_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.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",
"tip.room": "room — open the area",
"tip.lqi": "average zigbee signal:",
"info.device_header": "Device on the plan",
"info.model": "Model",
"info.state": "State",
"info.link": "Link",
"info.manuals": "Manuals",
"info.none": "No additional information",
"marker.new_device": "New device",
"marker.name_label": "Name (shown on the plan)",
"marker.name_ph": "Name",
"marker.binding_label": "Bind to an HA device",
"marker.virtual_option": "Virtual device (no binding)",
"marker.search_ph": "Search device / group…",
"marker.nothing_found": "nothing found",
"marker.room_label": "Room",
"marker.room_override": " (override placement)",
"marker.room_choose": "— select a room —",
"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",
"marker.desc_label": "Description",
"marker.desc_ph": "Notes, specs…",
"marker.manuals_label": "Manuals (PDF etc.)",
"marker.sub_device": "device",
"marker.sub_z2m_group": " · Z2M group",
"marker.sub_group": "group",
"marker.sub_helper": "helper",
"space.new": "New space",
"space.header": "Space",
"space.title_label": "Title",
"space.title_ph": "e.g. Garage",
"space.plan_label": "Floor plan (background)",
"space.no_plan": "no plan image",
"space.plan_alt": "plan",
"room.new": "New room",
"room.name_label": "Display name",
"room.name_ph": "e.g. Terrace",
"room.area_label": "Home Assistant area (unassigned)",
"room.no_area_option": "— no area —",
"room.default_name": "Room",
"device.unnamed": "unnamed",
"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?",
"toast.pos_save_failed": "Failed to save position: {err}",
"toast.no_entity": "The device has no suitable entity",
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.point_in_room": "That point is inside room “{name}” — rooms must not overlap",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”",
"toast.split_pick_wall": "Click a grid dot on the room’s wall",
"toast.split_bad_cut": "The cut must be a straight line from wall to wall, inside the room",
"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",
"toast.virtual_name_required": "Enter a name for the virtual device",
"toast.marker_saved": "Device saved",
"toast.marker_removed": "Device removed from the plan",
"toast.integration_missing": "The House Plan integration is not installed — management unavailable",
"toast.plan_formats": "Supported formats: SVG, PNG, JPG, WebP",
"toast.plan_required": "Upload a floor plan — it is required",
"toast.space_added_onboard": "Space added. Outline the rooms: click grid dots and close the contour.",
"toast.space_added": "Space added",
"toast.space_saved": "Space saved",
"toast.space_deleted": "Space deleted",
"toast.delete_failed": "Delete failed: {err}",
"toast.error": "Error: {err}",
"toast.file_failed": "File \"{name}\" was not uploaded: {err}",
"toast.files_attached": "Files attached: {n}",
"err.unknown": "unknown error",
"err.code": "code {code}",
"err.too_large": "file larger than {mb} MB",
"err.bad_ext": "unsupported type (PDF/image expected)",
"err.unauthorized": "administrator rights required",
"editor.title": "Title",
"editor.default_floor": "Default space",
"editor.icon_size": "Icon size, % of plan width",
"editor.show_temperature": "Show temperature",
"editor.live_states": "Live states (on/off, open…)",
"editor.show_signal": "Show zigbee signal (LQI)",
"editor.language": "Interface language",
"editor.lang_auto": "Auto (HA profile)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Icon rules: which MDI icon devices get by name",
"rules.title": "Icon rules",
"rules.hint": "Rules are checked top-down against “device name + model” (case-insensitive regex); the first match wins. When nothing matches, the entity device class decides, then the generic chip icon.",
"rules.pattern_ph": "regex, e.g. plug|socket",
"rules.icon_ph": "mdi:power-socket-de",
"rules.add": "Add rule",
"rules.reset": "Reset to defaults",
"rules.test_ph": "Try a device name…",
"rules.invalid": "invalid regex",
"rules.saved": "Icon rules saved",
"btn.up": "Up",
"btn.down": "Down",
"editor.tap_action": "Tap on a device",
"tap.info": "Info 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",
"import.hint": "Your Home Assistant already knows these floors. Pick the ones to turn into plan spaces — you will upload a floor-plan image for each one next. Rooms are then outlined by hand on the plan.",
"import.start": "Create {n} space(s)",
"import.manual": "Start from scratch",
"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)",
"space.room_color": "Border & name color",
"space.opacity": "Opacity",
"space.fill_label": "Room fill",
"fill.none": "None",
"fill.lqi": "Zigbee signal",
"fill.light": "Lights",
"space.source_file": "I have a floor-plan image",
"space.source_draw": "No image — I'll outline rooms by hand",
"space.orientation": "Canvas",
"orient.landscape": "Landscape",
"orient.portrait": "Portrait",
"orient.square": "Square",
"fill.temp": "Temperature",
"space.temp_min": "Comfort from",
"space.temp_max": "to",
"tip.temp_avg": "average temperature:",
"space_card.button": "Open the space plan",
"space_card.not_found": "Space “{id}” not found",
"space_card.loading": "Loading…",
"editor.space": "Space",
"editor.show_button": "Show button",
"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",
"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"
}
+243
View File
@@ -0,0 +1,243 @@
{
"card.title": "План дома",
"count.devices": "{n} устр.",
"empty.no_spaces": "Пространств пока нет.",
"empty.add_first": "Добавьте первое пространство и загрузите план этажа.",
"empty.install": "Установите интеграцию House Plan и добавьте запись в «Устройства и службы».",
"btn.add_space": "Добавить пространство",
"btn.cancel": "Отмена",
"btn.save": "Сохранить",
"btn.close": "Закрыть",
"btn.delete": "Удалить",
"btn.remove": "Убрать",
"btn.edit": "Редактировать",
"btn.open_in_ha": "Открыть в HA",
"btn.reset": "Сброс",
"btn.attach": "Прикрепить…",
"btn.upload": "Загрузить…",
"btn.replace": "Заменить…",
"btn.no_area": "Без зоны",
"title.zoom_in": "Приблизить",
"title.zoom_out": "Отдалить",
"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_merge": "Объединить комнаты: клик по одной, затем по соседней с общей стеной",
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
"title.markup_delroom": "Удалить комнату: клик внутри комнаты",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Добавить",
"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": "кликните точку сетки, чтобы начать контур",
"tip.room": "комната — открыть зону",
"tip.lqi": "средний сигнал zigbee:",
"info.device_header": "Устройство на плане",
"info.model": "Модель",
"info.state": "Состояние",
"info.link": "Ссылка",
"info.manuals": "Инструкции",
"info.none": "Нет дополнительной информации",
"marker.new_device": "Новое устройство",
"marker.name_label": "Имя (отображается на плане)",
"marker.name_ph": "Название",
"marker.binding_label": "Привязка к устройству HA",
"marker.virtual_option": "Виртуальное устройство (без привязки)",
"marker.search_ph": "Поиск устройства / группы…",
"marker.nothing_found": "ничего не найдено",
"marker.room_label": "Комната",
"marker.room_override": " (переопределить размещение)",
"marker.room_choose": "— выберите комнату —",
"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": "Ссылка",
"marker.desc_label": "Описание",
"marker.desc_ph": "Заметки, характеристики…",
"marker.manuals_label": "Инструкции (PDF и т.п.)",
"marker.sub_device": "устройство",
"marker.sub_z2m_group": " · Z2M-группа",
"marker.sub_group": "группа",
"marker.sub_helper": "хелпер",
"space.new": "Новое пространство",
"space.header": "Пространство",
"space.title_label": "Название",
"space.title_ph": "Например: Гараж",
"space.plan_label": "Подложка (план)",
"space.no_plan": "нет подложки",
"space.plan_alt": "план",
"room.new": "Новая комната",
"room.name_label": "Отображаемое имя",
"room.name_ph": "Например: Терраса",
"room.area_label": "Зона Home Assistant (свободные)",
"room.no_area_option": "— без зоны —",
"room.default_name": "Комната",
"device.unnamed": "без имени",
"device.light_group": "группа света",
"device.fallback": "устройство",
"device.virtual": "виртуальное устройство",
"confirm.reset_layout": "Сбросить позиции всех иконок к авто-раскладке?",
"confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.remove_marker": "Убрать «{name}» с плана?",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
"toast.pos_save_failed": "Не удалось сохранить позицию: {err}",
"toast.no_entity": "У устройства нет подходящей сущности",
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.point_in_room": "Точка внутри комнаты «{name}» — комнаты не должны накладываться",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»",
"toast.split_pick_wall": "Кликните по узлу сетки на стене комнаты",
"toast.split_bad_cut": "Разрез — прямая от стены до стены внутри комнаты",
"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": "Редактирование устройств доступно после переноса конфига на сервер",
"toast.virtual_name_required": "Укажите имя виртуального устройства",
"toast.marker_saved": "Устройство сохранено",
"toast.marker_removed": "Устройство убрано с плана",
"toast.integration_missing": "Интеграция House Plan не установлена — управление недоступно",
"toast.plan_formats": "Поддерживаются SVG, PNG, JPG, WebP",
"toast.plan_required": "Загрузите подложку — план этажа обязателен",
"toast.space_added_onboard": "Пространство добавлено. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"toast.space_added": "Пространство добавлено",
"toast.space_saved": "Пространство сохранено",
"toast.space_deleted": "Пространство удалено",
"toast.delete_failed": "Ошибка удаления: {err}",
"toast.error": "Ошибка: {err}",
"toast.file_failed": "Файл «{name}» не загружен: {err}",
"toast.files_attached": "Прикреплено файлов: {n}",
"err.unknown": "неизвестная ошибка",
"err.code": "код {code}",
"err.too_large": "файл больше {mb} МБ",
"err.bad_ext": "недопустимый тип (нужен PDF/изображение)",
"err.unauthorized": "нужны права администратора",
"editor.title": "Заголовок",
"editor.default_floor": "Пространство по умолчанию",
"editor.icon_size": "Размер иконок, % ширины плана",
"editor.show_temperature": "Показывать температуру",
"editor.live_states": "Живые состояния (вкл/выкл, открыто…)",
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
"editor.language": "Язык интерфейса",
"editor.lang_auto": "Авто (профиль HA)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Правила иконок: какая MDI-иконка достаётся устройству по имени",
"rules.title": "Правила иконок",
"rules.hint": "Правила проверяются сверху вниз по строке «имя устройства + модель» (regex без учёта регистра); срабатывает первое совпадение. Если ничего не подошло — решает device class сущности, затем — иконка-заглушка.",
"rules.pattern_ph": "regex, напр. розетк|plug",
"rules.icon_ph": "mdi:power-socket-de",
"rules.add": "Добавить правило",
"rules.reset": "Сбросить к умолчаниям",
"rules.test_ph": "Проверьте имя устройства…",
"rules.invalid": "некорректный regex",
"rules.saved": "Правила иконок сохранены",
"btn.up": "Вверх",
"btn.down": "Вниз",
"editor.tap_action": "Тап по устройству",
"tap.info": "Инфо-карточка",
"tap.more_info": "Диалог HA (more-info)",
"tap.toggle": "Переключить (свет/розетки)",
"tap.auto": "Как в настройках карточки",
"marker.tap_label": "Действие по тапу для этого устройства",
"tap.toggle_note": "Toggle никогда не применяется к замкам и сигнализациям; долгое нажатие всегда открывает инфо-карточку.",
"import.title": "Создать пространства из этажей HA",
"import.hint": "Home Assistant уже знает эти этажи. Отметьте, какие превратить в пространства плана — далее для каждого попросим картинку плана. Комнаты затем обводятся вручную по плану.",
"import.start": "Создать: {n}",
"import.manual": "Начать с нуля",
"import.progress": "Этаж {i} из {n}",
"import.done": "Пространства созданы. Обведите комнаты: кликайте по точкам сетки и замкните контур.",
"btn.skip": "Пропустить",
"space.scale_label": "Масштаб (размер клетки сетки)",
"space.scale_unit": "см на клетку",
"space.display_section": "Отображение",
"space.show_borders": "Всегда отображать границы комнат",
"space.show_names": "Отображать названия комнат (перетаскиваются)",
"space.room_color": "Цвет границ и названий",
"space.opacity": "Прозрачность",
"space.fill_label": "Заливка комнат",
"fill.none": "Нет",
"fill.lqi": "По силе зигби-сигнала",
"fill.light": "По освещению",
"space.source_file": "У меня есть картинка плана",
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
"space.orientation": "Холст",
"orient.landscape": "Альбомный",
"orient.portrait": "Портретный",
"orient.square": "Квадрат",
"fill.temp": "По температуре",
"space.temp_min": "Комфорт от",
"space.temp_max": "до",
"tip.temp_avg": "средняя температура:",
"space_card.button": "Перейти к пространству",
"space_card.not_found": "Пространство «{id}» не найдено",
"space_card.loading": "Загрузка…",
"editor.space": "Пространство",
"editor.show_button": "Показывать кнопку",
"editor.button_label": "Текст кнопки",
"editor.button_target": "Путь дашборда (куда вести)",
"editor.aspect_ratio": "Соотношение сторон (напр. 16:9 или auto)",
"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": "Нет источников света"
}
+540 -3
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,192 @@ 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;
}
/**
* Do two room outlines share floor area? Rooms must never overlap, but sharing a wall
* (fully or partially) and touching at a corner are normal and stay legal. A room nested
* inside another counts as an overlap.
*/
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;
return coversArea(a, b, eps) || coversArea(b, a, 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 {
if (!poly || poly.length < 3 || samePoint(a, b, eps)) return null;
const ia = edgeIndexOf(poly, a, eps);
const ib = edgeIndexOf(poly, b, eps);
if (ia < 0 || ib < 0) return null; // an end is not on a wall
for (let i = 0; i < poly.length; i++)
if (segmentsProperlyCross(a, b, poly[i], poly[(i + 1) % poly.length])) return null; // leaves the room
// a chord lying along a wall has its midpoint ON the outline, not inside it
if (!pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps)) return null;
const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
const pts: number[][] = [from];
let i = (fromIdx + 1) % poly.length;
for (let guard = 0; guard <= poly.length; guard++) {
pts.push(poly[i]);
if (i === toIdx) break;
i = (i + 1) % poly.length;
}
pts.push(to);
return dropRepeats(pts, eps);
};
const p1 = walk(a, ia, b, ib);
const p2 = walk(b, ib, a, ia);
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().
@@ -112,3 +413,239 @@ export function safeUrl(url: string | null | undefined): string | null {
}
return null;
}
// ---------------- tap actions ----------------
export type TapAction = 'info' | 'more-info' | 'toggle';
/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);
/**
* Domains that must NEVER toggle from a plan tap, even with an explicit
* per-device override: an accidental tap unlocking a door or disarming an
* alarm is a security incident, not a UX shortcut.
*/
export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel']);
/**
* Resolve the effective tap action for a device icon.
*
* Order: per-device override → card-wide default → 'info'.
* 'toggle' is applied conservatively: a card-wide toggle only affects
* TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
* except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
*/
export function resolveTapAction(
explicit: string | null | undefined,
cardDefault: string | null | undefined,
domain: string | null | undefined,
): TapAction {
const want = explicit || cardDefault || 'info';
if (want === 'more-info') return 'more-info';
if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
if (explicit === 'toggle') return 'toggle';
return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
}
// ---------------- floors import ----------------
export interface FloorInfo {
id: string;
name: string;
level: number | null;
}
/** HA floor registry → a list ordered by level (unknown levels last), then name. */
export function floorsOf(hass: any): FloorInfo[] {
const reg = hass?.floors;
if (!reg || typeof reg !== 'object') return [];
const list: FloorInfo[] = [];
for (const f of Object.values<any>(reg)) {
if (!f || !f.floor_id) continue;
list.push({ id: f.floor_id, name: f.name || f.floor_id, level: f.level ?? null });
}
list.sort((a, b) => {
const la = a.level ?? 1e9;
const lb = b.level ?? 1e9;
return la !== lb ? la - lb : a.name.localeCompare(b.name);
});
return list;
}
/** Substitute every occurrence of {name} placeholders in a template string. */
export function subst(s: string, vars?: Record<string, string | number>): string {
if (!vars) return s;
let out = s;
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
return out;
}
// ---------------- room fills & colors ----------------
export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp';
/** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay {
showBorders: boolean;
showNames: boolean;
color: string; // hex like #3ea6ff
opacity: number; // 0..1 — applied to borders, names and fills
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;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
export const DEFAULT_ROOM_OPACITY = 0.55;
export const DEFAULT_TEMP_MIN = 20;
export const DEFAULT_TEMP_MAX = 25;
/** Resolve a space's display settings; spaces without a plan default to visible markup. */
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
const s = spaceCfg?.settings || {};
const noPlan = !spaceCfg?.plan_url;
return {
showBorders: s.show_borders ?? noPlan,
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',
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,
};
}
// ---------------- 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;
}
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 },
};
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)
* - light: warm yellow when any light in the room is on, grey when all known
* lights are off; rooms without lights get no fill (a bathroom without smart
* bulbs should not look permanently "off").
*/
export function roomFillColor(
mode: RoomFillMode,
lqi: number | null,
lights: 'on' | 'off' | 'none',
temp?: number | null,
tempMin: number = DEFAULT_TEMP_MIN,
tempMax: number = DEFAULT_TEMP_MAX,
): string | null {
if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
if (mode === 'light') {
if (lights === 'none') return null;
return lights === 'on' ? '#ffd45c' : '#9aa0a6';
}
if (mode === 'temp') {
// blue below the comfort range, green inside, yellow above; no reading → no fill.
// Bounds are swapped automatically if entered in the wrong order.
if (temp == null) return null;
const lo = Math.min(tempMin, tempMax);
const hi = Math.max(tempMin, tempMax);
if (temp < lo) return '#4fc3f7';
if (temp > hi) return '#ffd45c';
return '#66d17a';
}
return null;
}
+118 -39
View File
@@ -1,53 +1,132 @@
/**
* Prototype rules — ported 1-to-1 from index.html/build_data.py (see DOCUMENTATION.md §4.2–4.5).
* Device curation and icon rules.
*
* Icon rules are DATA, not code: the built-in defaults below can be overridden
* per instance via `config.settings.icon_rules` (edited in the card UI).
* A rule is `{ pattern, icon }`; patterns are case-insensitive regexes matched
* against "<device name> <model>", first match wins. Invalid user regexes are
* skipped silently at compile time (and flagged in the rules editor).
*/
/** Integration domains whose devices are hidden (curation). */
/** Integration domains whose devices are hidden by default (curation). */
export const EXCLUDED_DOMAINS = new Set([
'hacs', 'sun', 'backup', 'hassio', 'met', 'telegram_bot', 'mobile_app',
'systemmonitor', 'better_thermostat', 'adaptive_lighting', 'yandex_pogoda',
'upnp_serial_number',
]);
const ICON_RULES: Array<[RegExp, string]> = [
[/протечк/, 'mdi:water-alert'],
[/клапан/, 'mdi:pipe-valve'],
[/дым/, 'mdi:smoke-detector'],
[/термоголов/, 'mdi:radiator'],
[/температ/, 'mdi:thermometer'],
[/qingping|air monitor|молекул/, 'mdi:air-filter'],
[/штор/, 'mdi:roller-shade'],
[/розетк|plug/, 'mdi:power-socket-de'],
[/выключат|switch/, 'mdi:light-switch'],
[/лампа|лампочк|bulb|gx53|светильник|rgb/, 'mdi:lightbulb'],
[/камер|camera/, 'mdi:cctv'],
[/замок|ttlock|lock|sn609|sn9161/, 'mdi:lock'],
[/ворота|garage/, 'mdi:garage-variant'],
[/калитк|door|открыт/, 'mdi:door'],
[/счётчик|счетчик|kws|meter/, 'mdi:meter-electric'],
[/вводный автомат|breaker|wifimcbn/, 'mdi:electric-switch'],
[/myheat|котёл|котел|boiler|отоплен/, 'mdi:water-boiler'],
[/холодильник|fridge/, 'mdi:fridge'],
[/стиральн|washer/, 'mdi:washing-machine'],
[/сушилк|dryer/, 'mdi:tumble-dryer'],
[/пылесос|vacuum|dreame/, 'mdi:robot-vacuum'],
[/soundbar|колонк|станц/, 'mdi:soundbar'],
[/tv|телевизор|hyundaitv|mitv/, 'mdi:television'],
[/keenetic|роутер|router/, 'mdi:router-wireless'],
[/ибп|ups|kirpich/, 'mdi:battery-charging-high'],
[/slzb|координат|zigbee/, 'mdi:zigbee'],
];
/** Pick an MDI icon by device name/model. */
export function iconFor(name?: string, model?: string): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const [pat, icon] of ICON_RULES) {
if (pat.test(s)) return icon;
}
return 'mdi:chip';
export interface IconRule {
pattern: string;
icon: string;
}
/** Domain priority for picking the device's “primary” entity (more-info). */
/**
* Built-in defaults, bilingual (EN + RU device-name vocabularies).
* Kept as plain strings so the UI editor can display and clone them.
*/
export const DEFAULT_ICON_RULES: IconRule[] = [
{ pattern: 'протечк|leak|water sensor', icon: 'mdi:water-alert' },
{ pattern: 'клапан|valve', icon: 'mdi:pipe-valve' },
{ pattern: 'дым|smoke', icon: 'mdi:smoke-detector' },
{ pattern: 'термоголов|trv|radiator', icon: 'mdi:radiator' },
{ pattern: 'температ|temperature|climate sensor', icon: 'mdi:thermometer' },
{ pattern: 'qingping|air monitor|молекул|air quality', icon: 'mdi:air-filter' },
{ pattern: 'штор|curtain|blind|shade', icon: 'mdi:roller-shade' },
{ pattern: 'розетк|plug|socket|outlet', icon: 'mdi:power-socket-de' },
{ pattern: 'выключат|switch', icon: 'mdi:light-switch' },
{ pattern: 'лампа|лампочк|bulb|gx53|светильник|rgb|lamp|light strip', icon: 'mdi:lightbulb' },
{ pattern: 'камер|camera', icon: 'mdi:cctv' },
{ pattern: 'замок|ttlock|lock|sn609|sn9161', icon: 'mdi:lock' },
{ pattern: 'ворота|garage|gate', icon: 'mdi:garage-variant' },
{ pattern: 'калитк|door|открыт|contact', icon: 'mdi:door' },
{ pattern: 'счётчик|счетчик|kws|meter', icon: 'mdi:meter-electric' },
{ pattern: 'вводный автомат|breaker|wifimcbn', icon: 'mdi:electric-switch' },
{ pattern: 'myheat|котёл|котел|boiler|отоплен|heating', icon: 'mdi:water-boiler' },
{ pattern: 'холодильник|fridge', icon: 'mdi:fridge' },
{ 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: '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' },
{ pattern: 'slzb|координат|zigbee|coordinator', icon: 'mdi:zigbee' },
{ pattern: 'motion|движен|presence|присутств', icon: 'mdi:motion-sensor' },
{ pattern: 'humidity|влажн', icon: 'mdi:water-percent' },
];
export interface CompiledIconRule {
re: RegExp;
icon: string;
}
/** Compile rules, silently skipping invalid regexes (user input!). */
export function compileIconRules(rules: IconRule[]): CompiledIconRule[] {
const res: CompiledIconRule[] = [];
for (const r of rules) {
if (!r || typeof r.pattern !== 'string' || !r.icon) continue;
try {
res.push({ re: new RegExp(r.pattern, 'i'), icon: r.icon });
} catch {
/* invalid user regex — skipped; the rules editor highlights it */
}
}
return res;
}
/** Whether a single rule pattern is a valid regex (for the editor UI). */
export function isValidPattern(pattern: string): boolean {
try {
new RegExp(pattern, 'i');
return true;
} catch {
return false;
}
}
const DEFAULT_COMPILED = compileIconRules(DEFAULT_ICON_RULES);
/** Fallback by entity device_class / domain when no name rule matched. */
const DEVICE_CLASS_ICONS: Record<string, string> = {
temperature: 'mdi:thermometer',
humidity: 'mdi:water-percent',
motion: 'mdi:motion-sensor',
occupancy: 'mdi:motion-sensor',
door: 'mdi:door',
window: 'mdi:window-closed',
garage_door: 'mdi:garage-variant',
smoke: 'mdi:smoke-detector',
moisture: 'mdi:water-alert',
gas: 'mdi:gas-cylinder',
power: 'mdi:meter-electric',
energy: 'mdi:meter-electric',
illuminance: 'mdi:brightness-5',
co2: 'mdi:molecule-co2',
pm25: 'mdi:air-filter',
battery: 'mdi:battery',
};
export const FALLBACK_ICON = 'mdi:chip';
/** Pick an MDI icon by device name/model using the given (or default) rules. */
export function iconFor(name?: string, model?: string, rules?: CompiledIconRule[]): string {
const s = ((name || '') + ' ' + (model || '')).toLowerCase();
for (const { re, icon } of rules ?? DEFAULT_COMPILED) {
if (re.test(s)) return icon;
}
return FALLBACK_ICON;
}
/** Icon from entity device_class attributes (used when name rules gave the fallback). */
export function iconFromDeviceClasses(deviceClasses: string[]): string | null {
for (const dc of deviceClasses) {
const icon = DEVICE_CLASS_ICONS[dc];
if (icon) return icon;
}
return null;
}
/** Priority of domains for picking a device's "primary" entity (more-info). */
export const DOMAIN_PRIORITY = [
'light', 'switch', 'cover', 'valve', 'lock', 'climate', 'fan',
'media_player', 'camera', 'vacuum', 'humidifier', 'water_heater',
+235
View File
@@ -0,0 +1,235 @@
/**
* houseplan-space-card — a READ-ONLY, static schematic of a single houseplan space,
* embeddable on any dashboard. Renders exactly what is configured (plan + configured
* room borders/names + device markers at their saved positions) with NO interactivity
* (no clicks/hover/tooltips/drag/more-info) and NO live states. A footer button opens
* the space in the full component via a deep-link (`#space=<id>`).
*/
import { LitElement, html, nothing, css, type TemplateResult, type PropertyValues } from 'lit';
import { cardStyles } from './styles';
import { renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
ev.detail = detail ?? {};
node.dispatchEvent(ev);
};
const navigate = (path: string) => {
history.pushState(null, '', path);
fireEvent(window, 'location-changed', { replace: false });
};
interface SpaceCardConfig {
type: string;
space: string;
title?: string;
show_button?: boolean;
button_label?: string;
button_target?: string;
aspect_ratio?: string;
icon_size?: number;
language?: string;
}
class HouseplanSpaceCard extends LitElement {
public hass?: any;
private _config?: SpaceCardConfig;
private _snap: HpConfigSnapshot | null = null;
private _loading = false;
private _unsub?: () => void;
static properties = {
hass: { attribute: false },
_config: { state: true },
_snap: { state: true },
};
public static getConfigElement() {
return document.createElement('houseplan-space-card-editor');
}
public static getStubConfig(hass: any): Partial<SpaceCardConfig> {
const snap = cachedSnapshot();
const first = spaceModels(snap?.config || null)[0]?.id || '';
return { type: 'custom:houseplan-space-card', space: first, show_button: true };
}
public setConfig(config: SpaceCardConfig): void {
if (!config || !config.space) {
throw new Error('houseplan-space-card: "space" is required');
}
this._config = { show_button: true, button_target: '/plan-doma', ...config };
// instant paint from the full card's localStorage snapshot, refresh in the background
this._snap = this._snap || cachedSnapshot();
}
public connectedCallback(): void {
super.connectedCallback();
this._unsub = onConfigChange(() => {
this._loading = false;
this._snap = null;
this.requestUpdate();
});
}
public disconnectedCallback(): void {
this._unsub?.();
this._unsub = undefined;
super.disconnectedCallback();
}
protected willUpdate(changed: PropertyValues): void {
if (this.hass && !this._loading && (!this._snap || changed.has('hass'))) {
if (!this._snap || !this._loadedOnce) this._load();
}
}
private _loadedOnce = false;
private async _load(): Promise<void> {
if (!this.hass || this._loading) return;
this._loading = true;
try {
const snap = await getConfig(this.hass);
this._snap = snap;
this._loadedOnce = true;
} catch {
/* keep any localStorage snapshot */
} finally {
this._loading = false;
this.requestUpdate();
}
}
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
public getCardSize(): number {
const models = spaceModels(this._snap?.config || null);
const sp = models.find((s) => s.id === this._config?.space);
if (sp) {
const ratio = sp.vb[3] / sp.vb[2]; // h/w
return Math.max(3, Math.round(ratio * 8)) + (this._config?.show_button === false ? 0 : 1);
}
return 6;
}
private _errorCard(msg: string): TemplateResult {
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
const cfg = this._snap?.config;
if (!cfg) {
// still loading and no snapshot yet
return html`<ha-card><div class="hp-static-error">${t(this._lang, 'space_card.loading')}</div></ha-card>`;
}
const spaceId = this._config.space;
const stage = renderSpaceStatic({
hass: this.hass,
cfg,
layout: this._snap?.layout || {},
spaceId,
iconSize: this._config.icon_size,
lang: this._lang,
});
if (!stage) {
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
}
const sp = spaceModels(cfg).find((s) => s.id === spaceId);
const title = this._config.title !== undefined ? this._config.title : sp?.title || '';
const showButton = this._config.show_button !== false;
const label = this._config.button_label || t(this._lang, 'space_card.button');
return html`
<ha-card>
${title ? html`<div class="hp-static-title">${title}</div>` : nothing}
${stage}
${showButton
? html`<div class="hp-static-foot">
<button class="hp-static-btn" @click=${this._goToSpace}>${label}</button>
</div>`
: nothing}
</ha-card>
`;
}
private _goToSpace = (): void => {
const base = (this._config?.button_target || '/plan-doma').replace(/#.*$/, '');
navigate(`${base}#space=${encodeURIComponent(this._config!.space)}`);
};
static styles = [
cardStyles,
css`
.hp-static-title {
font-weight: 700;
padding: 10px 14px 6px;
font-size: 16px;
color: var(--primary-text-color);
}
.hp-static-stage {
position: relative;
width: 100%;
container-type: inline-size;
overflow: hidden;
pointer-events: none; /* kill ALL interaction on the schematic (§4) */
background: var(--ha-card-background, var(--card-background-color, #111));
}
.hp-static-stage svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.hp-static-stage .devlayer {
position: absolute;
inset: 0;
}
.hp-static-foot {
padding: 8px 12px 12px;
pointer-events: auto; /* the button stays clickable */
}
.hp-static-btn {
width: 100%;
padding: 9px 14px;
border: none;
border-radius: 10px;
background: var(--primary-color, #3ea6ff);
color: var(--text-primary-color, #fff);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.hp-static-btn:hover {
filter: brightness(1.08);
}
.hp-static-error {
padding: 24px;
text-align: center;
color: var(--secondary-text-color, #9aa4ad);
}
`,
];
}
if (!customElements.get('houseplan-space-card')) {
customElements.define('houseplan-space-card', HouseplanSpaceCard);
}
(window as any).customCards = (window as any).customCards || [];
if (!(window as any).customCards.find((c: any) => c.type === 'houseplan-space-card')) {
(window as any).customCards.push({
type: 'houseplan-space-card',
name: 'House Plan — Space (static)',
description: 'Read-only static schematic of a single houseplan space, with a deep-link button.',
preview: false,
documentation: 'https://github.com/Matysh/houseplan-card',
});
}
export { HouseplanSpaceCard };
+85
View File
@@ -0,0 +1,85 @@
/** Config editor (Lovelace GUI) for houseplan-space-card. */
import { LitElement, html, nothing } from 'lit';
import { langOf, t, type Lang } from './i18n';
class HouseplanSpaceCardEditor extends LitElement {
public hass?: any;
private _config?: any;
private _spaces: { value: string; label: string }[] | null = null;
private _spacesLoading = false;
static properties = {
hass: { attribute: false },
_config: { state: true },
_spaces: { state: true },
};
public setConfig(config: any): void {
this._config = config;
}
private async _loadSpaces(): Promise<void> {
if (this._spaces || this._spacesLoading || !this.hass) return;
this._spacesLoading = true;
try {
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
this._spaces = (resp?.config?.spaces || []).map((s: any) => ({ value: s.id, label: s.title || s.id }));
} catch {
this._spaces = [];
} finally {
this._spacesLoading = false;
}
}
private get _lang(): Lang {
return langOf(this.hass, this._config?.language);
}
private get _schema(): any[] {
const spaces = this._spaces || [];
return [
spaces.length
? { name: 'space', selector: { select: { mode: 'dropdown', options: spaces } } }
: { name: 'space', selector: { text: {} } },
{ name: 'title', selector: { text: {} } },
{ name: 'show_button', selector: { boolean: {} } },
{ name: 'button_label', selector: { text: {} } },
{ name: 'button_target', selector: { text: {} } },
{ name: 'aspect_ratio', selector: { text: {} } },
{ name: 'icon_size', selector: { number: { min: 1, max: 6, step: 0.1, mode: 'box' } } },
];
}
protected render() {
if (!this.hass || !this._config) return nothing;
this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {
space: t(L, 'editor.space'),
title: t(L, 'editor.title'),
show_button: t(L, 'editor.show_button'),
button_label: t(L, 'editor.button_label'),
button_target: t(L, 'editor.button_target'),
aspect_ratio: t(L, 'editor.aspect_ratio'),
icon_size: t(L, 'editor.icon_size'),
};
return html`<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${this._schema}
.computeLabel=${(s: any) => labels[s.name] || s.name}
@value-changed=${this._valueChanged}
></ha-form>`;
}
private _valueChanged(ev: CustomEvent): void {
const config = { ...this._config, ...ev.detail.value };
const e = new Event('config-changed', { bubbles: true, composed: true }) as any;
e.detail = { config };
this.dispatchEvent(e);
}
}
if (!customElements.get('houseplan-space-card-editor')) {
customElements.define('houseplan-space-card-editor', HouseplanSpaceCardEditor);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Pure geometry/model helpers for houseplan spaces — no Lit/DOM imports, so they
* are directly unit-tested. Shared by the static renderer (space-render.ts) and
* mirror the full card's private math.
*/
import { declump } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect;
const scale = (r: any): RoomCfg => ({
id: r.id,
name: r.name,
area: r.area ?? null,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
h: r.h != null ? r.h * H : undefined,
poly: r.poly ? r.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H]) : undefined,
});
return {
id: s.id,
title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
}
/** Bounding rectangle of a room (rect or polygon) in render units. */
export function roomBounds(r: RoomCfg): { x: number; y: number; w: number; h: number } {
if (r.poly && r.poly.length) {
const xs = r.poly.map((p) => p[0]);
const ys = r.poly.map((p) => p[1]);
const x = Math.min(...xs);
const y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
return { x: r.x ?? 0, y: r.y ?? 0, w: r.w ?? 0, h: r.h ?? 0 };
}
/** Geometric centre of a room (label anchor). */
export function roomCenter(r: RoomCfg): number[] {
if (r.poly) {
const n = r.poly.length;
return [r.poly.reduce((a, p) => a + p[0], 0) / n, r.poly.reduce((a, p) => a + p[1], 0) / n];
}
return [r.x! + r.w! / 2, r.y! + Math.min(r.w!, r.h!) * 0.1];
}
/** Auto grid positions for a single space's area devices (identical to the full card). */
export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: number): Record<string, Pt> {
const map: Record<string, Pt> = {};
const minDist = (iconPct / 100) * NORM_W * 1.3;
for (const r of model.rooms) {
if (!r.area) continue;
const ds = devs.filter((d) => d.area === r.area);
if (!ds.length) continue;
const b = roomBounds(r);
const pad = Math.min(b.w, b.h) * 0.1;
const iw = b.w - pad * 2;
const ih = b.h - pad * 2;
const cols = Math.max(1, Math.round(Math.sqrt((ds.length * iw) / Math.max(ih, 1))));
const cw = iw / cols;
const ch = ih / Math.max(Math.ceil(ds.length / cols), 1);
const pts = ds.map((_, i) => ({
x: b.x + pad + cw * ((i % cols) + 0.5),
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
}));
declump(pts, b, minDist, pad * 0.5);
ds.forEach((d, i) => (map[d.id] = pts[i]));
}
return map;
}
/** Marker position in render units: saved layout → default grid → space centre. */
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
const saved = layout[d.id];
if (saved && saved.s === d.space) {
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
if (defPos[d.id]) return defPos[d.id];
const vb = model.vb;
return { x: vb[0] + vb[2] / 2, y: vb[1] + vb[3] / 2 };
}
/** Saved room-label position (layout key rl_<roomId>) or the room centre. */
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
const saved = layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) {
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1;
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
}
const c = roomCenter(r);
return { x: c[0], y: c[1] };
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Shared STATIC renderer for a single houseplan space — used by the read-only
* `houseplan-space-card`. Draws exactly what is CONFIGURED (plan background,
* configured room borders/names, device markers at their saved positions), with NO interactivity
* (pointer-events:none) and NO state subscription; room fills are rendered exactly as
* configured on the full card (a snapshot of the current states passed via `hass`).
* Geometry/model math lives in space-geometry.ts (pure, unit-tested).
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
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';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
} from './space-geometry';
export { spaceModels } from './space-geometry';
export interface StaticRenderOpts {
hass: any;
cfg: ServerConfig;
layout: Layout;
spaceId: string;
iconSize?: number;
lang: Lang;
}
/**
* Static schematic of one space. Returns the inner stage template (svg + marker
* layer) or null when the space id is unknown (the caller renders an error card).
*/
export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const models = spaceModels(o.cfg);
const space = models.find((s) => s.id === o.spaceId);
if (!space) return null;
const vb = space.vb;
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
const cfgSize = o.iconSize ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const areaToSpace: Record<string, string> = {};
for (const s of o.cfg.spaces || []) for (const r of (s as any).rooms || []) if (r.area) areaToSpace[r.area] = (s as any).id;
const excluded = o.cfg.settings?.exclude_integrations ? new Set(o.cfg.settings.exclude_integrations) : EXCLUDED_DOMAINS;
const iconRules = compileIconRules(
o.cfg.settings?.icon_rules?.length ? o.cfg.settings.icon_rules : DEFAULT_ICON_RULES,
);
const loc = (k: 'device.unnamed' | 'device.light_group' | 'device.fallback' | 'device.virtual') => t(o.lang, k);
const all = buildDevices({
hass: o.hass,
areaToSpace,
markers: o.cfg.markers || [],
settings: o.cfg.settings || {},
excluded,
showAll: !!o.cfg.settings?.show_all,
firstSpaceId: models[0]?.id || '',
loc,
iconRules,
});
const devs = all.filter((d) => d.space === o.spaceId);
const defPos = defaultPositions(devs, space, iconPct);
const roomShapes = space.rooms
.filter((r) => r.area || disp.showBorders)
.map((r) => {
let cls = 'room ' + (space.bg ? 'overlay' : 'yard');
let style = '';
if (disp.showBorders || disp.fill !== 'none') {
cls += ' styled';
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
? 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.c}`, `--room-fill-op:${fillC.a.toFixed(3)}`);
} else {
parts.push('--room-fill:transparent', '--room-fill-op:0');
}
style = parts.join(';');
}
const svgLabel = !space.bg && !disp.showNames;
const c = roomCenter(r);
const shape = r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"></polygon>`
: svg`<rect class="${cls}" style="${style}" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"></rect>`;
return svg`${shape}${svgLabel ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
});
const markers = devs.map((d) => {
const p = markerPos(d, o.layout, o.cfg, defPos, space);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
return html`<div class="dev ${d.virtual ? 'virtual' : ''}" style="left:${left}%;top:${top}%">
<ha-icon icon="${d.icon}"></ha-icon>
</div>`;
});
const labels = disp.showNames
? space.rooms
.filter((r) => r.name)
.map((r) => {
const p = labelPos(r, space.id, o.layout, o.cfg);
const left = ((p.x - vb[0]) / vb[2]) * 100;
const top = ((p.y - vb[1]) / vb[3]) * 100;
const op = Math.min(1, disp.opacity + 0.25);
return html`<div class="roomlabel" style="left:${left}%;top:${top}%;color:${disp.color};opacity:${op}">${r.name}</div>`;
})
: [];
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${space.bg
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
: nothing}
${roomShapes}
</svg>
<div class="devlayer" style="--icon-size:${iconPct}cqw">${markers}${labels}</div>
</div>
`;
}
+315 -13
View File
@@ -134,6 +134,9 @@ export const cardStyles = css`
opacity: 0.5;
pointer-events: none;
}
.stage.noplan {
background: #ffffff;
}
.stage {
position: relative;
width: 100%;
@@ -165,8 +168,9 @@ export const cardStyles = css`
position: absolute;
left: 8px;
bottom: 8px;
background: rgba(4, 18, 31, 0.8);
color: #dff1ff;
background: var(--card-background-color, var(--hp-bg));
opacity: 0.92;
color: var(--hp-txt);
border: 1px solid var(--hp-accent);
border-radius: 8px;
padding: 2px 8px;
@@ -190,19 +194,184 @@ export const cardStyles = css`
stroke: transparent;
stroke-width: 2;
}
.room.overlay:hover {
fill: rgba(62, 166, 255, 0.18);
stroke: var(--hp-accent);
.room.overlay:not(.styled):hover {
fill: #9aa0a6;
fill-opacity: 0.22;
stroke: var(--hp-muted);
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
stroke: #4b8c5a;
stroke-width: 2;
}
.room.yard:hover {
.room.yard:not(.styled):hover {
fill: rgba(75, 140, 90, 0.24);
stroke: #6fbf86;
}
.room.styled {
stroke: var(--room-stroke, transparent);
stroke-opacity: var(--room-stroke-op, 0);
stroke-width: 2.5;
fill: var(--room-fill, transparent);
fill-opacity: var(--room-fill-op, 0);
}
/* hover: darken the current fill instead of recoloring; grey when unfilled */
.room.styled.filled:hover {
filter: brightness(0.78);
stroke-opacity: 1;
}
.room.styled:not(.filled):hover {
fill: #9aa0a6;
fill-opacity: 0.22;
stroke-opacity: 1;
}
/* 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;
}
g.opening:hover .op-outline {
opacity: 0.9;
}
.op-hit {
fill: transparent;
cursor: grab;
pointer-events: auto;
touch-action: none; /* drags, not scrolls, on touch */
}
.op-hit:active {
cursor: grabbing;
}
.stage.markup .op-hit {
pointer-events: none; /* markup clicks go to the stage tools */
}
.oplock {
position: absolute;
transform: translate(-50%, -50%);
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);
pointer-events: auto;
cursor: pointer;
z-index: 1;
}
.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); }
.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 {
position: absolute;
transform: translate(-50%, -50%);
font-size: calc(var(--icon-size, 2.5cqw) * 0.5);
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: grab;
pointer-events: auto;
user-select: none;
z-index: 1;
}
.roomlabel:active { cursor: grabbing; }
.measurelayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.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;
}
.rlabel {
fill: var(--hp-muted);
font-size: 15px;
@@ -222,6 +391,11 @@ export const cardStyles = css`
.stage.markup .devlayer {
display: none; /* in markup mode icons must not get in the way */
}
.room.picked {
stroke: #ffc14d;
stroke-width: 3;
fill: rgba(255, 193, 77, 0.25);
}
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
@@ -279,9 +453,12 @@ export const cardStyles = css`
}
.dev {
position: absolute;
width: var(--icon-size, 2.5cqw);
height: var(--icon-size, 2.5cqw);
margin: calc(var(--icon-size, 2.5cqw) / -2) 0 0 calc(var(--icon-size, 2.5cqw) / -2);
/* 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(--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);
@@ -297,6 +474,10 @@ export const cardStyles = css`
}
.dev ha-icon {
--mdc-icon-size: calc(var(--icon-size, 2.5cqw) * 0.62);
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
}
.dev:active {
cursor: grabbing;
@@ -334,14 +515,31 @@ export const cardStyles = css`
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: rgba(4, 18, 31, 0.9);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
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: #dff1ff;
color: var(--hp-txt);
white-space: nowrap;
pointer-events: none;
}
.dev .hval {
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: calc(var(--icon-size, 2.5cqw) * 0.1);
background: var(--card-background-color, var(--hp-bg));
border: 1px solid #4fc3f7;
border-radius: calc(var(--icon-size, 2.5cqw) * 0.18);
padding: 0 calc(var(--icon-size, 2.5cqw) * 0.14);
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);
white-space: nowrap;
pointer-events: none;
}
@@ -382,6 +580,52 @@ export const cardStyles = css`
.tab.tabadd ha-icon {
--mdc-icon-size: 15px;
}
.srcrow {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
padding: 2px 0;
}
.dispsection {
margin-top: 12px !important;
padding-top: 8px;
border-top: 1px solid var(--hp-line);
font-weight: 600;
color: var(--hp-txt) !important;
}
.colorrow {
display: flex;
align-items: center;
gap: 8px;
}
.colorrow input[type='color'] {
width: 42px;
height: 28px;
border: 1px solid var(--hp-line);
border-radius: 6px;
background: transparent;
padding: 1px;
cursor: pointer;
}
.colorrow input[type='range'] { flex: 1; }
.colorrow .tempin { width: 70px; flex: none; }
.temprange {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: auto;
color: var(--hp-muted);
font-size: 12px;
}
/* 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; }
.colorrow .opv { font-size: 12px; min-width: 34px; text-align: right; }
.planrow {
display: flex;
align-items: center;
@@ -415,7 +659,7 @@ export const cardStyles = css`
flex: 1;
}
.dialog.wide {
width: min(440px, 94vw);
width: min(500px, 94vw);
}
.dialog .body {
max-height: 66vh;
@@ -577,6 +821,64 @@ export const cardStyles = css`
ha-icon-picker {
display: block;
}
.floorrow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
font-size: 13.5px;
cursor: pointer;
}
.floorrow .floorlvl {
color: var(--hp-muted);
font-size: 11px;
border: 1px solid var(--hp-line);
border-radius: 5px;
padding: 0 5px;
}
.importprog {
margin-left: auto;
color: var(--hp-muted);
font-size: 12px;
font-weight: 400;
}
.rhint {
font-size: 12px;
color: var(--hp-muted);
margin-bottom: 6px;
}
.rtest {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.rtest .namein { flex: 1; }
.rtest ha-icon { color: var(--hp-accent); }
.rtesticon { font-size: 11px; color: var(--hp-muted); }
.rrow {
display: flex;
align-items: center;
gap: 6px;
margin: 2px 0;
}
.rrow .rpat { flex: 2; }
.rrow .ricon { flex: 1.4; }
.rrow .rpat.bad { border-color: #ff7a5c; }
.rrow .rprev { --mdc-icon-size: 18px; color: var(--hp-txt); min-width: 18px; }
.rrow .ract {
--mdc-icon-size: 16px;
color: var(--hp-muted);
cursor: pointer;
}
.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;
+30 -1
View File
@@ -37,12 +37,38 @@ export interface Marker {
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
tap_action?: string | null; // per-device override: 'info' | 'more-info' | 'toggle'
display?: 'badge' | 'ripple' | 'icon_ripple' | null; // how the device is drawn
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
}
/** 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 {
spaces: any[];
markers: Marker[];
settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean };
settings: {
exclude_integrations?: string[];
group_lights?: boolean;
show_all?: boolean;
icon_rules?: { pattern: string; icon: string }[];
};
}
export interface DevItem {
@@ -55,6 +81,7 @@ export interface DevItem {
entities: string[];
primary?: string;
temp?: number | null;
hum?: number | null;
virtual?: boolean;
marker?: Marker; // linked config marker (metadata, overrides)
bindingKind?: 'device' | 'entity' | 'virtual';
@@ -62,6 +89,7 @@ export interface DevItem {
link?: string | null;
description?: string | null;
pdfs?: PdfRef[];
tapAction?: string | null; // from the marker override
}
export interface CardConfig {
@@ -73,4 +101,5 @@ export interface CardConfig {
live_states?: boolean;
show_signal?: boolean;
language?: string; // 'en' | 'ru' | '' (auto — HA profile)
tap_action?: string; // 'info' (default) | 'more-info' | 'toggle'
}
+282
View File
@@ -0,0 +1,282 @@
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';
/** Minimal fake hass around the pieces buildDevices reads. */
function mkHass({ devices = {}, entities = {}, states = {}, areas = {} } = {}) {
return { devices, entities, states, areas };
}
const loc = (k) =>
({ 'device.unnamed': 'unnamed', 'device.light_group': 'light group',
'device.fallback': 'device', 'device.virtual': 'virtual device' }[k]);
const baseCtx = (hass, over = {}) => ({
hass, areaToSpace: { living: 'f1', kitchen: 'f1' }, markers: [], settings: {},
excluded: new Set(['hacs']), showAll: false, firstSpaceId: 'f1', loc, ...over,
});
const dev = (id, name, model, area, extra = {}) =>
({ id, name, model, area_id: area, identifiers: [['demo', id]], entry_type: null, via_device_id: null, ...extra });
test('buildDevices: devices outside bound areas are dropped', () => {
const h = mkHass({ devices: {
a: dev('a', 'Lamp', 'Bulb', 'living'),
b: dev('b', 'Lamp', 'Bulb', 'garage'), // area not bound to a room
c: dev('c', 'Lamp', 'Bulb', null), // no area at all
}});
const res = buildDevices(baseCtx(h));
assert.deepEqual(res.map((d) => d.id), ['a']);
assert.equal(res[0].space, 'f1');
});
test('buildDevices: curation hides excluded/service/group/scene/bridge, show_all reveals', () => {
const h = mkHass({ devices: {
ok: dev('ok', 'Plug', 'Smart Plug', 'living'),
ex: dev('ex', 'HACS', 'x', 'living', { identifiers: [['hacs', '1']] }),
sv: dev('sv', 'Service', 'x', 'living', { entry_type: 'service' }),
gr: dev('gr', 'Group dev', 'Group', 'living'),
sc: dev('sc', 'Scenes', 'wireless scene switch', 'living'),
br: dev('br', 'Zigbee Bridge', 'Bridge V2', 'living'),
}});
assert.deepEqual(buildDevices(baseCtx(h)).map((d) => d.id), ['ok']);
// show_all reveals everything except service entries (those are never devices on the plan)
const all = buildDevices(baseCtx(h, { showAll: true })).map((d) => d.id);
assert.deepEqual(all.sort(), ['br', 'ex', 'gr', 'ok', 'sc']);
});
test('buildDevices: duplicate name|area gets numbered, different areas do not', () => {
const h = mkHass({ devices: {
a: dev('a', 'Lamp', 'Bulb', 'living'),
b: dev('b', 'Lamp', 'Bulb', 'living'),
c: dev('c', 'Lamp', 'Bulb', 'kitchen'),
}});
const names = buildDevices(baseCtx(h)).map((d) => d.name);
assert.deepEqual(names, ['Lamp', 'Lamp 2', 'Lamp']);
});
test('buildDevices: light groups appear, single lamps in grouped areas are suppressed', () => {
const h = mkHass({
devices: { lamp: dev('lamp', 'Ceiling lamp', 'Bulb E27', 'living') },
entities: {
'light.group_living': { entity_id: 'light.group_living', platform: 'group', area_id: 'living' },
'light.single': { entity_id: 'light.single', device_id: 'lamp', platform: 'demo' },
},
states: { 'light.group_living': { state: 'on', attributes: { friendly_name: 'Living lights' } } },
});
const res = buildDevices(baseCtx(h));
const ids = res.map((d) => d.id);
assert.ok(ids.includes('lg_light.group_living'));
assert.ok(!ids.includes('lamp'), 'single lamp must fold into the group');
// group_lights=false: no groups, the lamp comes back
const res2 = buildDevices(baseCtx(h, { settings: { group_lights: false } }));
assert.deepEqual(res2.map((d) => d.id), ['lamp']);
});
test('buildDevices: claimed device is replaced by its marker (metadata applied)', () => {
const h = mkHass({ devices: { a: dev('a', 'Boiler', 'BAXI', 'living') } });
const markers = [{ id: 'a', binding: 'device:a', name: 'Main boiler', icon: 'mdi:water-boiler',
link: 'https://x', tap_action: 'more-info' }];
const res = buildDevices(baseCtx(h, { markers }));
assert.equal(res.length, 1);
const it = res[0];
assert.equal(it.name, 'Main boiler');
assert.equal(it.icon, 'mdi:water-boiler');
assert.equal(it.link, 'https://x');
assert.equal(it.tapAction, 'more-info');
assert.equal(it.bindingKind, 'device');
});
test('buildDevices: hidden marker removes the device entirely', () => {
const h = mkHass({ devices: { a: dev('a', 'Noisy', 'x', 'living') } });
const res = buildDevices(baseCtx(h, { markers: [{ id: 'a', binding: 'device:a', hidden: true }] }));
assert.equal(res.length, 0);
});
test('buildDevices: virtual marker lands in its room; entity marker resolves name from state', () => {
const h = mkHass({
entities: { 'sensor.avg': { entity_id: 'sensor.avg', platform: 'min_max', area_id: 'kitchen' } },
states: { 'sensor.avg': { state: '21', attributes: { friendly_name: 'Average temp' } } },
});
const markers = [
{ id: 'v1', binding: 'virtual', name: 'Septic tank', icon: 'mdi:pipe', space: 'f1', area: 'living' },
{ id: 'lg_sensor.avg', binding: 'entity:sensor.avg' },
];
const res = buildDevices(baseCtx(h, { markers }));
const v = res.find((d) => d.id === 'v1');
assert.ok(v.virtual);
assert.equal(v.icon, 'mdi:pipe');
const e = res.find((d) => d.id === 'lg_sensor.avg');
assert.equal(e.name, 'Average temp');
assert.equal(e.primary, 'sensor.avg');
assert.equal(e.space, 'f1'); // via the entity's area
});
test('buildDevices: custom icon rules apply; lock entity still forces mdi:lock', () => {
const h = mkHass({
devices: {
p: dev('p', 'Steckdose Küche', 'Plug S26', 'living'),
l: dev('l', 'Home', 'S2', 'living'),
},
entities: { 'lock.front': { entity_id: 'lock.front', device_id: 'l', platform: 'demo' } },
});
const iconRules = compileIconRules([{ pattern: 'steckdose', icon: 'mdi:power-socket-eu' }]);
const res = buildDevices(baseCtx(h, { iconRules }));
assert.equal(res.find((d) => d.id === 'p').icon, 'mdi:power-socket-eu');
assert.equal(res.find((d) => d.id === 'l').icon, 'mdi:lock');
});
test('buildDevices: device_class fallback when no rule matches', () => {
const h = mkHass({
devices: { t: dev('t', 'XYZZY-42', 'unknown', 'living') },
entities: { 'sensor.x': { entity_id: 'sensor.x', device_id: 't', platform: 'demo' } },
states: { 'sensor.x': { state: '21.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } } },
});
const it = buildDevices(baseCtx(h))[0];
assert.equal(it.icon, 'mdi:thermometer');
assert.equal(it.temp, 21.5);
assert.equal(it.primary, 'sensor.x');
});
test('primaryEntity: domain priority and diagnostic-category demotion', () => {
const h = mkHass({
entities: {
'sensor.batt': { entity_id: 'sensor.batt', entity_category: 'diagnostic' },
'light.a': { entity_id: 'light.a' },
'sensor.temp': { entity_id: 'sensor.temp' },
},
states: {},
});
assert.equal(primaryEntity(h, ['sensor.batt', 'sensor.temp', 'light.a'], 'mdi:lightbulb'), 'light.a');
// only a diagnostic entity → still usable as a last resort
assert.equal(primaryEntity(h, ['sensor.batt'], 'mdi:chip'), 'sensor.batt');
});
test('lqiFor: dedicated sensor wins over attribute duplication; tempFor rounds', () => {
const h = mkHass({ states: {
'sensor.x_linkquality': { state: '120', attributes: { unit_of_measurement: 'lqi' } },
'sensor.temp': { state: '22.46', attributes: { device_class: 'temperature', linkquality: 118 } },
}});
assert.equal(lqiFor(h, ['sensor.x_linkquality', 'sensor.temp']), 119); // avg(120,118)
assert.equal(tempFor(h, ['sensor.temp']), 22.5);
});
test('areaLights: on / off / none tri-state', () => {
const hass = mkHass({ states: { 'light.a': { state: 'on' }, 'light.b': { state: 'off' } } });
const devs = [
{ area: 'living', entities: ['light.a', 'sensor.x'] },
{ area: 'kitchen', entities: ['light.b'] },
{ area: 'bath', entities: ['sensor.hum'] },
];
assert.equal(areaLights(hass, devs, 'living'), 'on');
assert.equal(areaLights(hass, devs, 'kitchen'), 'off');
assert.equal(areaLights(hass, devs, 'bath'), 'none');
});
test('areaTemp: averages only thermometer devices, null when none report', () => {
const hass = mkHass({ states: {
'sensor.t1': { state: '20.0', attributes: { device_class: 'temperature' } },
'sensor.t2': { state: '23.1', attributes: { device_class: 'temperature' } },
'sensor.hum': { state: '55', attributes: { device_class: 'humidity' } },
}});
const devs = [
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t1'] },
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.t2'] },
{ area: 'bath', icon: 'mdi:water-percent', entities: ['sensor.hum'] },
];
assert.equal(areaTemp(hass, devs, 'living'), 21.6); // (20+23.1)/2=21.55 → 21.6
assert.equal(areaTemp(hass, devs, 'bath'), null);
assert.equal(areaTemp(hass, devs, 'garage'), null);
});
test('areaTemp: ignores non-thermometer devices that expose a temperature (fridge, TRV, chip)', () => {
const hass = mkHass({
entities: { 'sensor.plug_device_temperature': { entity_id: 'sensor.plug_device_temperature', entity_category: 'diagnostic' } },
states: {
'sensor.room': { state: '22.0', attributes: { device_class: 'temperature' } },
'sensor.fridge_t': { state: '4.5', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.trv_current': { state: '8.3', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
'sensor.plug_device_temperature': { state: '31', attributes: { device_class: 'temperature', unit_of_measurement: '°C' } },
},
});
const devs = [
{ area: 'living', icon: 'mdi:thermometer', entities: ['sensor.room'] },
{ area: 'living', icon: 'mdi:fridge', entities: ['sensor.fridge_t'] },
{ area: 'living', icon: 'mdi:radiator', entities: ['sensor.trv_current'] },
{ area: 'living', icon: 'mdi:power-socket-de', entities: ['sensor.plug_device_temperature'] },
];
// только настоящий термометр (22.0), холодильник/термоголовка/чип игнорируются
assert.equal(areaTemp(hass, devs, 'living'), 22.0);
});
test('isTempEntity: excludes chip temperature and diagnostic-category entities', () => {
const hass = mkHass({
entities: { 'sensor.calibrated': { entity_id: 'sensor.calibrated', entity_category: 'diagnostic' } },
states: {
'sensor.room_temperature': { state: '21', attributes: { device_class: 'temperature' } },
'sensor.plug_device_temperature': { state: '30', attributes: { device_class: 'temperature' } },
'sensor.calibrated': { state: '19', attributes: { device_class: 'temperature' } },
},
});
// настоящий комнатный датчик проходит
assert.equal(tempFor(hass, ['sensor.room_temperature']), 21);
// чип и диагностика — нет
assert.equal(tempFor(hass, ['sensor.plug_device_temperature']), null);
assert.equal(tempFor(hass, ['sensor.calibrated']), null);
// если в устройстве и чип, и настоящий — берётся настоящий
assert.equal(tempFor(hass, ['sensor.plug_device_temperature', 'sensor.room_temperature']), 21);
});
test('buildDevices: entity marker gets an auto icon + temperature (split a multi-entity device)', () => {
const h = mkHass({
entities: {
'sensor.clim_temp': { entity_id: 'sensor.clim_temp', device_id: 'clim', platform: 'demo' },
'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' },
},
states: {
'sensor.clim_temp': { state: '22.5', attributes: { device_class: 'temperature', friendly_name: 'Climate Temperature' } },
'sensor.clim_hum': { state: '55', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } },
},
});
const ctx = baseCtx(h, { markers: [
{ id: 'e1', binding: 'entity:sensor.clim_temp' },
{ id: 'e2', binding: 'entity:sensor.clim_hum' },
] });
const devs = buildDevices(ctx);
const t = devs.find((d) => d.id === 'e1');
const hum = devs.find((d) => d.id === 'e2');
assert.equal(t.icon, 'mdi:thermometer');
assert.equal(t.temp, 22.5);
// humidity resolves to a real icon (device_class fallback), not the old generic shape
assert.ok(hum && hum.icon && hum.icon !== 'mdi:shape-outline');
assert.equal(hum.primary, 'sensor.clim_hum'); // more-info target
});
test('humFor: reads a humidity entity as an integer %, ignores non-humidity', () => {
const h = mkHass({ states: {
'sensor.h': { state: '54.7', attributes: { device_class: 'humidity' } },
'sensor.t': { state: '22', attributes: { device_class: 'temperature' } },
}});
assert.equal(humFor(h, ['sensor.t', 'sensor.h']), 55); // rounded
assert.equal(humFor(h, ['sensor.t']), null);
});
test('buildDevices: humidity entity marker shows a humidity badge (item.hum), water-percent icon', () => {
const h = mkHass({
entities: { 'sensor.clim_hum': { entity_id: 'sensor.clim_hum', device_id: 'clim', platform: 'demo' } },
states: { 'sensor.clim_hum': { state: '54.7', attributes: { device_class: 'humidity', friendly_name: 'Climate Humidity' } } },
});
const hum = buildDevices(baseCtx(h, { markers: [{ id: 'e2', binding: 'entity:sensor.clim_hum' }] })).find((d) => d.id === 'e2');
assert.equal(hum.icon, 'mdi:water-percent');
assert.equal(hum.hum, 55);
assert.equal(hum.temp, undefined);
});
test('buildDevices: humidity badge is gated on device_class, not the icon (name may force another icon)', () => {
const h = mkHass({
entities: { 'sensor.mh_hum': { entity_id: 'sensor.mh_hum', device_id: 'mh', platform: 'demo' } },
// name contains "myheat" → icon rule resolves to water-boiler, NOT water-percent…
states: { 'sensor.mh_hum': { state: '45.2', attributes: { device_class: 'humidity', friendly_name: 'Myheat Влажность 1 этаж' } } },
});
const d = buildDevices(baseCtx(h, { markers: [{ id: 'e', binding: 'entity:sensor.mh_hum' }] })).find((x) => x.id === 'e');
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)
});
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const en = JSON.parse(readFileSync(new URL('../src/i18n/en.json', import.meta.url)));
const ru = JSON.parse(readFileSync(new URL('../src/i18n/ru.json', import.meta.url)));
test('i18n: en and ru dictionaries carry the same key set', () => {
const enKeys = Object.keys(en).sort();
const ruKeys = Object.keys(ru).sort();
assert.deepEqual(enKeys, ruKeys);
});
test('i18n: no empty values', () => {
for (const [lang, d] of [['en', en], ['ru', ru]]) {
for (const [k, v] of Object.entries(d)) {
assert.ok(typeof v === 'string' && v.length > 0, `${lang}:${k} is empty`);
}
}
});
test('i18n: placeholders match between languages', () => {
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
for (const k of Object.keys(en)) {
assert.equal(ph(en[k]), ph(ru[k]), `placeholder mismatch in ${k}`);
}
});
+357 -2
View File
@@ -2,9 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
fitView, declump, safeUrl,
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle,
} from '../test-build/logic.js';
import { iconFor } from '../test-build/rules.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
test('lqiColor: boundaries and midpoint', () => {
assert.equal(lqiColor(40), 'hsl(0, 85%, 55%)');
@@ -128,3 +132,354 @@ test('safeUrl: allows http(s) and relative paths, cuts dangerous schemes', () =>
assert.equal(safeUrl(undefined), null);
assert.equal(safeUrl(' https://x.ru '), 'https://x.ru');
});
test('icon rules: custom rules override defaults, first match wins', () => {
const custom = compileIconRules([
{ pattern: 'plug|socket', icon: 'mdi:custom-plug' },
{ pattern: 'plug deluxe', icon: 'mdi:never-reached' },
]);
assert.equal(iconFor('Smart Plug deluxe', '', custom), 'mdi:custom-plug');
// defaults are NOT consulted when custom rules are provided
assert.equal(iconFor('Датчик протечки', '', custom), 'mdi:chip');
});
test('icon rules: invalid regex is skipped, the rest still work', () => {
const compiled = compileIconRules([
{ pattern: '[unclosed', icon: 'mdi:broken' },
{ pattern: 'camera', icon: 'mdi:cctv' },
]);
assert.equal(compiled.length, 1);
assert.equal(iconFor('Backyard camera', '', compiled), 'mdi:cctv');
});
test('icon rules: isValidPattern flags bad regexes', () => {
assert.equal(isValidPattern('plug|socket'), true);
assert.equal(isValidPattern('[unclosed'), false);
});
test('icon rules: defaults are bilingual', () => {
assert.equal(iconFor('Water leak sensor'), 'mdi:water-alert');
assert.equal(iconFor('Датчик протечки кухня'), 'mdi:water-alert');
assert.equal(iconFor('Umidifier presence sensor'), 'mdi:motion-sensor');
});
test('icon rules: device_class fallback', () => {
assert.equal(iconFromDeviceClasses(['temperature']), 'mdi:thermometer');
assert.equal(iconFromDeviceClasses(['unknown', 'motion']), 'mdi:motion-sensor');
assert.equal(iconFromDeviceClasses(['unknown']), null);
assert.equal(iconFromDeviceClasses([]), null);
});
test('tap action: defaults to info', () => {
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info');
assert.equal(resolveTapAction(null, 'info', 'switch'), 'info');
assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info');
});
test('tap action: card-wide toggle only touches safe domains', () => {
assert.equal(resolveTapAction(null, 'toggle', 'light'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'switch'), 'toggle');
assert.equal(resolveTapAction(null, 'toggle', 'cover'), 'info'); // garage stays shut
assert.equal(resolveTapAction(null, 'toggle', 'valve'), 'info');
assert.equal(resolveTapAction(null, 'toggle', 'sensor'), 'info');
});
test('tap action: explicit per-device toggle works for cover but never for lock/alarm', () => {
assert.equal(resolveTapAction('toggle', 'info', 'cover'), 'toggle'); // conscious choice
assert.equal(resolveTapAction('toggle', 'toggle', 'lock'), 'info'); // hard security block
assert.equal(resolveTapAction('toggle', 'toggle', 'alarm_control_panel'), 'info');
assert.equal(resolveTapAction('toggle', 'info', undefined), 'info'); // no entity → nothing to toggle
});
test('tap action: per-device override beats the card default', () => {
assert.equal(resolveTapAction('info', 'toggle', 'light'), 'info');
assert.equal(resolveTapAction('more-info', 'toggle', 'light'), 'more-info');
});
test('floorsOf: sorts by level, tolerates missing registry and odd entries', () => {
assert.deepEqual(floorsOf({}), []);
assert.deepEqual(floorsOf({ floors: null }), []);
const hass = { floors: {
a: { floor_id: 'attic', name: 'Attic', level: 2 },
g: { floor_id: 'ground', name: 'Ground', level: 0 },
x: { floor_id: 'x', name: 'No level' },
bad: null,
}};
const res = floorsOf(hass);
assert.deepEqual(res.map((f) => f.id), ['ground', 'attic', 'x']);
});
test('subst: replaces every occurrence of a placeholder, ignores unknown', () => {
assert.equal(subst('{n} of {n} ({x})', { n: 2, x: 'y' }), '2 of 2 (y)');
assert.equal(subst('no vars'), 'no vars');
assert.equal(subst('keep {unknown}', { n: 1 }), 'keep {unknown}');
});
test('spaceDisplayOf: defaults differ for spaces with and without a plan', () => {
const withPlan = spaceDisplayOf({ plan_url: '/x.svg' });
assert.equal(withPlan.showBorders, false);
assert.equal(withPlan.showNames, false);
assert.equal(withPlan.fill, 'none');
const noPlan = spaceDisplayOf({ plan_url: null });
assert.equal(noPlan.showBorders, true);
assert.equal(noPlan.showNames, true);
const s = spaceDisplayOf({ plan_url: null, settings: { show_borders: false, room_color: '#ff0000', room_opacity: 2, fill_mode: 'lqi' } });
assert.equal(s.showBorders, false);
assert.equal(s.color, '#ff0000');
assert.equal(s.opacity, 1);
assert.equal(s.fill, 'lqi');
const g = spaceDisplayOf({ settings: { room_color: 'javascript:alert(1)', fill_mode: 'weird' } });
assert.equal(g.color, '#3ea6ff');
assert.equal(g.fill, 'none');
});
test('roomFillColor: lqi gradient, light tri-state, none', () => {
assert.equal(roomFillColor('none', 200, 'on'), null);
assert.equal(roomFillColor('lqi', null, 'on'), null);
assert.equal(roomFillColor('lqi', 180, 'none'), 'hsl(120, 85%, 55%)');
assert.equal(roomFillColor('light', null, 'on'), '#ffd45c');
assert.equal(roomFillColor('light', null, 'off'), '#9aa0a6');
assert.equal(roomFillColor('light', null, 'none'), null);
});
test('roomFillColor temp: blue/green/yellow bands, swapped bounds tolerated, no reading → no fill', () => {
assert.equal(roomFillColor('temp', null, 'none', 18, 20, 25), '#4fc3f7'); // cold
assert.equal(roomFillColor('temp', null, 'none', 20, 20, 25), '#66d17a'); // lower bound inclusive
assert.equal(roomFillColor('temp', null, 'none', 25, 20, 25), '#66d17a'); // upper bound inclusive
assert.equal(roomFillColor('temp', null, 'none', 26.5, 20, 25), '#ffd45c'); // hot
assert.equal(roomFillColor('temp', null, 'none', 18, 25, 20), '#4fc3f7'); // swapped bounds
assert.equal(roomFillColor('temp', null, 'none', null, 20, 25), null);
assert.equal(roomFillColor('temp', null, 'none', undefined, 20, 25), null);
});
test('spaceDisplayOf: temp bounds default to 20..25 and accept overrides', () => {
const d = spaceDisplayOf({ plan_url: '/x.svg' });
assert.equal(d.tempMin, 20);
assert.equal(d.tempMax, 25);
const o = spaceDisplayOf({ settings: { temp_min: 18.5, temp_max: 23 } });
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: nested, identical and enclosing outlines all count as overlap', () => {
assert.ok(roomsOverlap(SQ, [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]])); // nested
assert.ok(roomsOverlap(SQ, SQ)); // duplicate
// drawn AROUND an existing room: every vertex outside, no vertex of ours inside it
assert.ok(roomsOverlap([[-1, -1], [3, -1], [3, 3], [-1, 3]], SQ));
// 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]]));
});
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 });
});
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos,
} from '../test-build/space-geometry.js';
const cfg = {
spaces: [{
id: 'f1', title: '1st', aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
}, {
id: 'yard', title: 'Yard', aspect: 1, view_box: [0, 0, 1, 1], rooms: [],
}],
markers: [], settings: {},
};
test('spaceModels: scales vb/rooms by NORM_W and H=NORM_W/aspect; bg only with plan_url', () => {
const m = spaceModels(cfg);
assert.equal(m.length, 2);
const f1 = m[0];
assert.deepEqual(f1.vb, [0, 0, 1000, 500]); // aspect 2 → H 500
assert.equal(f1.bg.href, '/plans/f1.svg');
assert.deepEqual(f1.rooms[0].poly, [[100, 50], [500, 50], [500, 250], [100, 250]]);
assert.equal(m[1].bg, null); // no plan_url
assert.equal(spaceModels(null).length, 0);
});
test('roomBounds + roomCenter for a polygon', () => {
const r = spaceModels(cfg)[0].rooms[0];
assert.deepEqual(roomBounds(r), { x: 100, y: 50, w: 400, h: 200 });
assert.deepEqual(roomCenter(r), [300, 150]);
});
test('markerPos: saved layout → default grid → space centre', () => {
const model = spaceModels(cfg)[0];
const dev = { id: 'd1', space: 'f1', area: 'a1', entities: [] };
// saved layout (normalized) → render units
assert.deepEqual(
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
{ x: 200, y: 150 }, // 0.2*1000, 0.3*(1000/2)
);
// default grid position (inside the room)
const defPos = defaultPositions([dev], model, 2.5);
assert.ok(defPos.d1);
assert.deepEqual(markerPos(dev, {}, cfg, defPos, model), defPos.d1);
const b = roomBounds(model.rooms[0]);
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
// no layout, no defPos → space centre
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 250 });
});
test('labelPos: saved rl_<id> → render units; else room centre', () => {
const model = spaceModels(cfg)[0];
const r = model.rooms[0];
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 200 });
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 150 }); // room centre
});
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
const model = spaceModels(cfg)[0];
const devs = [0, 1, 2, 3].map((i) => ({ id: 'd' + i, space: 'f1', area: 'a1', entities: [] }));
const pos = defaultPositions(devs, model, 2.5);
assert.equal(Object.keys(pos).length, 4);
const keys = Object.keys(pos);
for (let i = 0; i < keys.length; i++)
for (let j = i + 1; j < keys.length; j++) {
const a = pos[keys[i]], b = pos[keys[j]];
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) > 1, 'positions distinct');
}
});
test('NORM_W is 1000', () => assert.equal(NORM_W, 1000));
+28
View File
@@ -97,3 +97,31 @@ def test_layout_schema():
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1, "y": 0.2, "s": "f1"}})
with pytest.raises(vol.Invalid):
v.LAYOUT_SCHEMA({"dev1": {"x": 0.1}})
def test_space_display_settings():
"""Per-space display settings validate; garbage color/mode is rejected."""
ok = {
"id": "f1", "title": "Floor 1", "aspect": 1.0, "view_box": [0, 0, 1, 1],
"rooms": [], "settings": {
"show_borders": True, "show_names": False,
"room_color": "#3ea6ff", "room_opacity": 0.5, "fill_mode": "lqi",
},
}
v.SPACE_SCHEMA(ok)
import pytest as _pytest
bad_color = dict(ok, settings={"room_color": "javascript:x"})
with _pytest.raises(Exception):
v.SPACE_SCHEMA(bad_color)
bad_mode = dict(ok, settings={"fill_mode": "rainbow"})
with _pytest.raises(Exception):
v.SPACE_SCHEMA(bad_mode)
def test_space_temp_bounds():
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
ok = {
"id": "f1", "title": "F", "aspect": 1.0, "view_box": [0, 0, 1, 1], "rooms": [],
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
}
v.SPACE_SCHEMA(ok)
+10 -3
View File
@@ -3,14 +3,21 @@
"target": "es2021",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["es2021", "dom", "dom.iterable"],
"lib": [
"es2021",
"dom",
"dom.iterable"
],
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"outDir": "tsout"
"outDir": "tsout",
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
"include": [
"src/**/*.ts"
]
}
+15 -3
View File
@@ -1,5 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "module": "esnext", "outDir": "test-build", "noEmit": false, "declaration": false },
"include": ["src/logic.ts", "src/rules.ts"]
}
"compilerOptions": {
"module": "esnext",
"outDir": "test-build",
"noEmit": false,
"declaration": false,
"resolveJsonModule": true
},
"include": [
"src/logic.ts",
"src/rules.ts",
"src/devices.ts",
"src/types.ts",
"src/space-geometry.ts"
]
}