Compare commits

...
Author SHA1 Message Date
Matysh b1deb0beab Avoid hassfest manifest collision
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m52s
Validate / backend (push) Failing after 8m33s
Validate / golden (push) Failing after 6m9s
Validate / smoke (push) Failing after 22m51s
2026-08-08 22:03:09 +03:00
Matysh 9590011ddb Add reviewed Linux golden baselines 2026-08-08 21:52:26 +03:00
Matysh ddbd3288fe Fix prerelease smoke regressions 2026-08-08 21:42:49 +03:00
Matysh e177c14603 v1.60.3-beta.1: harden rendering and QA tooling 2026-08-08 21:33:09 +03:00
Matysh 164f7cf76a chore(hacs): zip_release + workflow attaching houseplan.zip to every release
HACS will install from the named asset instead of the auto zipball, so
GitHub's public download counter becomes a per-version install metric.
The workflow also has a manual dispatch to backfill an existing release.
2026-08-08 19:43:24 +03:00
Matysh 2219700d63 Release v1.60.2
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 3m58s
Validate / backend (push) Failing after 8m11s
Validate / smoke (push) Failing after 22m39s
2026-08-08 17:39:25 +03:00
Matysh 854a6944a0 test: align smokes with active registry projection 2026-08-08 15:55:33 +03:00
Matysh 2a8302f4d6 v1.60.2-beta.3: unify boundaries and device presentation 2026-08-08 15:46:07 +03:00
Matysh f1537b2108 v1.60.2-beta.2: harden device state and editor interactions
Validate / hassfest (push) Failing after 19s
Validate / hacs (push) Failing after 20s
Validate / frontend (push) Successful in 3m2s
Validate / backend (push) Failing after 9m17s
Validate / smoke (push) Failing after 14m1s
2026-08-08 00:12:47 +03:00
Matysh 5e1315f61d ci: announce releases and prereleases in the Telegram chat
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 23s
Validate / frontend (push) Successful in 3m38s
Validate / smoke (push) Failing after 53s
Validate / backend (push) Failing after 7m14s
2026-08-07 22:35:10 +03:00
Matysh 4e29db1afb test: settle zoom baseline after editor collapse 2026-08-07 22:18:03 +03:00
Matysh 953063b984 fix: keep wall thickness beside room outline 2026-08-07 22:09:42 +03:00
Matysh d48d220a8c v1.60.2-beta.1: add persistent physical geometry 2026-08-07 22:02:41 +03:00
Matysh 1c949ae49d test: poll navigation background transitions
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 4m38s
Validate / backend (push) Failing after 10m49s
Validate / smoke (push) Failing after 25m55s
2026-08-07 14:39:24 +03:00
Matysh 9956a6cfe6 test: settle navigation transitions in browser smokes 2026-08-07 14:31:02 +03:00
Matysh fe5f5b6a24 v1.60.1-beta.1: harden editing and device state 2026-08-07 14:19:02 +03:00
Matysh 6db9eb0a66 test: use access token for non-admin websocket
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 4m33s
Validate / backend (push) Failing after 10m31s
Validate / smoke (push) Failing after 20m7s
2026-08-07 13:10:27 +03:00
Matysh 3028122016 v1.60.0: harden background editing and device state 2026-08-07 13:02:46 +03:00
185 changed files with 23173 additions and 3873 deletions
+14
View File
@@ -0,0 +1,14 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.mp4 binary
*.webm binary
*.zip binary
*.woff binary
*.woff2 binary
+42
View File
@@ -0,0 +1,42 @@
name: Announce release
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
# Fires on every published release, prereleases included: the workflow file
# lives at the TAGGED commit, so it works for beta tags cut from dev as soon
# as this file is on dev, and for stable tags once it reaches main.
# workflow_dispatch exists purely as a connectivity test button.
on:
release:
types: [published]
workflow_dispatch: {}
permissions: {}
jobs:
telegram:
runs-on: ubuntu-latest
steps:
- name: Send to Telegram
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ github.event.release.tag_name }}
NAME: ${{ github.event.release.name }}
URL: ${{ github.event.release.html_url }}
PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
KIND=$([ "$PRE" = "true" ] && echo "🧪 Пре-релиз" || echo "🏠 Релиз")
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
fi
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true
+40
View File
@@ -0,0 +1,40 @@
name: Attach HACS zip to release
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
# dev, stable tags once it reaches main.
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
# once for the latest stable after the hacs.json change reaches main).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach the zip to"
required: true
permissions:
contents: write
jobs:
zip:
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: unzip -l houseplan.zip | grep -q "manifest.json"
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
+42 -4
View File
@@ -32,8 +32,10 @@ jobs:
run: npm test
- name: Build
run: npm run build
- name: Card bundle in sync with integration
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
- name: Card bundle snapshots in sync
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
smoke:
# audit T2: the end-to-end layer used to run only when a human remembered.
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
@@ -47,8 +49,8 @@ jobs:
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a FRESH bundle for the smokes
# the committed demo/srv/assets copy is a snapshot; testing it would
# report green about code that no longer exists (audit T2)
# Never depend on a previous job's artefact; build the exact source
# under test again before the browser suite.
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
@@ -72,10 +74,46 @@ jobs:
name: smoke-logs
path: /tmp/smoke-logs
golden:
# Separate deterministic pixel layer. Before the first reviewed baseline
# set it captures candidates; once baselines exist the same job becomes a
# blocking comparison and uploads actual/diff/report on failure.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
- name: Build the exact source under review
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture or verify golden matrix
id: golden
run: |
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
npm run golden:verify
else
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
npm run golden:capture
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v4
with:
name: golden-images
path: artifacts/golden
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
+1
View File
@@ -5,3 +5,4 @@ test-build/
__pycache__/
.pytest_cache/
.venv-backend/
artifacts/
+14 -3
View File
@@ -12,9 +12,11 @@ Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/D
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed demo snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a stale demo bundle. Build and copy the current bundle first, then review `artifacts/golden/actual/` and `diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the complete Linux CI artifact; never accept a partial scenario or images merely to make CI green. See `demo/golden/README.md`.
- **Freshness contract**: the embedded fingerprint covers `src/` plus Rollup, TypeScript and package-lock build inputs. Benchmark and golden tooling must call `assertFreshDemoBundle` before recording any result; a missing or mismatched fingerprint is a hard failure, not a warning.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
- **Owner's local Windows checkout is invisible here.** Path
@@ -25,3 +27,12 @@ The startup update script already runs `npm ci`, provisions a Python 3.13 backen
Agent / My Machines worker inside that checkout. Day-to-day source of truth
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
(releases) — see `docs/STATUS.md`.
## Promotion rule
Every new feature or material behaviour change must be published as a beta/RC
before it can enter a stable release, even when its local audit is clean. The
stable release commit is promotion-only: version fields, generated bundle
snapshots and changelog/release metadata. Do not add feature source code in
that commit. An explicit owner-requested emergency hotfix is the only exception
and must be called out in the release handoff.
+16 -7
View File
@@ -15,6 +15,11 @@ room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wa
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
> ### 🚀 Try it live — no install needed
@@ -114,7 +119,9 @@ Key advantages in short:
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
a light is shining, a socket is powering, a fan is spinning, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
that mode is the best available fallback. Orange = open / unlocked.
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
A translucent icon = unavailable. Dark = idle.
@@ -227,15 +234,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
- **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
### Doors, windows, gates and locks
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
In markup mode the **"Opening"** tool places doors, windows and gates: 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**.
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — 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
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
passage work exactly like a door. A door or gate 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.
@@ -246,7 +255,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
@@ -332,7 +341,7 @@ Services → House Plan**.
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
+15 -8
View File
@@ -15,6 +15,11 @@
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
> ### 🚀 Попробовать вживую — без установки
@@ -31,7 +36,7 @@
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
@@ -112,7 +117,9 @@ draw-инструмент. При наличии плана и зон HA House P
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
@@ -224,15 +231,15 @@ title: План дома
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
@@ -243,7 +250,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти скрытые устройства, откройте редактор устройств и нажмите **«Показать скрытые»**: они отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -330,7 +337,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто автоматически — откройте редактор устройств, нажмите **«Показать скрытые»**, откройте его диалог, нажмите **«Показать»**, затем **«Сохранить»**.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.60.0-beta.1"
VERSION = "1.60.3-beta.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -31,6 +31,9 @@ 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")),
"room_drafts": len(s.get("room_drafts", [])),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
}
for s in config.get("spaces", [])
],
File diff suppressed because one or more lines are too long
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for draft in space.get("room_drafts") or []:
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
for part in space.get("partitions") or []:
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
for column in space.get("wall_columns") or []:
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.60.0-beta.1"
"version": "1.60.3-beta.1"
}
@@ -27,6 +27,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"config_rev": cfg_raw.get("rev", 0),
"spaces": len(config.get("spaces", [])),
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
+20 -13
View File
@@ -147,16 +147,7 @@ class TrailRecorder:
# marker records its own copy of the run
pairs.setdefault(src, []).append((str(m.get("id")), vac))
self.pairs = pairs
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
self._resubscribe()
# A run already in progress (HA restarted mid-cleanup, or the user
# just finished calibrating) must start recording NOW, not at the
# next state change — otherwise the first seconds of the path are
@@ -167,15 +158,18 @@ class TrailRecorder:
async def async_delete(self, marker: str) -> bool:
"""Stop and erase one marker without racing subscription refresh/save."""
async with self._refresh_lock:
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = self.book.delete(marker)
if not removed:
return False
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
removed = self.book.delete(marker)
if not removed:
return False
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
@@ -183,6 +177,19 @@ class TrailRecorder:
self.hass.bus.async_fire("houseplan_trail_updated", {})
return True
def _resubscribe(self) -> None:
"""Replace the state subscription for the current pair graph."""
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents and not self._closed:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
def teardown(self) -> None:
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
# not re-subscribe after this cleanup has already run
+121 -5
View File
@@ -69,6 +69,10 @@ MAX_MARKERS = 2000
MAX_OPENINGS = 500
MAX_DECOR = 1000
MAX_WALLS = 500
MAX_ROOM_DRAFTS = 200
MAX_DRAFT_SEGMENTS = 2000
MAX_PARTITIONS = 2000
MAX_WALL_COLUMNS = 500
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
# Every span is a piece of a shared boundary, so there can never be more of
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
@@ -95,6 +99,8 @@ MAX_URL = 2000
# they can act on. For scale, a real three-floor home with ~200 devices stores
# about 70 KB, so this is ~30x headroom.
MAX_CONFIG_BYTES = 2 * 1024 * 1024
CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
@@ -312,7 +318,8 @@ _NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
DECOR_SCHEMA = vol.Any(
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
@@ -385,10 +392,107 @@ WALL_SCHEMA = vol.All(
_wall_endpoints_pair,
)
SPACE_SCHEMA = vol.Schema(
def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
raise vol.Invalid("room draft consecutive points must differ")
return value
ROOM_DRAFT_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
vol.Required("segments"): vol.All(
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
extra=vol.ALLOW_EXTRA)],
vol.Length(min=1, max=499),
),
},
extra=vol.ALLOW_EXTRA,
),
_room_draft_segments,
)
def _partition_nonzero(value: dict) -> dict:
if value["a"] == value["b"]:
raise vol.Invalid("partition endpoints must differ")
return value
PARTITION_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("a"): POINT,
vol.Required("b"): POINT,
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
},
extra=vol.ALLOW_EXTRA,
),
_partition_nonzero,
)
def _strict_wall_column(value: dict) -> dict:
"""Reject shape-inapplicable or non-canonical column fields."""
if value["shape"] == "circle" and "angle" in value:
raise vol.Invalid("angle is allowed only for square wall columns")
if value["shape"] == "square" and value.get("angle", 0) >= 90:
raise vol.Invalid("square wall column angle must be in [0, 90)")
return value
WALL_COLUMN_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("shape"): vol.In(["square", "circle"]),
vol.Required("center"): POINT,
# Outer side for a square, outer diameter for a circle.
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
vol.Optional("angle"): vol.All(
_finite, vol.Range(min=0, max=90)
),
},
extra=vol.ALLOW_EXTRA,
),
_strict_wall_column,
)
def _space_geometry_invariants(value: dict) -> dict:
"""All stored geometry shares ids; draft segments also have a space cap."""
seen: set[str] = set()
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
for item in value.get(key, []):
item_id = item.get("id")
if not item_id:
continue
if item_id in seen:
raise vol.Invalid("geometry object ids must be unique within a space")
seen.add(item_id)
draft_segments = sum(
len(item.get("segments", [])) for item in value.get("room_drafts", [])
)
if draft_segments > MAX_DRAFT_SEGMENTS:
raise vol.Invalid("too many saved room-draft segments")
return value
SPACE_SCHEMA = vol.All(vol.Schema(
{
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
vol.Required("title"): str,
# Physical grid scale. It feeds every px/cell -> centimetres migration,
# so NaN/Infinity or an absurd value must not be allowed to manufacture
# invalid decor sizes later.
vol.Optional("cell_cm"): vol.All(
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
),
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
vol.Optional("plan_url"): vol.Any(str, None),
# The canvas is square since v1.48.0. What used to be the space's own
@@ -429,7 +533,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Schema(
{
vol.Required("id"): str,
vol.Required("type"): vol.Any("door", "window"),
vol.Required("type"): vol.Any("door", "window", "gate"),
vol.Required("x"): _GEOM,
vol.Required("y"): _GEOM,
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
@@ -449,6 +553,15 @@ SPACE_SCHEMA = vol.Schema(
# (midpoint + direction), thickness always in centimetres. Optional —
# a space without `walls` validates and renders exactly as before.
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
vol.Optional("room_drafts"): vol.All(
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
),
vol.Optional("partitions"): vol.All(
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
),
vol.Optional("wall_columns"): vol.All(
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
),
# Open (virtual) wall stretches: a piece of a shared boundary that the
# user opened. Optional and bounded — a space without `open_spans`
# validates exactly as before, and the legacy `rooms[].open_to` index
@@ -465,7 +578,7 @@ SPACE_SCHEMA = vol.Schema(
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
), _space_geometry_invariants)
MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
@@ -519,7 +632,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("room_id"): vol.Any(str, None),
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
# offered, but remains accepted while old stores migrate to icon_ripple.
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
vol.Optional("ripple_color"): vol.Any(
None, vol.Match(r"^#[0-9a-fA-F]{6}$")
),
@@ -546,6 +659,9 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
+19 -3
View File
@@ -116,7 +116,9 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An
HA device ids may be layout-only because auto-discovered devices need no
marker entry. Virtual ids are different: every live virtual marker is
explicit, so a missing `v_*` owner is always stale data.
explicit, so a missing `v_*` owner is stale data. The prefix itself is
only a legacy naming convention, however; an explicit real marker remains
authoritative even when its id happens to begin with `v_`.
"""
markers = config.get("markers") or []
removed_ids = {
@@ -126,10 +128,16 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") == "virtual"
}
explicit_live_ids = {
str(m.get("id")) for m in markers
if m.get("removed") is not True and m.get("binding") != "virtual"
}
return {
marker_id: pos for marker_id, pos in layout.items()
if marker_id not in removed_ids
and (not marker_id.startswith("v_") or marker_id in virtual_ids)
and (not marker_id.startswith("v_")
or marker_id in virtual_ids
or marker_id in explicit_live_ids)
}
@@ -224,7 +232,15 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
and m.get("binding") == "virtual"
for m in markers
)
orphan_virtual = msg["device_id"].startswith("v_") and not live_virtual
live_explicit = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
for m in markers
)
orphan_virtual = (
msg["device_id"].startswith("v_")
and not live_virtual
and not live_explicit
)
if deleted or orphan_virtual:
data = await rt.store.async_load() or {}
connection.send_result(msg["id"], {
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* Reproducible browser baseline for HP-PERF-01.
*
* This is a benchmark, not a pass/fail test. It intentionally records data
* before budgets are approved; using an arbitrary local number as a CI gate
* would make the result less trustworthy, not more.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
const sampleArg = process.argv.find((arg) => arg.startsWith('--samples='));
const outputArg = process.argv.find((arg) => arg.startsWith('--output='));
const samples = Math.max(1, Math.min(20, Number(sampleArg?.split('=')[1]) || 3));
const output = outputArg ? resolve(outputArg.slice('--output='.length)) : null;
const fixture = makeLargeHouseFixture();
const percentile = (values, p) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))];
};
const { page, browser } = await launch(
{ width: 1440, height: 1000 },
1,
['--enable-precise-memory-info'],
);
await page.emulateMedia({ reducedMotion: 'reduce' });
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let sample = 0; sample < samples; sample++) {
rows.push(await page.evaluate(async ({ fixture, sample }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const duration = async (action) => {
const started = performance.now();
await action();
await frame();
return Number((performance.now() - started).toFixed(2));
};
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
const hassFor = (states) => ({
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
return { ok: true };
},
callService: async () => undefined,
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const loadStarted = performance.now();
host.replaceChildren(card);
card.hass = hassFor(fixture.states);
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
await card.updateComplete;
await frame();
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
await until(() => card._booting === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const steadyHeap = performance.memory?.usedJSHeapSize ?? null;
if (card._openingTunnelCache === undefined || card._openingWallIndexCache === undefined) {
throw new Error('loaded bundle does not expose the expected opening geometry caches');
}
const spaceSwitchMs = await duration(async () => {
card._pickSpace('perf-floor-2');
await card.updateComplete;
});
const firstEntity = Object.keys(fixture.states)[0];
const nextStates = {
...fixture.states,
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
};
const stateUpdateMs = await duration(async () => {
card.hass = hassFor(nextStates);
await card.updateComplete;
});
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const panZoomMs = await duration(async () => {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
await card.updateComplete;
});
const settingsDialogMs = await duration(async () => {
card._openSettingsDialog();
await card.updateComplete;
});
card._settingsDialog = null;
await card.updateComplete;
const switchCycleMs = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
}
});
const result = {
sample,
modelReadyMs,
firstStableRenderMs,
spaceSwitchMs,
stateUpdateMs,
panZoomMs,
settingsDialogMs,
switchCycleMs,
cacheEntries: {
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
},
heapGrowthBytes: steadyHeap == null ? null : performance.memory.usedJSHeapSize - steadyHeap,
renderedDevices: card._devices?.length ?? 0,
};
card.remove();
await frame();
return result;
}, { fixture, sample }));
}
} finally {
await browser.close();
}
const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
const summary = Object.fromEntries(metricNames.map((metric) => {
const values = rows.map((row) => row[metric]);
return [metric, {
median: percentile(values, 0.5),
p95: percentile(values, 0.95),
min: Math.min(...values),
max: Math.max(...values),
}];
}));
const report = {
schema: 1,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: { node: process.version, chromium, platform: process.platform, arch: process.arch },
fixture: LARGE_HOUSE_COUNTS,
samples,
summary,
rows,
note: 'Baseline only. Budgets must be approved from repeated CI-profile measurements.',
};
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else {
process.stdout.write(text);
}
+15
View File
@@ -0,0 +1,15 @@
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
const expected = sourceFingerprint(root);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
);
}
return expected;
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Deterministic, fictional high-load fixture shared by performance and future
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
const OPENING_COUNT = 100;
const PARTITION_COUNT = 60;
const COLUMN_COUNT = 40;
const DECOR_COUNT = 500;
const round = (value) => Number(value.toFixed(6));
const roomGrid = (floor) => {
const rooms = [];
const left = 0.04;
const top = 0.04;
const width = 0.92 / 5;
const height = 0.92 / 4;
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const index = row * 5 + column;
const x1 = round(left + column * width);
const y1 = round(top + row * height);
const x2 = round(x1 + width);
const y2 = round(y1 + height);
rooms.push({
id: `perf-room-${floor}-${index}`,
name: `Room ${floor + 1}.${index + 1}`,
area: `perf_area_${floor}_${index}`,
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
});
}
}
return rooms;
};
const wallSegments = (rooms) => {
const unique = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!unique.has(reverse)) unique.set(forward, { a, b });
});
}
return [...unique.values()];
};
const makeOpenings = (floor, walls, count) => walls.slice(0, count).map((wall, index) => {
const horizontal = Math.abs(wall.b[0] - wall.a[0]) >= Math.abs(wall.b[1] - wall.a[1]);
return {
id: `perf-opening-${floor}-${index}`,
type: index % 7 === 0 ? 'window' : index % 11 === 0 ? 'gate' : 'door',
x: round((wall.a[0] + wall.b[0]) / 2),
y: round((wall.a[1] + wall.b[1]) / 2),
angle: horizontal ? 0 : 90,
length: horizontal ? 0.045 : 0.055,
};
});
const makePartitions = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[index % rooms.length];
const [a, , c] = room.poly;
const y = round(a[1] + (c[1] - a[1]) * (0.35 + (index % 3) * 0.12));
return {
id: `perf-partition-${floor}-${index}`,
a: [round(a[0] + 0.035), y],
b: [round(c[0] - 0.035), y],
cm: 10 + (index % 3) * 5,
};
});
const makeColumns = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[(index * 3) % rooms.length];
const [a, , c] = room.poly;
return {
id: `perf-column-${floor}-${index}`,
shape: index % 3 === 0 ? 'circle' : 'square',
center: [
round(a[0] + (c[0] - a[0]) * (0.28 + (index % 2) * 0.44)),
round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 1) % 2) * 0.44)),
],
cm: 25 + (index % 4) * 5,
...(index % 3 === 0 ? {} : { angle: (index % 6) * 15 }),
};
});
const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) => {
const column = index % 25;
const row = Math.floor(index / 25);
const x = round(0.02 + column * 0.039);
const y = round(0.018 + (row % 20) * 0.048);
if (index % 10 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'text', x, y, text: `F${floor + 1}-${index}`, size_cm: 14,
color: '#59636e', opacity: 0.75,
};
}
if (index % 3 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'rect', x, y, w: 0.022, h: 0.018, angle: (index % 12) * 5,
color: '#687681', opacity: 0.55, width_cm: 1.5,
fill: index % 2 === 0, fill_color: '#75838e', fill_opacity: 0.12,
};
}
return {
id: `perf-decor-${floor}-${index}`,
kind: 'line', x1: x, y1: y, x2: round(x + 0.025), y2: round(y + (index % 2 ? 0.012 : 0)),
color: '#687681', opacity: 0.6, width_cm: 1.2,
...(index % 9 === 0 ? { line_style: 'dashed' } : {}),
};
});
const entityKinds = [
['light', 'on'],
['switch', 'off'],
['sensor', '21.5'],
['binary_sensor', 'off'],
['climate', 'heat'],
['media_player', 'playing'],
['cover', 'closed'],
['fan', 'on'],
['lock', 'locked'],
['vacuum', 'docked'],
];
const makeRuntime = (spaces) => {
const devices = {};
const entities = {};
const states = {};
const areas = {};
const layout = {};
const roomRefs = spaces.flatMap((space) => space.rooms.map((room) => ({ space, room })));
for (const { room } of roomRefs) areas[room.area] = { area_id: room.area, name: room.name };
for (let index = 0; index < DEVICE_COUNT; index++) {
const { space, room } = roomRefs[index % roomRefs.length];
const [domain, baseState] = entityKinds[index % entityKinds.length];
const deviceId = `perf-device-${index}`;
const entityId = `${domain}.perf_${index}`;
devices[deviceId] = {
id: deviceId,
name: `Synthetic ${domain} ${index + 1}`,
model: `PERF-${String(index + 1).padStart(3, '0')}`,
area_id: room.area,
identifiers: [['houseplan_perf', deviceId]],
config_entries: ['perf_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
entities[entityId] = {
entity_id: entityId,
device_id: deviceId,
platform: 'houseplan_perf',
config_entry_id: 'perf_entry',
disabled_by: null,
};
const attributes = { friendly_name: devices[deviceId].name };
if (domain === 'sensor') Object.assign(attributes, {
device_class: 'temperature', unit_of_measurement: '°C', state_class: 'measurement',
});
if (domain === 'binary_sensor') attributes.device_class = index % 2 ? 'motion' : 'occupancy';
if (domain === 'climate') Object.assign(attributes, { current_temperature: 21.5, temperature: 22 });
states[entityId] = { entity_id: entityId, state: index % 4 === 0 && domain === 'light' ? 'off' : baseState, attributes };
const [a, , c] = room.poly;
layout[deviceId] = {
s: space.id,
x: round(a[0] + (c[0] - a[0]) * (0.2 + (index % 4) * 0.2)),
y: round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 2) % 3) * 0.22)),
};
}
return { devices, entities, states, areas, layout };
};
export const LARGE_HOUSE_COUNTS = Object.freeze({
floors: FLOOR_COUNT,
rooms: FLOOR_COUNT * ROOMS_PER_FLOOR,
devices: DEVICE_COUNT,
openings: OPENING_COUNT,
partitions: PARTITION_COUNT,
columns: COLUMN_COUNT,
decor: DECOR_COUNT,
});
export const makeLargeHouseFixture = () => {
let openingsLeft = OPENING_COUNT;
let partitionsLeft = PARTITION_COUNT;
let columnsLeft = COLUMN_COUNT;
let decorLeft = DECOR_COUNT;
const spaces = Array.from({ length: FLOOR_COUNT }, (_, floor) => {
const rooms = roomGrid(floor);
const segments = wallSegments(rooms);
const floorsRemaining = FLOOR_COUNT - floor;
const openingCount = Math.ceil(openingsLeft / floorsRemaining);
const partitionCount = Math.ceil(partitionsLeft / floorsRemaining);
const columnCount = Math.ceil(columnsLeft / floorsRemaining);
const decorCount = Math.ceil(decorLeft / floorsRemaining);
openingsLeft -= openingCount;
partitionsLeft -= partitionCount;
columnsLeft -= columnCount;
decorLeft -= decorCount;
return {
id: `perf-floor-${floor + 1}`,
title: `Performance floor ${floor + 1}`,
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
wall_columns: makeColumns(floor, rooms, columnCount),
decor: makeDecor(floor, decorCount),
};
});
const runtime = makeRuntime(spaces);
return {
config: { spaces, markers: [], settings: { glow_radius_cm: 300 } },
...runtime,
counts: LARGE_HOUSE_COUNTS,
};
};
+181
View File
@@ -0,0 +1,181 @@
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
const round = (value) => Number(value.toFixed(6));
const uniqueEdges = (rooms) => {
const edges = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
});
}
return [...edges.values()];
};
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
key: `${prefix}-wall-${index}`,
a: edge.a,
b: edge.b,
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
}));
const geometryRooms = [
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
];
const lightingRooms = [
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', show_borders: true, show_names: true,
room_color: '#2d8fce', room_opacity: 0.16,
},
rooms: geometryRooms,
walls: wallsFor('geo', geometryRooms, (edge, index) => {
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
return index % 4 === 0 ? 10 : 15;
}),
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
openings: [
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
],
partitions: [
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
],
wall_columns: [
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
],
decor: [
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
],
};
const lightingSpace = {
id: 'golden-lighting',
title: 'Lighting matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'glow', show_borders: true, show_names: true,
north_deg: 0, sun_rays: true, bg_mode: 'static',
},
rooms: lightingRooms,
walls: wallsFor('light', lightingRooms, (edge) => (
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
)),
openings: [
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
],
partitions: [
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
],
wall_columns: [
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
],
decor: [],
};
const runtime = () => {
const devices = {};
const entities = {};
const states = {
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 24 },
},
};
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
devices[id] = {
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
entry_type: null, via_device_id: null, disabled_by: null,
};
entities[entityId] = {
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
config_entry_id: 'golden_entry', disabled_by: null,
};
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
};
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
});
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
unit_of_measurement: 'lqi',
});
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
return { devices, entities, states, layout, areas };
};
export const VISUAL_MATRIX_COUNTS = Object.freeze({
spaces: 2,
rooms: geometryRooms.length + lightingRooms.length,
openings: geometrySpace.openings.length + lightingSpace.openings.length,
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = () => ({
config: {
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
markers: [],
settings: {
glow_radius_cm: 360,
north_deg: 0,
sun_rays: true,
bg_mode: 'static',
fill_colors: {
glow_base: { c: '#1b2530', a: 0.78 },
glow_light: { c: '#ffd27b', a: 0.70 },
wall_fill: { c: '#d7d9dc', a: 1 },
},
},
},
...runtime(),
counts: VISUAL_MATRIX_COUNTS,
});
+48
View File
@@ -0,0 +1,48 @@
# HP-QA-01 golden images
This layer catches visual regressions that DOM smokes cannot: wall seams and
end caps, thick opening tunnels, Glow/sun clipping, hover contours, editor
chrome, long dialog titles/footers, mobile clipping, themes and zoom/remount.
## Safety contract
- A build fingerprint embedded by Rollup must match `src/`, Rollup/TypeScript
configuration and locked package inputs; stale committed
demo bundles fail before the first screenshot.
- Chromium, viewport, locale, timezone, colour profile, font rendering,
animations and caret are controlled by the runner.
- `capture` writes only to ignored `artifacts/golden/`; it never changes a
baseline and never claims a missing baseline passed. Any scenario runtime
error makes capture fail, including the initial no-baseline CI run.
- `verify` requires every image plus a matching matrix manifest and fails on
missing/different/error scenarios, browser mismatch or a baseline whose hash
no longer matches the reviewed manifest.
- `accept` requires `--reviewed`, a complete candidate report and current
source fingerprint. It validates the whole set before copying anything and
is the only command allowed to update baselines.
## Workflow
Build and copy the exact current source first:
```bash
npm run build
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run golden:capture
```
Review `artifacts/golden/actual/` and, when existing references are present,
`artifacts/golden/diff/`. If every image is intentional:
```bash
npm run golden:accept -- --reviewed
npm run golden:verify
```
Never accept images merely to make CI green. A matrix/framing change increments
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
Future updates must still use the `golden-images` artifact produced by the Linux
CI job as the review set: desktop font rasterisation can differ from the CI
environment even with the same pinned Chromium. Pass its unpacked root via
`--from=...` when accepting it locally.
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
const reportPath = resolve(from, 'golden-report.json');
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
if (report.buildFingerprint !== sourceFingerprint(ROOT))
throw new Error('candidate screenshots were not captured from the current frontend source');
if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const byId = new Map(report.results.map((result) => [result.id, result]));
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(baselineRoot, { recursive: true });
const hashes = {};
const candidates = [];
for (const scenario of GOLDEN_SCENARIOS) {
const result = byId.get(scenario.id);
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
if (!result?.actualSha256 || !existsSync(candidate))
throw new Error(`review candidate missing: ${scenario.id}`);
const bytes = readFileSync(candidate);
const digest = createHash('sha256').update(bytes).digest('hex');
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
candidates.push({ scenario, candidate });
hashes[scenario.id] = digest;
}
// Validate the complete set first: a broken report must never leave a half-
// updated baseline directory behind.
for (const { scenario, candidate } of candidates)
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
schema: 1,
matrixVersion: GOLDEN_MATRIX_VERSION,
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
scenarios: hashes,
}, null, 2)}\n`, 'utf8');
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,29 @@
{
"schema": 1,
"matrixVersion": 2,
"acceptedAt": "2026-08-08T18:51:15.651Z",
"sourceFingerprint": "4d8e60a278cd81ea896a50b94da318b11ba8b2d7cdc3b782a5fb7fbe042702a5",
"chromium": "151.0.7922.34",
"scenarios": {
"geometry-view-dark-fit": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
"geometry-view-light-fit": "a425fa6d77081bb35d2fb81e469962402c6a3c86a8113ff957f28548908d0816",
"geometry-plan-editor-dark": "1c98c2a22843846fe021fd69f8f854a394229216a35f1e22cce381f510415754",
"geometry-devices-editor-dark": "89f677b2ae242236071bea8c4bc9ba88100eb61ba80bd85ad919e288673a0622",
"geometry-decor-editor-dark": "6e8cafb30e47da92b711528bb24c6d29dce094214b81c1175ec30408c6b988e4",
"geometry-diagonal-45-opening-dark": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
"openings-thick-wall-dark": "5d9ac6add9a5709318180f4554ac62c1c7e2862bd016be7901a2bd450e021f0c",
"openings-hidden-view-dark": "799e791830ab63a1c445b8c77650a577d8e881daa1ff47d1dcf4e238eb1cff16",
"lighting-glow-sun-dark": "638ab9fa6856f91581453c732e4059b2a7af47b20d6a58b45a47497876142ea1",
"lighting-fill-light-axis-split-dark": "acdbebe845ede66dbbe9de979aad93ba78dc2779d1ec533b238a9bea0d047d1c",
"lighting-fill-temp-axis-split-dark": "2348b7c6a8594468664755e2fea1b49126e604192599be0a3965f24a0c2d2765",
"lighting-fill-lqi-axis-split-dark": "584adcfb5541b1dddc814c2674bfce8002566bbb667b10501b7ff49fedef6f9c",
"hover-over-glow-dark": "3ca354fa94922a3e38ce15886250ae88630a1e9356e4ac07b74b425f063839d7",
"hover-nested-room-dark": "472f630768f60c3168f64744af891323a64cf71f36f943fc5a56b4f0494efa70",
"large-house-zoom-040-dark": "69593f75f1965daf0a12777fde923ae25c99b42089a73a04cf523b3ea5ce4f0d",
"large-house-zoom-250-dark": "21a1a0408c913f477913ae9c0339417ae7aaad7baad8a890eb558f6e36a5a597",
"large-house-warm-remount-dark": "f3eb077934701cf11bc828da6a68b55346d499a68c323988938b88aca5f5d9fe",
"device-dialog-desktop-en": "f669ff6b975a3d52d06b6eb7535f1af39373001217b7ab65c94a4fb9c3036193",
"device-dialog-mobile-ru": "852b5ced2d056eb1b79d7dcd3218271198a90a7b3ea6e93da5622f75518ca6c6",
"decor-color-popover-mobile-ru": "206f9e8ec7ee1d584561622772b6a4cbdadfcbfeec55c987dffc0107f5ed1293"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+206
View File
@@ -0,0 +1,206 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
const themeVars = {
dark: {
'--primary-color': '#3ea6ff', '--primary-text-color': '#e6e7eb',
'--secondary-text-color': '#9aa4ad', '--card-background-color': '#202126',
'--ha-card-background': '#202126', '--divider-color': '#3a3d45',
},
light: {
'--primary-color': '#0b73b8', '--primary-text-color': '#202124',
'--secondary-text-color': '#5f6368', '--card-background-color': '#ffffff',
'--ha-card-background': '#ffffff', '--divider-color': '#d7d9de',
},
};
async function stableEnvironment(page, scenario) {
await page.setViewportSize(scenario.viewport);
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
await page.evaluate(({ variables, theme }) => {
let style = document.getElementById('hp-golden-stability');
if (!style) {
style = document.createElement('style');
style.id = 'hp-golden-stability';
style.textContent = `
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
scroll-behavior: auto !important;
}
html, body { width: 100%; min-height: 100%; overflow: hidden; }
body { background: var(--hp-golden-page-bg) !important;
font-family: Arial, sans-serif !important; }
#host { width: min(100%, 1120px) !important; margin: 0 auto !important;
padding: 8px !important; box-sizing: border-box !important; }
`;
document.head.appendChild(style);
}
for (const [name, value] of Object.entries(variables))
document.documentElement.style.setProperty(name, value);
document.documentElement.style.setProperty(
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
);
document.documentElement.style.colorScheme = theme;
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
}
export async function prepareGoldenScenario(page, scenario) {
await stableEnvironment(page, scenario);
const fixture = fixtureFor(scenario.fixture);
if (scenario.deviceName && fixture.devices?.[scenario.deviceId])
fixture.devices[scenario.deviceId].name = scenario.deviceName;
if (scenario.fillMode) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
space.settings = { ...(space.settings || {}), fill_mode: scenario.fillMode };
}
if (scenario.hideOpenings) {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
space.settings = { ...(space.settings || {}), hide_openings: true };
}
return page.evaluate(async ({ fixture, scenario }) => {
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error(`golden scenario timed out: ${scenario.id}`);
await wait(15);
}
};
window.__goldenCard?.remove?.();
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const cardConfig = {
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
language: scenario.language || 'en',
};
const hassFor = () => ({
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
devices: fixture.devices || {}, entities: fixture.entities || {},
areas: fixture.areas || {}, states: fixture.states || {},
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices || {});
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities || {});
if (message.type === 'config_entries/get')
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden fixture' }];
if (message.type === 'manifest/list')
return [{ domain: 'houseplan_golden', name: 'House Plan Golden' }];
return { ok: true };
},
callService: async () => undefined,
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const mount = async () => {
const card = document.createElement('houseplan-card');
card.setConfig(cardConfig);
host.replaceChildren(card);
card.hass = hassFor();
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
await card.updateComplete;
const expectedDevices = Object.keys(fixture.devices || {}).length;
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
await until(() => card._booting === false);
await frame();
return card;
};
let card = await mount();
if (scenario.warmRemount) {
card.remove();
await wait(0);
card = await mount();
}
window.__goldenCard = card;
if (scenario.space && card._space !== scenario.space) {
card._pickSpace(scenario.space);
await card.updateComplete;
}
if (scenario.mode) {
card._setMode(scenario.mode);
await card.updateComplete;
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
card.requestUpdate();
await card.updateComplete;
}
if (scenario.hoverRoom) {
const room = card._spaceModel().rooms.find((item) => item.id === scenario.hoverRoom);
if (!room) throw new Error(`golden hover room missing: ${scenario.hoverRoom}`);
card._hoverRoom = { space: card._space, room };
card.requestUpdate();
await card.updateComplete;
}
if (scenario.dialog === 'device') {
card._setMode('devices');
await card.updateComplete;
const device = card._devices.find((item) => item.id === scenario.deviceId);
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
card._openMarkerDialog(device);
await card.updateComplete;
if (scenario.focusDialogClose) {
const dialog = card.renderRoot.querySelector('hp-dialog');
await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus();
}
} else if (scenario.dialog === 'decor-color') {
card._setMode('decor');
card._decorTool = 'select';
await card.updateComplete;
const shape = card._decorList.find((item) => item.kind === 'line');
if (!shape) throw new Error('golden decor line missing');
card._decorShapeDbl(new MouseEvent('dblclick'), shape);
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const picker = dialog?.querySelector('hp-color-opacity');
await picker?.updateComplete;
const trigger = picker?.renderRoot?.querySelector('.trigger');
if (!trigger) throw new Error('golden decor color trigger missing');
trigger.click();
await picker.updateComplete;
}
await document.fonts?.ready;
await frame();
return {
space: card._space,
mode: card._mode,
devices: card._devices.length,
dialog: !!card.renderRoot.querySelector('hp-dialog'),
};
}, { fixture, scenario });
}
export async function goldenClip(page, capture) {
if (capture === 'page') return null;
return page.evaluate(() => {
const card = window.__goldenCard;
const target = card?.renderRoot?.querySelector('.stage');
if (!target) throw new Error('golden stage capture target missing');
const rect = target.getBoundingClientRect();
const pad = 2;
const x = Math.max(0, Math.floor(rect.left - pad));
const y = Math.max(0, Math.floor(rect.top - pad));
const right = Math.min(window.innerWidth, Math.ceil(rect.right + pad));
const bottom = Math.min(window.innerHeight, Math.ceil(rect.bottom + pad));
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
});
}
+53
View File
@@ -0,0 +1,53 @@
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 2;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-diagonal-45-opening-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-thick-wall-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'light', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-lqi-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'lqi', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hover-over-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
zoom: 2.5, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'large-house-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
warmRemount: true, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'device-dialog-desktop-en', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-climate',
deviceName: 'Living room climate controller with an intentionally long title',
focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-climate',
deviceName: 'Контроллер климата гостиной с намеренно очень длинным названием',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 760 }, ...page },
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'decor-color', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
]);
+13
View File
@@ -0,0 +1,13 @@
export const GOLDEN_BASELINE_MANIFEST = 'baseline-manifest.json';
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
if (mode === 'verify' && scenarioFilter)
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
};
export const goldenRunFailed = (mode, manifestValid, results) => {
if (results.some((result) => result.status === 'error')) return true;
return mode === 'verify'
&& (!manifestValid || results.some((result) => result.status !== 'passed'));
};
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch } from '../serve.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { assertGoldenInvocation, GOLDEN_BASELINE_MANIFEST, goldenRunFailed } from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
const scenarioFilter = process.argv.find((arg) => arg.startsWith('--scenario='))?.slice(11) || '';
assertGoldenInvocation(mode, scenarioFilter);
const scenarios = scenarioFilter
? GOLDEN_SCENARIOS.filter((scenario) => scenario.id === scenarioFilter)
: GOLDEN_SCENARIOS;
if (!scenarios.length) throw new Error(`unknown golden scenario: ${scenarioFilter}`);
const artifactRoot = resolve(ROOT, 'artifacts/golden');
const actualRoot = resolve(artifactRoot, 'actual');
const diffRoot = resolve(artifactRoot, 'diff');
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(actualRoot, { recursive: true });
mkdirSync(diffRoot, { recursive: true });
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
async function comparePng(page, actual, baseline, threshold) {
return page.evaluate(async ({ actual64, baseline64, threshold }) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const [actualImage, baselineImage] = await Promise.all([decode(actual64), decode(baseline64)]);
if (actualImage.width !== baselineImage.width || actualImage.height !== baselineImage.height) {
return {
dimensionsMatch: false,
actualSize: [actualImage.width, actualImage.height],
baselineSize: [baselineImage.width, baselineImage.height],
differingPixels: null,
diffRatio: 1,
maxObservedDelta: 255,
passed: false,
diffPngBase64: null,
};
}
const width = actualImage.width;
const height = actualImage.height;
const canvas = document.createElement('canvas');
const baselineCanvas = document.createElement('canvas');
const diffCanvas = document.createElement('canvas');
for (const item of [canvas, baselineCanvas, diffCanvas]) {
item.width = width;
item.height = height;
}
const context = canvas.getContext('2d', { willReadFrequently: true });
const baselineContext = baselineCanvas.getContext('2d', { willReadFrequently: true });
const diffContext = diffCanvas.getContext('2d');
context.drawImage(actualImage, 0, 0);
baselineContext.drawImage(baselineImage, 0, 0);
const actualData = context.getImageData(0, 0, width, height).data;
const baselineData = baselineContext.getImageData(0, 0, width, height).data;
const diff = diffContext.createImageData(width, height);
let differingPixels = 0;
let maxObservedDelta = 0;
for (let offset = 0; offset < actualData.length; offset += 4) {
let delta = 0;
for (let channel = 0; channel < 4; channel++)
delta = Math.max(delta, Math.abs(actualData[offset + channel] - baselineData[offset + channel]));
maxObservedDelta = Math.max(maxObservedDelta, delta);
if (delta > threshold.maxChannelDelta) {
differingPixels++;
diff.data[offset] = 255;
diff.data[offset + 1] = 0;
diff.data[offset + 2] = 180;
diff.data[offset + 3] = 255;
} else {
const gray = Math.round((actualData[offset] + actualData[offset + 1] + actualData[offset + 2]) / 9);
diff.data[offset] = gray;
diff.data[offset + 1] = gray;
diff.data[offset + 2] = gray;
diff.data[offset + 3] = 90;
}
}
diffContext.putImageData(diff, 0, 0);
const diffRatio = differingPixels / (width * height);
return {
dimensionsMatch: true,
actualSize: [width, height],
baselineSize: [width, height],
differingPixels,
diffRatio,
maxObservedDelta,
passed: diffRatio <= threshold.maxDiffRatio,
diffPngBase64: differingPixels
? diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length)
: null,
};
}, {
actual64: actual.toString('base64'),
baseline64: baseline.toString('base64'),
threshold,
});
}
let baselineManifest = null;
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
if (existsSync(baselineManifestPath)) {
try { baselineManifest = JSON.parse(readFileSync(baselineManifestPath, 'utf8')); }
catch { baselineManifest = { invalid: true }; }
}
const { page, browser } = await launch(
{ width: 1000, height: 900 },
1,
['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'],
{ locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' },
);
const chromium = await browser.version();
const results = [];
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
let buildFingerprint = null;
try {
buildFingerprint = await assertFreshDemoBundle(page, ROOT);
for (const scenario of scenarios) {
const result = {
id: scenario.id,
threshold: scenario.threshold,
status: 'error',
actualSha256: null,
};
try {
pageErrors.length = 0;
result.runtime = await prepareGoldenScenario(page, scenario);
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
const clip = await goldenClip(page, scenario.capture);
const actual = await page.screenshot({
...(clip ? { clip } : {}),
animations: 'disabled',
caret: 'hide',
scale: 'css',
});
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
writeFileSync(actualPath, actual);
result.actualSha256 = sha256(actual);
result.actual = actualPath;
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
result.baseline = baselinePath;
if (!existsSync(baselinePath)) {
result.status = 'missing-baseline';
} else {
const baseline = readFileSync(baselinePath);
const expectedBaselineSha256 = baselineManifest?.scenarios?.[scenario.id];
result.baselineSha256 = sha256(baseline);
if (!expectedBaselineSha256 || result.baselineSha256 !== expectedBaselineSha256) {
result.status = 'invalid-baseline';
result.error = expectedBaselineSha256
? 'baseline PNG does not match its reviewed manifest hash'
: 'baseline PNG is not listed in the reviewed manifest';
results.push(result);
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
continue;
}
const comparison = await comparePng(page, actual, baseline, scenario.threshold);
Object.assign(result, comparison);
result.status = comparison.passed ? 'passed' : 'different';
if (comparison.diffPngBase64) {
const diffPath = resolve(diffRoot, `${scenario.id}.png`);
writeFileSync(diffPath, Buffer.from(comparison.diffPngBase64, 'base64'));
result.diff = diffPath;
}
delete result.diffPngBase64;
}
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
}
results.push(result);
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
}
} finally {
await browser.close();
}
const manifestValid = !!baselineManifest
&& !baselineManifest.invalid
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
&& baselineManifest.chromium === chromium
&& GOLDEN_SCENARIOS.every((scenario) => typeof baselineManifest.scenarios?.[scenario.id] === 'string');
const report = {
schema: 1,
mode,
generatedAt: new Date().toISOString(),
matrixVersion: GOLDEN_MATRIX_VERSION,
buildFingerprint,
chromium,
baselineManifest: baselineManifest ? {
present: true,
valid: manifestValid,
matrixVersion: baselineManifest.matrixVersion ?? null,
chromium: baselineManifest.chromium ?? null,
} : { present: false, valid: false, matrixVersion: null, chromium: null },
results,
};
writeFileSync(resolve(artifactRoot, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (goldenRunFailed(mode, manifestValid, results)) process.exitCode = 1;
+10 -3
View File
@@ -40,9 +40,16 @@ export async function finish(browser, out) {
}
}
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();
export async function launch(
viewport = { width: 820, height: 760 },
scale = 1,
browserArgs = [],
contextOptions = {},
) {
const browser = await chromium.launch({ args: ['--no-sandbox', ...browserArgs] });
const page = await (await browser.newContext({
viewport, deviceScaleFactor: scale, ...contextOptions,
})).newPage();
// audit T1: an exception inside the card used to be logged and ignored
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
await page.route('**/*', (r) => {
+8 -2
View File
@@ -25,11 +25,14 @@ const restore = () => page.evaluate((s) => {
return c.updateComplete && true;
}, snap);
const mode = (m) => page.evaluate((m) => {
const mode = async (m) => {
await page.evaluate((m) => {
const c = window.__card;
c._setMode(m); c.requestUpdate();
return c.updateComplete && true;
}, m);
}, m);
await page.waitForTimeout(220); // editor chrome transition owns stage geometry
};
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
const spaceCfg = () => page.evaluate(() => {
@@ -276,9 +279,11 @@ const editorWhite = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const navSettled = () => new Promise((resolve) => setTimeout(resolve, 220));
const probe = async (mode) => {
c._setMode(mode);
await upd();
await navSettled();
const stage = sr().querySelector('.stage');
const paper = sr().querySelector('.stage svg .hp-paper');
return {
@@ -294,6 +299,7 @@ const editorWhite = await page.evaluate(async () => {
const decor = await probe('decor');
c._setMode('view');
await upd();
await navSettled();
const viewStage = sr().querySelector('.stage');
// View with a backdrop must NOT force white — theme/card colour stays
const viewForcedWhite = getComputedStyle(viewStage).backgroundColor === 'rgb(255, 255, 255)'
+13 -2
View File
@@ -13,6 +13,15 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
};
const waitStageBg = async (accept) => {
const deadline = performance.now() + 700;
let value = await stageBg();
while (!accept(value) && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
value = await stageBg();
}
return value;
};
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
const themeBg = await stageBg(); // theme default from the stylesheet
@@ -81,10 +90,10 @@ const res = await page.evaluate(async () => {
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
const editorBg = await stageBg();
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
// 9) kiosk instance respects the setting
const k = document.createElement('houseplan-card');
@@ -181,9 +190,11 @@ const paper = await page.evaluate(async () => {
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
await new Promise((resolve) => setTimeout(resolve, 220));
// daynight night: the paper dims via the brightness filter ONLY, never alpha
c._serverCfg.settings.bg_mode = 'daynight';
c._serverCfg.settings.north_deg = 0;
+11
View File
@@ -1,5 +1,7 @@
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
// Reproduce the narrow wall-tablet/mobile dialog from the field report.
await page.setViewportSize({ width: 390, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
@@ -12,6 +14,15 @@ const res = await page.evaluate(async () => {
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.infoUsesWideShell = sr().querySelector('hp-dialog')?.hasAttribute('wide') === true;
const footer = sr().querySelector('.infofooter');
const footerBox = footer?.getBoundingClientRect();
const footerButtons = [...(footer?.querySelectorAll('button') || [])];
out.footerActionsContained = !!footerBox && footerButtons.length >= 2 && footerButtons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= footerBox.left - 1 && rect.right <= footerBox.right + 1;
});
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('hp-dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
+3 -2
View File
@@ -62,13 +62,14 @@ const res = await page.evaluate(async () => {
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
// scansPerUpdate = 1: active-registry projection is shared by the climate map
// and buildDevices. Важно
// не само число, а что оно не растёт вместе с числом комнат.
checkAll(res, {
scansOnRerender: 0,
roomCount: 44,
scansSameWith44Rooms: true,
scansPerUpdate: 2,
scansPerUpdate: 1,
tempBefore: 22.4,
tempAfter: 33.3,
climateIsMap: true,
+37
View File
@@ -57,6 +57,43 @@ const res = await page.evaluate(async () => {
c._clickDevice(new MouseEvent('click'), dev2());
const last = calls.at(-1);
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
// Opening and saving must not rewrite configuration that runtime cannot
// currently execute: YAML-only domains and duplicates are still user data.
const losslessControls = [lights[0], 'input_boolean.yaml_only', lights[0], 'sensor.vendor_option'];
cfg.controls = [...losslessControls];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
c._openMarkerDialog(dev2()); await c.updateComplete;
out.dialogKeepsUnknownAndDuplicateControls = JSON.stringify(c._markerDialog?.controls)
=== JSON.stringify(losslessControls);
await c._saveMarker(); await c.updateComplete;
const savedLossless = c._serverCfg.markers.find((m) => m.name === 'Выключатель')?.controls;
out.saveKeepsUnknownAndDuplicateControls = JSON.stringify(savedLossless)
=== JSON.stringify(losslessControls);
// Legacy configs could store a standalone entity as its own external
// control. That self-reference must neither become a light source/group nor
// disappear behind ambiguous dialog UI. Its normal Toggle action still
// operates the entity, and an ON hood/fan still has working-state yellow.
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
out.hasStandaloneSwitch = !!own;
const self = own && {
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
space: c._space, area: room.area, entities: [own], primary: own,
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
};
if (self) {
await setSt({ [own]: 'off' });
c._clickDevice(new MouseEvent('click'), self);
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
=== JSON.stringify(['homeassistant', 'toggle', own]);
c._openMarkerDialog(self); await c.updateComplete;
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
c._markerDialog = null;
await setSt({ [own]: 'on' });
out.selfSwitchStillShowsWorkingState = c._stateClass(self).includes('on');
}
return out;
});
checkAll(res);
+9 -1
View File
@@ -181,7 +181,15 @@ const out = await page.evaluate(async () => {
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
const clsOfState = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = c._stateClass(d);
c.hass = saved;
return r;
+9 -1
View File
@@ -45,7 +45,15 @@ const out = await page.evaluate(async () => {
// =======================================================================
const withStates = (states, fn) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = fn();
c.hass = saved;
return r;
+97 -1
View File
@@ -11,6 +11,21 @@ const res = await page.evaluate(async () => {
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
// The compact colour/opacity control must escape the clipped editor/dialog
// surface through the browser top layer and remain inside the viewport.
const picker = sr().querySelector('.decorbar hp-color-opacity');
const trigger = picker?.shadowRoot?.querySelector('.trigger');
trigger?.click();
await picker?.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const popup = picker?.shadowRoot?.querySelector('.picker');
const popupRect = popup?.getBoundingClientRect();
out.colorPickerUsesTopLayer = !!popup?.matches(':popover-open');
out.colorPickerInsideViewport = !!popupRect
&& popupRect.left >= 0 && popupRect.top >= 0
&& popupRect.right <= innerWidth && popupRect.bottom <= innerHeight;
trigger?.click();
await picker?.updateComplete;
// seven tools (the six drawing ones + «Мебель», docs/FURNITURE.md) plus
// «Картинка-подложка», which f1 offers because it HAS a picture
// (docs/BACKDROP.md §2); a hand-drawn space still shows seven
@@ -48,6 +63,9 @@ const res = await page.evaluate(async () => {
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
out.degenerateSkipped = c._decorList.length === 3;
c._decorDraft = { kind: 'rect', a: [g, g], b: [g * 5, g], pid: 1 };
c._decorCommitDraft();
out.flatRectSkipped = c._decorList.length === 3;
// 3b) живая плашка размера, пока фигуру ещё тянут (владелец 2026-08-04:
// «в редакторе подложки у линий писать длину, как при рисовании комнат в
// редакторе плана»). Та же .measurelabel, тот же formatLength/cell_cm.
@@ -182,20 +200,98 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
&& !!sr().querySelector('hp-dialog .dfill') === false;
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', widthCm: 6.5 };
out.lineStyleDefaultsSolid = c._decorShapeDialog?.lineStyle === 'solid'
&& sr().querySelectorAll('input[name="decor-line-style"]').length === 2
&& !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
const angleInput = sr().querySelector('hp-dialog input[min="-180"][max="180"]');
if (angleInput) {
angleInput.value = '12.3456';
angleInput.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
}
out.angleInputKeepsWorkingPrecision = c._decorShapeDialog?.angle === '12.3456'
&& sr().querySelector('hp-dialog input[min="-180"][max="180"]')?.value === '12.3456';
out.lineStyleAbsentFromToolbar = !sr().querySelector('.editbar input[name="decor-line-style"]');
c._decorShapeDialog = {
...c._decorShapeDialog, color: '#123456', widthCm: 6.5, lineStyle: 'dashed',
};
c._decorSaveShape(); await c.updateComplete;
const editedProbe = c._decorList.find((x) => x.id === 'dcprobe');
out.objectDialogSavesStyle = editedProbe?.color === '#123456'
&& editedProbe?.width_cm === 6.5 && editedProbe?.width === undefined
&& editedProbe?.line_style === 'dashed'
&& c._decorShapeDialog === null;
out.dashedLineRendered = !!probe()?.getAttribute('stroke-dasharray');
c._undoGeometry(); await c.updateComplete;
const undoSolid = !c._decorList.find((x) => x.id === 'dcprobe')?.line_style;
c._redoGeometry(); await c.updateComplete;
out.lineStyleUndoRedo = undoSolid
&& c._decorList.find((x) => x.id === 'dcprobe')?.line_style === 'dashed';
const selectHit = sr().querySelector('.decorlayer line.dselecthit');
const dashedPaint = probe();
let gapPicked = null;
if (selectHit && dashedPaint) {
const sw = Number(dashedPaint.getAttribute('stroke-width')) || 1;
const gapPoint = dashedPaint.getPointAtLength(Math.min(dashedPaint.getTotalLength() * 0.75, sw * 5.5));
const screenPoint = new DOMPoint(gapPoint.x, gapPoint.y).matrixTransform(dashedPaint.getScreenCTM());
gapPicked = sr().elementFromPoint(screenPoint.x, screenPoint.y);
}
out.dashedLineSelectableAcrossGaps = gapPicked === selectHit
&& selectHit?.classList.contains('dshape')
&& selectHit?.dataset.hp === 'decor'
&& selectHit?.dataset.id === 'dcprobe';
// Erase gets a constant screen-space target around hairlines. Exercise the
// browser's actual SVG hit-test 6 px away from a sub-pixel painted stroke;
// dispatching directly to the proxy would only prove that its listener exists.
c._replaceDecor('dcprobe', { width_cm: 0.1, width: undefined });
c._decorTool = 'erase'; await c.updateComplete;
const paintedProbe = sr().querySelector('.decorlayer line.dshape:not(.derasehit)');
const eraseHit = sr().querySelector('.decorlayer line.derasehit');
const matrix = paintedProbe?.getScreenCTM();
let picked = null;
if (paintedProbe && eraseHit && matrix) {
const p1 = new DOMPoint(+paintedProbe.getAttribute('x1'), +paintedProbe.getAttribute('y1')).matrixTransform(matrix);
const p2 = new DOMPoint(+paintedProbe.getAttribute('x2'), +paintedProbe.getAttribute('y2')).matrixTransform(matrix);
const dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.hypot(dx, dy) || 1;
picked = sr().elementFromPoint((p1.x + p2.x) / 2 - (dy / len) * 6,
(p1.y + p2.y) / 2 + (dx / len) * 6);
}
out.eraseHairlineHasWideHitTarget = picked === eraseHit
&& getComputedStyle(eraseHit).strokeWidth === '16px'
&& getComputedStyle(eraseHit).pointerEvents === 'stroke';
picked?.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true, pointerId: 45, button: 0, isPrimary: true,
}));
await c.updateComplete;
out.eraseWideHitOpensConfirmation = c._decorEraseConfirm?.id === 'dcprobe';
c._decorEraseConfirm = null;
c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// Switching modes during a live move cancels the transaction and restores
// its start instead of leaving mutated geometry with a forgotten pointer.
const liveShape = c._decorList[0];
const liveBefore = JSON.parse(JSON.stringify(liveShape));
const liveSnapshot = c._geometrySnapshot();
c._decorMove = {
id: liveShape.id, start: [0, 0], orig: liveBefore, pid: 99,
moved: true, before: liveSnapshot,
};
c._curSpaceCfg.decor = c._decorList.map((shape) => shape.id === liveShape.id
? { ...shape, x: (shape.x || 0) + 0.2 }
: shape);
c._setMode('view'); await c.updateComplete;
const restoredLive = c._decorList.find((shape) => shape.id === liveShape.id);
out.modeSwitchCancelsLiveDecor = !c._decorMove
&& JSON.stringify(restoredLive) === JSON.stringify(liveBefore);
c._setMode('decor'); await c.updateComplete;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
c._decorShapeDown({ stopPropagation() {}, preventDefault() {}, target: null, pointerId: 1 }, c._decorList[0]);
out.eraseAsksConfirmation = c._decorList.length === n0 && !!c._decorEraseConfirm;
c._confirmDecorErase();
out.eraseWorks = c._decorList.length === n0 - 1;
// 7) Esc-лестница: инструмент → select → выход
c._decorTool = 'line'; c._decorSel = null; await c.updateComplete;
+1
View File
@@ -197,6 +197,7 @@ const res = await page.evaluate(async () => {
const before = c._decorList.length;
const target = c._decorList.find((x) => x.id === 'dtm');
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
out.chromiumTextUsesAtomicBoundingBox = getComputedStyle(el('dtm')).pointerEvents === 'bounding-box';
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
await c.updateComplete;
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
+60
View File
@@ -0,0 +1,60 @@
// Shared face parity for the same live light on the full plan, the marker
// editor preview and houseplan-space-card.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const face = (node) => {
if (!node) return null;
const semantic = ['on', 'open', 'alarm', 'unavail', 'valonly', 'activity-running', 'activity-event', 'activity-presence', 'activity-transition'];
return {
classes: semantic.filter((name) => node.classList.contains(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
value: node.querySelector('.valtext')?.textContent?.trim() || '',
temp: node.querySelector('.tval')?.textContent?.trim() || '',
hum: node.querySelector('.hval')?.textContent?.trim() || '',
scale: node.style.getPropertyValue('--dev-scale'),
rippleScale: node.style.getPropertyValue('--ripple-scale'),
rippleColor: node.style.getPropertyValue('--ripple-color'),
};
};
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const planFace = face(sr().querySelector('.dev[data-id="d_light1"]'));
c._setMode('devices');
const device = c._devices.find((item) => item.id === 'd_light1');
c._openMarkerDialog(device);
await c.updateComplete;
const preview = sr().querySelector('hp-device-preview');
await preview?.updateComplete;
const previewFace = face(preview?.renderRoot?.querySelector('.dev'));
const providerShown = /demo/i.test(preview?.renderRoot?.querySelector('.previewfacts')?.textContent || '');
await customElements.whenDefined('houseplan-space-card');
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = c.hass;
document.body.appendChild(card);
const started = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - started < 6000) await wait(60);
await card.updateComplete;
const staticNode = card.renderRoot.querySelector('.dev[data-id="d_light1"]');
const staticFace = face(staticNode);
return {
allFacesPresent: !!planFace && !!previewFace && !!staticFace,
planPreviewEqual: JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticEqual: JSON.stringify(planFace) === JSON.stringify(staticFace),
providerShown,
staticBindingHook: staticNode?.getAttribute('data-binding-status') === 'active',
};
});
checkAll(res);
await finish(browser, res);
+70
View File
@@ -0,0 +1,70 @@
// HA-disabled lifecycle: saved marker -> forced hidden -> service ghost and
// blocked Show -> same marker restored after reactivation, without config writes.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const paint = async () => { c.requestUpdate(); await c.updateComplete; };
c._serverCfg = {
...c._serverCfg,
markers: [
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'd_light1'),
{ id: 'd_light1', binding: 'device:d_light1' },
],
};
c._cfgEpoch++;
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('view');
await paint();
const configBefore = JSON.stringify(c._serverCfg);
const layoutBefore = JSON.stringify(c._layout);
const visibleBefore = !!sr().querySelector('.dev[data-id="d_light1"]');
window.__setRegistryDisabled('device', 'd_light1', 'user');
c.hass = window.__mkHass();
await wait(220);
await paint();
const hiddenInView = !sr().querySelector('.dev[data-id="d_light1"]');
c._showHidden = true;
c._setMode('devices');
c._regSignature = '';
c._maybeRebuildDevices();
await paint();
const ghost = sr().querySelector('.dev[data-id="d_light1"][data-binding-status="ha-disabled"]');
const disabledDevice = c._devices.find((device) => device.id === 'd_light1');
c._openMarkerDialog(disabledDevice);
await paint();
const hiddenBeforeShow = c._markerDialog?.hideFromPlan;
c._toggleMarkerDialogVisibility();
await paint();
const showBlocked = /disabled|deactiv/i.test(c._toast)
&& c._markerDialog?.hideFromPlan === hiddenBeforeShow
&& sr().querySelector('.toast')?.getAttribute('role') === 'alert';
c._markerDialog = null;
window.__setRegistryDisabled('device', 'd_light1', null);
c.hass = window.__mkHass();
await wait(220);
c._showHidden = false;
c._setMode('view');
await paint();
return {
visibleBefore,
hiddenInView,
labelledGhost: !!ghost,
showBlocked,
restored: !!sr().querySelector('.dev[data-id="d_light1"]'),
configUnchanged: JSON.stringify(c._serverCfg) === configBefore,
layoutUnchanged: JSON.stringify(c._layout) === layoutBefore,
};
});
checkAll(res);
await finish(browser, res);
+102 -1
View File
@@ -19,20 +19,121 @@ const res = await page.evaluate(async () => {
// 3) повторный клик по активной вкладке — ничего
tabs()[0].click(); await c.updateComplete;
out.reclickNoop = c._mode === 'plan';
// Let the View -> editor grid transition finish first. A real user cannot
// switch editors in the same zero-layout frame, and competing WAAPI/CSS
// height animations in that frame are intentionally suppressed.
await new Promise((resolve) => setTimeout(resolve, 220));
// 4) прямое переключение План → Устройства
// Deliberately make the outgoing bar taller: the editor transition must
// interpolate real content height, not merely fade two equal-height rows.
const planBarBeforeSwap = sr().querySelector('.editorchrome .editbar');
planBarBeforeSwap.style.minHeight = `${planBarBeforeSwap.getBoundingClientRect().height + 36}px`;
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = swapChrome.getAnimations()
.some((animation) => animation.id === 'hp-editor-height-swap');
out.editorSwapAnimatesContent = swapInner.getAnimations()
.some((animation) => animation.id === 'hp-editor-content-swap');
// 5) инструменты устройств из шапки исчезли (в .bar их больше нет)
out.headerCleanInDev = !sr().querySelector('.bar > .btn[title*="' + (c._t('title.add_device')) + '"]');
// 6) крестик на панели → Просмотр
sr().querySelector('.editbar .barclose').click(); await c.updateComplete;
out.barCloseWorks = c._mode === 'view' && !sr().querySelector('.editbar');
const chrome = sr().querySelector('.editorchrome');
out.barCloseStarts = c._mode === 'view' && !chrome.classList.contains('open')
&& chrome.getAttribute('aria-hidden') === 'true';
// The last bar deliberately remains mounted while its grid row collapses.
await new Promise((resolve) => setTimeout(resolve, 220));
out.barCloseWorks = c._mode === 'view'
&& getComputedStyle(chrome).visibility === 'hidden'
&& chrome.getBoundingClientRect().height < 1;
// 7) крестик в самой вкладке → Просмотр (и не переключает режим)
tabs()[1].click(); await c.updateComplete;
tabs()[1].querySelector('.closex').click(); await c.updateComplete;
out.tabCrossWorks = c._mode === 'view';
// Layout-independent editor history: physical KeyZ works when `key` is the
// Russian «я», while an input keeps its native browser history.
tabs()[0].click(); await c.updateComplete;
const realUndo = c._undoGeometry;
const realRedo = c._redoGeometry;
let undoCalls = 0;
let redoCalls = 0;
c._undoGeometry = () => { undoCalls += 1; };
c._redoGeometry = () => { redoCalls += 1; };
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.layoutIndependentUndo = undoCalls === 1;
const editorInput = sr().querySelector('.editbar input');
editorInput.dispatchEvent(new KeyboardEvent('keydown', {
key: 'я', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.inputKeepsNativeUndo = undoCalls === 1;
// QWERTZ swaps the physical Y/Z codes. The labelled key wins when it is a
// Latin letter; otherwise both isZ/isY became true and Redo ran first.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'z', code: 'KeyY', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlZIsUndo = undoCalls === 2 && redoCalls === 0;
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'y', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.qwertzCtrlYIsRedo = undoCalls === 2 && redoCalls === 1;
// On AZERTY the physical KeyZ carries W: Ctrl+W must remain the browser's
// close-tab shortcut, never an editor Undo.
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'w', code: 'KeyZ', ctrlKey: true, bubbles: true, composed: true,
}));
out.azertyCtrlWIsNotUndo = undoCalls === 2 && redoCalls === 1;
c._undoGeometry = realUndo;
c._redoGeometry = realRedo;
// Ctrl+click closes a valid draft without adding the click as a new point;
// the same gesture must refuse a draft with fewer than two existing edges.
const savedPath = c._path;
const savedRoomDialog = c._roomDialog;
const savedNameSel = c._nameSel;
const savedAreaSel = c._areaSel;
const ctrlClick = () => {
let prevented = false;
c._markupClick({
ctrlKey: true, metaKey: false, clientX: 0, clientY: 0,
preventDefault() { prevented = true; }, composedPath() { return []; },
});
return prevented;
};
c._tool = 'draw';
c._roomDialog = false;
c._path = [[-120, -120], [-80, -120]];
const shortBefore = JSON.stringify(c._path);
out.ctrlCloseRequiresTwoEdges = ctrlClick()
&& !c._roomDialog && JSON.stringify(c._path) === shortBefore;
c._path = [[-120, -120], [-80, -120], [-80, -80]];
out.ctrlCloseValidContour = ctrlClick() && c._roomDialog
&& c._path.length === 4
&& c._path[0][0] === c._path[3][0] && c._path[0][1] === c._path[3][1];
await c.updateComplete;
const roomDialog = sr().querySelector('hp-dialog.roomdialog');
const roomBody = roomDialog?.querySelector('.body');
out.roomDialogUsesMediumWidth = roomDialog?.wide === true
&& roomDialog.hasAttribute('wide');
out.roomDialogHasNoHorizontalScroll = !!roomBody
&& roomBody.scrollWidth <= roomBody.clientWidth + 1;
c._nameSel = 'No-area room';
c._areaSel = '';
c.requestUpdate();
await c.updateComplete;
const noAreaSave = sr().querySelector('hp-dialog.roomdialog .room-save');
out.noAreaUsesRegularSave = !!noAreaSave && !noAreaSave.disabled
&& sr().querySelectorAll('hp-dialog.roomdialog .room-save').length === 1;
c._path = savedPath;
c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel;
c._areaSel = savedAreaSel;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+98
View File
@@ -0,0 +1,98 @@
// Browser contract for saved room drafts, independent partitions and columns.
// This deliberately exercises the card's editor transactions rather than
// injecting finished records: create, drag, edit and Delete must all pass
// through the same paths a pointer/keyboard gesture uses.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
const out = await page.evaluate(async () => {
const o = {};
const c = window.__card;
const PITCH = 1000 / 240;
window.confirm = () => true;
c._serverCfg = {
spaces: [{ id: 'physical', title: 'Physical', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [] }],
markers: [], settings: {},
};
c._space = 'physical';
c._mode = 'plan';
c._modelCache = null;
// Keep the smoke local: editor transactions still mutate the real config,
// invalidate derived models and history, but do not need a websocket write.
c._saveConfig = () => { c._cfgEpoch++; c._modelCache = null; c.requestUpdate(); };
await c.updateComplete;
c._tool = 'partition';
c._drawWallField = '15';
c._partitionClick([100, 100], false);
c._partitionClick([300, 100], false);
let sp = c._serverCfg.spaces[0];
o.partitionCreated = sp.partitions?.length === 1 && sp.partitions[0].cm === 15;
c._tool = 'column';
c._drawWallField = '30';
c._columnClick([200, 200]);
sp = c._serverCfg.spaces[0];
o.columnCreatedSquare = sp.wall_columns?.length === 1
&& sp.wall_columns[0].shape === 'square' && sp.wall_columns[0].cm === 30;
c._tool = 'draw';
c._drawWallField = '12';
c._path = [[100, 300], [200, 300]];
c._activeDraftId = null;
c._draftSegmentCms = [];
c._persistActiveDraftSegment();
sp = c._serverCfg.spaces[0];
o.draftPersistedImmediately = sp.room_drafts?.length === 1
&& sp.room_drafts[0].segments[0].cm === 12;
// Endpoint-to-endpoint join is one atomic record: the active id survives
// and segment thickness follows geometric order.
sp.room_drafts = [
{ id: 'join-a', points: [[0.1, 0.4], [0.3, 0.4]], segments: [{ cm: 10 }] },
{ id: 'join-b', points: [[0.3, 0.4], [0.5, 0.4]], segments: [{ cm: 20 }] },
];
c._modelCache = null;
c._activeDraftId = 'join-a';
c._path = [[100, 400], [300, 400]];
c._draftSegmentCms = [10];
const join = c._draftEndAt([300, 400], 'join-a');
if (join) c._mergeDraftEndpoint(join);
sp = c._serverCfg.spaces[0];
o.draftEndpointJoinIsAtomic = sp.room_drafts?.length === 1
&& sp.room_drafts[0].id === 'join-a'
&& JSON.stringify(sp.room_drafts[0].segments.map((segment) => segment.cm)) === '[10,20]';
const part = c._spaceModel().partitions[0];
c._physicalDrag = {
pid: 71, kind: 'partition', id: part.id, start: [...part.a],
startClient: [0, 0], before: c._geometrySnapshot(), moved: true,
base: JSON.parse(JSON.stringify(part)), delta: [PITCH, PITCH],
};
c._physicalUp(new PointerEvent('pointerup', { pointerId: 71, bubbles: true }));
sp = c._serverCfg.spaces[0];
o.partitionDragIsRigidAndGridBound = Math.abs(sp.partitions[0].a[0] - (0.1 + PITCH / 1000)) < 1e-9
&& Math.abs((sp.partitions[0].b[0] - sp.partitions[0].a[0]) - 0.2) < 1e-9
&& Math.abs(sp.partitions[0].a[1] - sp.partitions[0].b[1]) < 1e-9;
c._physicalSel = { kind: 'partition', id: sp.partitions[0].id };
c._deletePhysicalSelection();
o.deleteRemovesPartition = !c._serverCfg.spaces[0].partitions;
const draftId = c._serverCfg.spaces[0].room_drafts[0].id;
c._physicalSel = { kind: 'draft', id: draftId, segment: 0 };
c._deletePhysicalSelection();
o.deleteOnDraftRemovesWholeOutline = !c._serverCfg.spaces[0].room_drafts;
c._tool = 'partition';
c._drawWallField = '120';
c._partitionClick([400, 100], false);
c._partitionClick([500, 100], false);
o.invalidThicknessCreatesNothing = !c._serverCfg.spaces[0].partitions;
return o;
});
await finish(browser, checkAll(out));
+11
View File
@@ -250,6 +250,13 @@ const res = await page.evaluate(async () => {
&& near(after.w, stored.w, 1e-12) && near(after.h, stored.h, 1e-12)
&& near(after.x, stored.x, 1e-12) && near(after.y, stored.y, 1e-12);
out.stillRenderedAfterRebuild = !!el(sofaId);
c._decorTool = 'select';
c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, after);
await c.updateComplete;
out.objectDialogSelectsStoredSymbol = sr().querySelector(
'.body select.namein option:checked',
)?.value === stored.symbol;
c._decorShapeDialog = null;
// ================= 8. инертность и удаление ==============================
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
@@ -261,6 +268,10 @@ const res = await page.evaluate(async () => {
c._decorTool = 'erase'; await c.updateComplete;
ev('pointerdown', el(sofaId), 300, 300);
await c.updateComplete;
// Eraser is intentionally two-step now; the generic decor smoke verifies
// the dialog itself, while this furniture scenario confirms the action.
c._confirmDecorErase();
await c.updateComplete;
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
// а в режиме просмотра мебель видна и не кликается
c._setMode('view'); await c.updateComplete; await sleep(30);
+6 -3
View File
@@ -21,8 +21,11 @@ const res = await page.evaluate(async () => {
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'));
const styledRooms = [...sr().querySelectorAll('.room.styled')];
out.customFillUsed = styledRooms.some((room) => (
room.style.getPropertyValue('--room-fill').trim() === '#ff00ff'
&& Math.abs(Number(room.style.getPropertyValue('--room-fill-op')) - 0.5) < 1e-9
));
// 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,
@@ -42,7 +45,7 @@ const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 16, // 11 цветов (включая wall_fill) + радиус свечения + фон + погода (docs/SUN.md)
"rows": 15, // 11 цветов (включая wall_fill) + радиус свечения + фон
// + «Оптимизировать планы» (docs/CANVAS.md §9)
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Walls", "Stage background", "Sun", "Plan maintenance", "About"],
"aboutVersion": `Houseplan Card v${BUNDLE_VERSION}`, // та же константа, что в баннере
+9 -1
View File
@@ -2,7 +2,8 @@
// 1. every element placed or dragged by hand lands on a node — devices, room
// labels, decor, room vertices, resize handles; an opening lands ON ITS
// WALL at a whole number of steps along it; positional off-grid placement
// is not supported, including while Shift is held;
// is not supported. Shift keeps the room-outline point on the grid while
// additionally constraining its wall to the nearest 45° direction;
// 2. the explicit «Оптимизировать планы» action moves a deliberately
// detuned plan onto the grid through one atomic config+layout commit,
// tells the truth about how much it moves, and is idempotent.
@@ -146,6 +147,13 @@ const out = await page.evaluate(async () => {
c._tool = 'draw'; c._path = [];
c._markupClick(at(220 + OFF, 220 + OFF, 'click'));
o.roomVertexOnANode = c._path.length === 1 && onGridR(c._path[0][0]) && onGridR(c._path[0][1]);
c._path = [[200, 200]];
c._markupClick(at(240 + OFF, 310 + OFF, 'click', { shiftKey: true }));
const locked = c._path[1];
const ldx = Math.abs(locked[0] - 200), ldy = Math.abs(locked[1] - 200);
o.shiftLocksRoomWallTo45 = c._path.length === 2
&& (ldx < 1e-7 || ldy < 1e-7 || Math.abs(ldx - ldy) < 1e-7)
&& onGridR(locked[0]) && onGridR(locked[1]);
c._path = []; c._tool = 'draw';
// ---- 2) «Оптимизировать планы» -----------------------------------------
+41 -4
View File
@@ -62,13 +62,50 @@ Object.assign(out, await page.evaluate(async () => {
const back = c._devices.find((x) => x.id === d.id) || c._devices.find((x) => x.bindingRef === d.bindingRef);
o.deviceVisibleAgain = !!back && !back.hidden;
// --- у виртуального кнопка «Удалить» есть -------------------------------
// --- у виртуального кнопка «Удалить» есть; удаляется только выбранный ---
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'Тест', binding: 'virtual' };
c._markerDialog = { ...c._markerDialog, name: 'delete-one-a', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 2));
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'delete-one-b', binding: 'virtual' };
await c._saveMarker(); await c.updateComplete;
const virtualA = c._devices.find((x) => x.virtual && x.name === 'delete-one-a');
const virtualB = c._devices.find((x) => x.virtual && x.name === 'delete-one-b');
c._openMarkerDialog(virtualA); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
const savedConfirm = window.confirm;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
o.deleteOnlySelectedVirtual = !!virtualA && !!virtualB
&& !(c._serverCfg.markers || []).some((m) => m.id === virtualA.id)
&& (c._serverCfg.markers || []).some((m) => m.id === virtualB.id);
// A re-added HA binding replaces its tombstone and receives a fresh layout
// position instead of inheriting the deleted marker's last coordinates.
const readd = c._devices.find((x) => x.bindingKind === 'device' && !x.hidden);
const oldPos = { s: readd.space, x: 0.913, y: 0.917 };
c._layout = { ...c._layout, [readd.id]: oldPos };
c._openMarkerDialog(readd); await c.updateComplete;
window.confirm = () => true;
await c._deleteMarker(); await c.updateComplete;
window.confirm = savedConfirm;
c._openMarkerDialog(); await c.updateComplete;
c._markerDialog = {
...c._markerDialog,
bindingMode: 'ha',
binding: `device:${readd.bindingRef}`,
name: readd.name,
};
await c._saveMarker(); await c.updateComplete;
const rebound = (c._serverCfg.markers || [])
.filter((m) => m.binding === `device:${readd.bindingRef}`);
const fresh = c._layout[readd.id];
o.readdReplacesTombstone = rebound.length === 1 && rebound[0].removed !== true;
o.readdUsesFreshPosition = !!fresh
&& (Math.abs(fresh.x - oldPos.x) > 1e-6 || Math.abs(fresh.y - oldPos.y) > 1e-6);
c._markerDialog = null; c._setMode('view');
return o;
}));
+2 -2
View File
@@ -83,7 +83,7 @@ const res = await page.evaluate(async () => {
// Every editor still draws them. The dash deliberately reaches the physical
// centreline there, even if show_borders is off; only View hides it.
await mode('plan');
c._tool = 'openwall';
c._tool = 'boundary';
await upd();
out.openWallsVisibleInPlanEditor = n('.openwalls .openwall') > 0;
await mode('devices');
@@ -98,7 +98,7 @@ const res = await page.evaluate(async () => {
await upd();
const rows = [...sr().querySelectorAll('hp-dialog .srcrow')].map((r) => r.textContent.trim());
out.dialogOffersHideDecor = rows.some((t) => /декорат|decorative/i.test(t));
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|doors and windows/i.test(t));
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|openings|doors and windows/i.test(t));
out.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
c._spaceDialog = { ...c._spaceDialog, hideDecor: true, hideOpenings: true };
await c._saveSpaceDialog();
+1 -1
View File
@@ -61,7 +61,7 @@ out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
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();});
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._areaSel=''; c._saveRoom();});
s = await S();
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
+3 -1
View File
@@ -12,7 +12,9 @@ const st = () => page.evaluate(() => {
active: sr.querySelector('.modetab.active')?.textContent.trim(),
editBtns: sr.querySelectorAll('.head .btn:not(.zb)').length,
gears: sr.querySelectorAll('.tabedit').length,
markupBar: !!sr.querySelector('.editbar'),
// The last bar stays mounted for the exit animation; only an open chrome
// is an active/visible editor bar.
markupBar: !!sr.querySelector('.editorchrome.open .editbar'),
// hpsettle is the transient post-boot grace (AUD-1552-02), not a mode class
stageClass: sr.querySelector('.stage').className.replace(/ ?\bhpsettle\b/, ''),
};
+2 -1
View File
@@ -1,4 +1,4 @@
// Opening drag rulers (owner 2026-08-03): while a door/window is dragged along
// Opening drag rulers (owner 2026-08-03): while a door/window/gate is dragged along
// a wall, a measure badge sits on EACH shoulder (wall end -> opening edge,
// along the wall), and when the opening's center hits the wall's center a
// perpendicular dashed tick appears and the center magnet-snaps (Shift cannot
@@ -20,6 +20,7 @@ await page.evaluate(() => {
return c.updateComplete && true;
});
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.waitForTimeout(220); // editor chrome transition changes stage coordinates
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
+113
View File
@@ -0,0 +1,113 @@
/**
* Room-coloured tunnels in thick walls: door/window/gate, outer and shared
* walls, a hard split on the wall axis, hide/show policies and editor opacity.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
const update = async () => {
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
};
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (v) => Math.round(v / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
dx /= len; dy /= len;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const wall = (a, b, cm = 20) => ({ key: wallKey(a, b), a, b, cm });
sp.rooms = [
{ id: 'tunnel-left', name: 'Left', area: 'tunnel_left_area',
poly: [[0.1, 0.2], [0.5, 0.2], [0.5, 0.7], [0.1, 0.7]],
settings: { fill_mode: 'light' } },
{ id: 'tunnel-right', name: 'Right', area: 'tunnel_right_area',
poly: [[0.5, 0.2], [0.9, 0.2], [0.9, 0.7], [0.5, 0.7]],
settings: {} },
];
sp.walls = [
wall([0.1, 0.2], [0.5, 0.2]),
wall([0.5, 0.2], [0.5, 0.7]),
wall([0.5, 0.7], [0.9, 0.7]),
];
sp.openings = [
{ id: 'tunnel-window', type: 'window', x: 0.3, y: 0.2, angle: 0, length: 0.1 },
{ id: 'tunnel-door', type: 'door', x: 0.5, y: 0.45, angle: 90, length: 0.1 },
{ id: 'tunnel-gate', type: 'gate', x: 0.7, y: 0.7, angle: 0, length: 0.16 },
];
sp.settings = { ...(sp.settings || {}), show_borders: true, hide_openings: false, fill_mode: 'glow' };
c._serverCfg.settings = {
...(c._serverCfg.settings || {}),
fill_colors: {
...(c._serverCfg.settings?.fill_colors || {}),
light_none: { c: '#112233', a: 0.21 },
glow_base: { c: '#445566', a: 0.42 },
},
};
c._setMode('view');
await update();
const tunnels = () => [...root().querySelectorAll('[data-hp="opening-tunnel"]')];
const byId = (id) => root().querySelector(`[data-hp="opening-tunnel"][data-id="${id}"]`);
const out = {};
out.allThreeTypes = tunnels().length === 3;
out.windowRepeatsColdRoom = byId('tunnel-window')?.getAttribute('fill') === '#112233'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-left"]')?.getAttribute('style') || '')
.includes('--room-fill:#112233');
out.gateRepeatsHotRoom = byId('tunnel-gate')?.getAttribute('fill') === '#445566'
&& (root().querySelector('[data-hp="room"][data-id="tunnel-right"]')?.getAttribute('style') || '')
.includes('--room-fill:#445566');
const shared = byId('tunnel-door');
const stops = [...(shared?.querySelectorAll('stop') || [])].map((s) => ({
offset: s.getAttribute('offset'), color: s.getAttribute('stop-color'),
opacity: s.getAttribute('stop-opacity'),
}));
out.sharedUsesOneHardGradient = !!shared?.querySelector('linearGradient') && stops.length === 4;
out.sharedStopsOnWallAxis = stops[1]?.offset === '50.000000%'
&& stops[2]?.offset === '50.000000%';
out.sharedCarriesBothRooms = new Set(stops.map((s) => s.color)).has('#112233')
&& new Set(stops.map((s) => s.color)).has('#445566');
const svgEl = root().querySelector('.stage svg');
const tunnelGroup = root().querySelector('.opening-tunnels');
const wallGroup = root().querySelector('.wallbodies');
out.layerBeforeWall = !!svgEl && !!tunnelGroup && !!wallGroup
&& !!(tunnelGroup.compareDocumentPosition(wallGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
sp.settings.hide_openings = true;
await update();
out.hideSymbolKeepsTunnel = root().querySelectorAll('[data-hp="opening"]').length === 0
&& tunnels().length === 3;
sp.settings.show_borders = false;
await update();
out.hiddenBordersKeepTunnel = root().querySelectorAll('.wallbodies').length === 0
&& tunnels().length === 3;
c._setMode('decor');
await update();
out.backdropUsesSingleGroupOpacity = tunnels().length === 3
&& getComputedStyle(root().querySelector('.opening-tunnels')).opacity === '0.35'
&& byId('tunnel-window')?.getAttribute('fill-opacity') === '0.21';
c._setMode('plan');
await update();
out.planGestureCannotLeaveStalePatch = tunnels().length === 0;
return out;
});
checkAll(result);
await finish(browser, result);
+19 -19
View File
@@ -4,17 +4,21 @@ const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
c._setMode('plan'); c._tool = 'boundary'; await c.updateComplete;
// Shared wall r1|r2 at x=550, y from 140..460. Two-click open full shared stretch.
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
out.hasSpans = Array.isArray(c._curSpaceCfg.open_spans) && c._curSpaceCfg.open_spans.length > 0;
out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot');
const boundaryButtons = [...sr().querySelectorAll('.editbar button')]
.filter((b) => /Boundary|Граница/.test(b.textContent || ''));
out.oneBoundaryTool = boundaryButtons.length === 1;
out.boundaryToolUsesReviewedIcon = boundaryButtons[0]?.querySelector('ha-icon')
?.getAttribute('icon') === 'mdi:border-style';
// View: trimmed outlines + dash
c._setMode('view'); await c.updateComplete;
out.noedge = sr().querySelectorAll('.room.noedge').length >= 2;
@@ -35,19 +39,16 @@ const res = await page.evaluate(async () => {
return Math.abs(x1 - 550) < 0.5 && Math.abs(x2 - 550) < 0.5
&& Math.min(y1, y2) < midY && Math.max(y1, y2) > midY;
});
c._tool = 'openwall';
c._openWallClick([550, 300]); await c.updateComplete;
out.openToolCannotClose = (c._curSpaceCfg.open_spans || []).length > 0;
c._tool = 'closewall'; await c.updateComplete;
// Closing is a separate, single-purpose action (UX-02).
c._closeWallClick([550, 300]); await c.updateComplete;
c._tool = 'boundary';
c._boundaryClick([550, 300]); await c.updateComplete;
out.restoredBySameTool = !(c._curSpaceCfg.open_spans || []).length;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
c._closeWallClick([100, 100]);
c._boundaryClick([100, 100]);
out.missToast = !!c._toast;
// reopen for glow
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
@@ -64,20 +65,19 @@ const res = await page.evaluate(async () => {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
// legacy open_to without spans still expands on read for cuts — force spans for r2-r3
c._openWallClick = c._openWallClick.bind(c);
c._setMode('plan'); c._tool = 'openwall';
c._setMode('plan'); c._tool = 'boundary';
// shared r2|r3 at y=460, x 550..960
c._openWallClick([700, 460]);
c._openWallClick([900, 460]);
c._boundaryClick([700, 460]);
c._boundaryClick([900, 460]);
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
} else out.transitive = 'no r3';
// outer wall refuses open
c._setMode('plan'); c._tool = 'openwall';
c._setMode('plan'); c._tool = 'boundary';
c._toast = null;
c._openWallClick([40, 300]);
c._boundaryClick([40, 300]);
out.outerToast = !!(c._toast && String(c._toast).length);
return out;
});
+71 -16
View File
@@ -5,34 +5,89 @@ const res = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
c._setMode('plan'); c._tool = 'boundary'; await c.updateComplete;
const H = 1000;
// 1) idle: crosshair (draw-like), no preview
// A pure touch tap has no hover/pointermove. P1 must still be visible, and a
// pen uses the same coarse tolerances as touch.
c._boundaryPointerType = 'touch';
c._cursorPt = null;
c._boundaryClick([550, 200]); await c.updateComplete;
out.touchTapShowsAnchor = !!c._openWallAnchor && !!sr().querySelector('.boundary-point');
c._openWallAnchor = null;
c._boundaryPointerType = 'pen';
out.penUsesCoarseTolerance = Math.abs(c._boundaryTolerances().hit - c._cssPxToRender(22)) < 1e-6;
c._boundaryPointerType = 'mouse';
// The semantic hit width remains exactly 12 CSS px at both view scales.
const sp = c._curSpaceCfg;
const originalWalls = sp.walls;
delete sp.walls;
c._cfgEpoch++; c._boundaryTargetMemo = null;
const originalView = c._view ? { ...c._view } : null;
const baseView = c._viewOr(c._baseVb());
const atCssOffset = (px) => c._boundaryTargetAt([
550 + px * c._cssPxToRender(1), 300,
]).kind;
const fineAtBase = [atCssOffset(11), atCssOffset(13)];
c._view = {
x: baseView.x + baseView.w / 4, y: baseView.y + baseView.h / 4,
w: baseView.w / 2, h: baseView.h / 2,
};
const fineAtZoom = [atCssOffset(11), atCssOffset(13)];
out.cssPixelToleranceSurvivesZoom = fineAtBase[0] === 'shared' && fineAtBase[1] === 'none'
&& fineAtZoom[0] === 'shared' && fineAtZoom[1] === 'none';
c._view = originalView;
if (originalWalls) sp.walls = originalWalls;
else delete sp.walls;
c._cfgEpoch++; c._boundaryTargetMemo = null;
// Independent masonry is neutral away from room boundaries, but blocks a
// shared boundary directly below it.
const originalPartitions = sp.partitions;
sp.partitions = [...(originalPartitions || []),
{ id: 'smoke-center-partition', a: [0.2, 0.3], b: [0.4, 0.3], cm: 15 }];
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
out.barePartitionIsNeutral = c._boundaryTargetAt([300, 300]).kind === 'none';
c._toast = '';
c._boundaryClick([300, 300]);
out.barePartitionUsesBoundaryMessage = c._toast === c._t('toast.openwall_pick');
sp.partitions = [...(originalPartitions || []),
{ id: 'smoke-boundary-partition', a: [0.55, 0.14], b: [0.55, 0.46], cm: 15 }];
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
out.partitionAboveBoundaryBlocks = c._boundaryTargetAt([550, 300]).kind === 'blocked';
if (originalPartitions) sp.partitions = originalPartitions;
else delete sp.partitions;
c._cfgEpoch++; c._modelCache = null; c._boundaryTargetMemo = null;
// 1) idle: neutral until a semantic target is under the pointer
c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'crosshair';
out.idleCursor = getComputedStyle(stage()).cursor === 'default';
out.noPreview = !sr().querySelector('.openwall-preview');
// 2) hover shared solid wall: still NO full-edge preview (click target must stay clear)
// 2) solid shared wall: local anchor marker, never a full-edge preview
c._cursorPt = [550, 0.25 * H]; c.requestUpdate(); await c.updateComplete;
out.hoverNoFullEdge = !sr().querySelector('.openwall-preview');
out.hoverLocalMarker = !!sr().querySelector('.boundary-point') && !sr().querySelector('.openwall-preview');
out.hoverCursor = getComputedStyle(stage()).cursor === 'crosshair';
// 3) Opening and closing are separate tools: openwall does not advertise a
// close action, while closewall highlights the exact virtual stretch.
c._openWallClick([550, 140]);
c._openWallClick([550, 460]);
// 3) The same tool predicts the full body that one click will restore.
c._boundaryClick([550, 140]);
c._boundaryClick([550, 460]);
await c.updateComplete;
c._cursorPt = [550, 300]; c.requestUpdate(); await c.updateComplete;
out.openToolDoesNotOfferClose = !sr().querySelector('.openwall-preview');
c._tool = 'closewall'; c.requestUpdate(); await c.updateComplete;
const prev = sr().querySelector('.openwall-preview');
out.closeToolWillClose = prev?.classList.contains('willclose') === true;
out.sameToolWillRestore = prev?.classList.contains('boundary-restore') === true;
out.restoreCursor = getComputedStyle(stage()).cursor === 'pointer';
const planClosedOpenSpan = c._planClosedOpenSpan.bind(c);
let previewPlans = 0;
c._planClosedOpenSpan = (...args) => { previewPlans++; return planClosedOpenSpan(...args); };
c._boundaryPreviewMemo = null;
void c._boundaryPreview; void c._boundaryPreview; void c._boundaryPreview;
c._planClosedOpenSpan = planClosedOpenSpan;
out.restorePreviewIsMemoized = previewPlans === 1;
// 4) with anchor set: rubber-band preview appears
c._closeWallClick([550, 300]);
c._boundaryClick([550, 300]);
await c.updateComplete;
c._tool = 'openwall';
c._openWallClick([550, 200]);
c._boundaryClick([550, 300]);
out.samePlaceSecondTapDoesNotRearm = !c._openWallAnchor;
c._boundaryClick([550, 200]);
c._cursorPt = [550, 400]; c.requestUpdate(); await c.updateComplete;
const band = sr().querySelector('.openwall-preview');
out.anchorPreview = !!band && !band.classList.contains('willclose');
out.anchorPreview = !!band && band.classList.contains('boundary-range');
c._openWallAnchor = null;
return out;
});
+1 -1
View File
@@ -49,7 +49,7 @@ Object.assign(out, await page.evaluate(async () => {
};
await inMode('view', async () => { c._setMode('view'); });
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'openwall', 'closewall', 'delroom']) {
for (const tool of ['draw', 'merge', 'split', 'resize', 'opening', 'boundary', 'delroom']) {
await inMode('plan_' + tool, async () => { c._setMode('plan'); c._tool = tool; });
}
await inMode('devices', async () => { c._setMode('devices'); });
+17
View File
@@ -48,6 +48,23 @@ const rec = await page.evaluate(async () => {
out.noHpbootClass = !!stage && !stage.classList.contains('hpboot');
out.veilGone = !sr().querySelector('.bootveil');
// A detach during resume/nav motion kills their timers. The transient state
// must die with the timers, otherwise the same element comes back veiled or
// permanently clipped by nav-enter.
c._resumeSettling = true;
c._navMotion = 'enter';
c._slide = 'left';
c.requestUpdate(); await c.updateComplete;
c.remove();
out.detachClearsResumeAndNav = c._resumeSettling === false
&& c._navMotion === '' && c._slide === '';
wrap.appendChild(c);
await c.updateComplete;
const stageAfterMotionReattach = sr().querySelector('.stage');
out.reattachHasNoStuckMotionClasses = !!stageAfterMotionReattach
&& !stageAfterMotionReattach.classList.contains('hpnav')
&& !stageAfterMotionReattach.classList.contains('hpresume');
// A2: a second detach DURING the fade must not leave a zombie veil either
customElements.get('houseplan-card')?._warmBootReset?.(); // DEV-B703: this scenario simulates a COLD first open — forget the page's warm re-mount memo
const c2 = document.createElement('houseplan-card');
+10 -6
View File
@@ -21,12 +21,16 @@ const restore = () => page.evaluate((s) => {
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const enter = async (tool) => {
const changed = await page.evaluate((t) => {
const c = window.__card;
const changed = !c._markup;
if (changed) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return Promise.resolve(c.updateComplete).then(() => changed);
}, tool);
if (changed) await page.waitForTimeout(220);
};
const settle = () => page.evaluate(() => new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res))));
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const c = window.__card;
+11 -3
View File
@@ -34,8 +34,8 @@ const res = await page.evaluate(async () => {
// The two-click rubber band used to be below .wallbodies and disappeared
// exactly where it crossed the real T receiver.
c._tool = 'openwall';
c._openWallClick([550, 200]);
c._tool = 'boundary';
c._boundaryClick([550, 200]);
c._cursorPt = [550, 300];
await upd();
const bodyGroup = sr().querySelector('.wallbodies');
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
out.virtualPreviewAboveReal = !!bodyGroup && !!virtualGroup
&& !!(bodyGroup.compareDocumentPosition(virtualGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
c._openWallClick([550, 300]);
c._boundaryClick([550, 300]);
c._cursorPt = null;
await upd();
out.partialSpanCreated = (sp().open_spans || []).length === 1;
@@ -58,6 +58,14 @@ const res = await page.evaluate(async () => {
const viewVirtual = sr().querySelector('.openwalls');
out.savedVirtualBelowRealInView = !!viewBody && !!viewVirtual
&& !!(viewVirtual.compareDocumentPosition(viewBody) & Node.DOCUMENT_POSITION_FOLLOWING);
c._setMode('decor');
await upd();
const decorBody = sr().querySelector('.wallbodies');
const decorVirtual = sr().querySelector('.openwalls');
out.realWallsFadeBehindDecor = !!decorBody
&& getComputedStyle(decorBody).opacity === '0.35';
out.virtualWallsFadeBehindDecor = !!decorVirtual
&& getComputedStyle(decorVirtual).opacity === '0.35';
c._setMode('plan');
await upd();
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
+10 -6
View File
@@ -14,12 +14,16 @@ const restore = () => page.evaluate((s) => {
return c.updateComplete && true;
}, snap);
const enter = (tool) => page.evaluate((t) => {
const c = window.__card;
if (!c._markup) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return c.updateComplete && true;
}, tool);
const enter = async (tool) => {
const changed = await page.evaluate((t) => {
const c = window.__card;
const changed = !c._markup;
if (changed) c._setMode('plan');
c._tool = t; c._rszSel = null; c.requestUpdate();
return Promise.resolve(c.updateComplete).then(() => changed);
}, tool);
if (changed) await page.waitForTimeout(220);
};
const handleCount = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.rszhandle').length);
const roomPolyN = (id) => page.evaluate((id) => {
+7 -2
View File
@@ -1,4 +1,4 @@
// Smoke: houseplan-space-card renders a static, non-interactive schematic + deep-link button.
// Smoke: houseplan-space-card renders a live, 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 () => {
@@ -18,6 +18,7 @@ const res = await page.evaluate(async () => {
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 litMarker = card.renderRoot.querySelector('.hp-static-stage .dev[data-id="d_light1"]');
const btn = card.renderRoot.querySelector('.hp-static-btn');
// deep-link: clicking the button pushes #space=<id>
@@ -35,6 +36,8 @@ const res = await page.evaluate(async () => {
return {
stagePointerEvents: pe,
markers,
litMarkerOn: !!litMarker?.classList.contains('on'),
sharedFacePresent: !!litMarker?.querySelector('ha-icon'),
hasButton: !!btn,
deepLink: pushed,
errorShown: !!errCard,
@@ -45,9 +48,11 @@ await browser.close();
const ok =
res.stagePointerEvents === 'none' &&
res.markers > 0 &&
res.litMarkerOn &&
res.sharedFacePresent &&
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');
console.log('OK space-card: live shared marker face, pointer-events:none, deep-link button, error card');
+7 -2
View File
@@ -15,7 +15,12 @@ const res = await page.evaluate(async () => {
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');
out.livingStyle = {
stroke: liv.style.getPropertyValue('--room-stroke').trim(),
strokeOpacity: Number(liv.style.getPropertyValue('--room-stroke-op')),
fill: liv.style.getPropertyValue('--room-fill').trim(),
fillOpacity: Number(liv.style.getPropertyValue('--room-fill-op')),
};
// 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'));
@@ -54,7 +59,7 @@ checkAll(res, {
"defaultLabels": 0,
"styled": 4,
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"livingStyle": {"stroke": "#ff8800", "strokeOpacity": 0.8, "fill": "#ffd45c", "fillOpacity": 0.18},
"lqiFills": 0,
"atticSquare": true,
// fill_mode 'glow' — the default for NEW spaces since v1.54 (owner call).
+193
View File
@@ -0,0 +1,193 @@
// Always-static display contract across resolver, plan DOM, preview, static
// space card and live-vacuum overlays. The existing dynamic mode still reacts.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const card = window.__card;
const saved = card.hass;
const root = () => card.shadowRoot || card.renderRoot;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const update = async () => {
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const domFace = (node) => node && ({
classes: [...node.classList].filter((name) => [
'static-icon', 'on', 'open', 'alarm', 'unavail', 'valonly',
'activity-running', 'activity-event', 'activity-presence', 'activity-transition',
].includes(name)),
icon: node.querySelector(':scope > ha-icon')?.getAttribute('icon') || '',
satellites: node.querySelectorAll('.tval,.hval,.lqi,.valtext').length,
scale: node.style.getPropertyValue('--dev-scale'),
angle: node.querySelector(':scope > ha-icon')?.style.transform || '',
});
const show = (device, states) => {
card.hass = {
...saved,
entities: {
...saved.entities,
...Object.fromEntries(Object.keys(states).map((entity_id) => [entity_id, {
entity_id, device_id: device.id, platform: 'demo', disabled_by: null,
}])),
},
states: { ...saved.states, ...states },
};
const presentation = card._devicePresentation(device);
card.hass = saved;
return {
classes: presentation.classes,
icon: presentation.icon,
status: presentation.visual.status,
activity: presentation.activity,
temp: presentation.tempText,
lqi: presentation.lqiText,
vacuumLive: presentation.vacuumLive,
};
};
const smoke = {
id: 'static-smoke', name: 'Smoke', icon: 'mdi:smoke-detector',
entities: ['binary_sensor.static_smoke'], primary: 'binary_sensor.static_smoke',
marker: {
binding: 'device:static-smoke', display: 'static_icon',
vacuum: { live: true },
},
};
const relay = {
// Reuse the harness registry entry: `_planHass` deliberately resolves
// against the acquired HA registry, so an invented entity would correctly
// be absent even if it were injected into `hass.states` for this frame.
id: 'd_kettle', name: 'Relay', icon: 'mdi:toggle-switch',
entities: ['switch.kettle'], primary: 'switch.kettle',
marker: { binding: 'device:d_kettle', display: 'badge' },
};
const smokeOn = show(smoke, {
'binary_sensor.static_smoke': {
state: 'on', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const smokeUnavailable = show(smoke, {
'binary_sensor.static_smoke': {
state: 'unavailable', attributes: { device_class: 'smoke', linkquality: 180 },
},
});
const relayOn = show(relay, {
'switch.kettle': { state: 'on', attributes: {} },
});
const markerId = 'static-vacuum';
card._serverCfg.markers.push({
id: markerId,
// Rebind the registered demo mower into f1. A made-up entity would be
// correctly absent from the authoritative HA registry and could not prove
// that the live-vacuum renderer itself was suppressed.
binding: 'device:d_mower',
space: 'f1',
area: 'living_room',
display: 'static_icon',
icon: 'mdi:robot-vacuum',
size: 1.4,
angle: 27,
vacuum: {
source: 'camera.static_robot_map',
live: true,
trail_mode: 'always',
calibration: { m1: [0.001, 0, 0, 0.001, 0, 0] },
},
});
card._layout[markerId] = { s: 'f1', x: 0.3, y: 0.3 };
card.hass = {
...saved,
states: {
...saved.states,
'vacuum.mower': {
entity_id: 'vacuum.mower', state: 'cleaning',
attributes: { friendly_name: 'Static robot' },
},
'camera.static_robot_map': {
entity_id: 'camera.static_robot_map', state: 'idle',
attributes: {
map_name: 'm1', vacuum_position: { x: 500, y: 500, a: 0 },
path: [[200, 200], [350, 350], [500, 500]],
},
},
},
};
card._regSignature = '';
card._cfgEpoch++;
card._setMode('view');
await update();
const planFace = domFace(root().querySelector(`.dev[data-id="${markerId}"]`));
const noPlanVacuumOverlay = !root().querySelector(`.vacpuck[data-mid="${markerId}"]`)
&& !root().querySelector('.vactrail');
card._setMode('devices');
const staticDevice = card._devices.find((item) => item.id === markerId);
card._openMarkerDialog(staticDevice);
await update();
const preview = root().querySelector('hp-device-preview');
await preview?.updateComplete;
const previewFace = domFace(preview?.renderRoot?.querySelector('.dev'));
const staticPreviewHasNoDemo = !preview?.renderRoot?.querySelector('.previewdemo');
const savedMarker = card._serverCfg.markers.find((marker) => marker.id === markerId);
savedMarker.display = 'badge';
card._markerDialog = null;
card._cfgEpoch++;
card._setMode('view');
card.hass = { ...card.hass };
await update();
const dynamicNode = root().querySelector(`.dev[data-id="${markerId}"]`);
const switchedBackToDynamic = dynamicNode?.classList.contains('on')
&& !dynamicNode?.classList.contains('static-icon');
const dynamicVacuumOverlayRestored = !!root().querySelector(`.vacpuck[data-mid="${markerId}"]`)
&& !!root().querySelector('.vactrail');
savedMarker.display = 'static_icon';
card._cfgEpoch++;
card.hass = { ...card.hass };
await update();
const returnedNode = root().querySelector(`.dev[data-id="${markerId}"]`);
const returnCreatesNoActivity = returnedNode?.classList.contains('static-icon')
&& ![...returnedNode.classList].some((name) => name.startsWith('activity-'));
const returnHidesVacuumOverlay = !root().querySelector(`.vacpuck[data-mid="${markerId}"]`)
&& !root().querySelector('.vactrail');
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
staticCard.hass = card.hass;
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector(`.dev[data-id="${markerId}"]`)
&& Date.now() - started < 6000) {
await wait(60);
}
await staticCard.updateComplete;
const staticCardFace = domFace(staticCard.renderRoot?.querySelector(`.dev[data-id="${markerId}"]`));
return {
staticAlarmNeutral: smokeOn.status === 'neutral'
&& smokeOn.activity === 'none'
&& smokeOn.classes.join(' ') === 'static-icon',
staticUnavailableSame: JSON.stringify(smokeOn) === JSON.stringify(smokeUnavailable),
staticSuppressesVacuum: smokeOn.vacuumLive === false,
liveSwitchStillDynamic: relayOn.status === 'working' && relayOn.classes.includes('on'),
planPreviewStaticParity: !!planFace && !!previewFace
&& planFace.classes.includes('static-icon')
&& previewFace.classes.includes('static-icon')
&& JSON.stringify(planFace) === JSON.stringify(previewFace),
planStaticCardParity: !!planFace && !!staticCardFace
&& planFace.classes.includes('static-icon')
&& staticCardFace.classes.includes('static-icon')
&& JSON.stringify(planFace) === JSON.stringify(staticCardFace),
noPlanVacuumOverlay,
staticPreviewHasNoDemo,
switchedBackToDynamic,
dynamicVacuumOverlayRestored,
returnCreatesNoActivity,
returnHidesVacuumOverlay,
};
});
await finish(browser, checkAll(out));
+12 -2
View File
@@ -4,7 +4,7 @@
// 1) устройство: data-hp="device" + data-id + data-entity + data-area;
// 2) комната: data-hp="room" + data-id + data-area;
// 3) подпись комнаты: data-hp="room-label" + data-id (и в SVG, и в HTML-карточке);
// 4) проём: data-hp="opening" + data-id + data-kind (door/window);
// 4) проём: data-hp="opening" + data-id + data-kind (door/window/gate);
// 5) декор: data-hp="decor" + data-id + data-kind (line/rect/ellipse/text);
// 6) вкладка пространства: data-hp="space-tab" + data-id;
// 7) «нет значения — нет атрибута»: у виртуального маркера нет data-entity,
@@ -75,14 +75,24 @@ const res = await page.evaluate(async () => {
c._curSpaceCfg.openings = [
{ id: 'op_d', type: 'door', x: 0.55, y: 0.3, angle: 90, length: 0.09 },
{ id: 'op_w', type: 'window', x: 0.3, y: 0.14, angle: 0, length: 0.12 },
// bottom wall of r1: the room is above it, so an exterior gate must open
// toward +Y. The two compact leaves therefore rotate +10° / -10°.
{ id: 'op_g', type: 'gate', x: 0.3, y: 0.58, angle: 0, length: 0.3 },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.openingHookFindsBoth = qa('[data-hp="opening"]').length === 2;
out.openingHookFindsEveryType = qa('[data-hp="opening"]').length === 3;
const door = q('[data-hp="opening"][data-kind="door"]');
const win = q('[data-hp="opening"][data-kind="window"]');
const gate = q('[data-hp="opening"][data-kind="gate"]');
out.openingKindDoor = door?.getAttribute('data-id') === 'op_d';
out.openingKindWindow = win?.getAttribute('data-id') === 'op_w';
out.openingKindGate = gate?.getAttribute('data-id') === 'op_g';
out.openingKeepsItsClass = !!door && door.classList.contains('opening');
const gateLeaves = [...(gate?.querySelectorAll('.op-leaf') || [])];
out.gateHasTwoLeaves = gateLeaves.length === 2;
out.gateOpensOnlyTenDegreesOutward = gateLeaves[0]?.getAttribute('style')?.includes('rotate(10deg)')
&& gateLeaves[1]?.getAttribute('style')?.includes('rotate(-10deg)')
&& !gate?.querySelector('.op-arc');
// ============ 5. декор ============
c._curSpaceCfg.decor = [
+8 -10
View File
@@ -1,6 +1,6 @@
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
// day/night background vs static, per-space inheritance, cloud fading,
// day/night background vs static, per-space inheritance, weather independence,
// memoisation across hass ticks, editors stay clean.
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
@@ -124,19 +124,16 @@ const res = await page.evaluate(async () => {
delete sp.settings.north_deg;
touchCfg();
// 10) clouds: rain kills the wedges, cloudy only fades them
// 10) legacy weather settings and live weather states no longer participate
cfg().settings.weather_entity = 'weather.home';
touchCfg();
await setWeather('pouring');
await setSun(90, 5);
out.rainKillsRays = domPolys().length === 0;
const stopRain = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.rainDoesNotHideRays = domPolys().length > 0 && Math.abs(stopRain - 0.3) < 1e-6;
await setWeather('cloudy');
const stopCloudy = Number(sr().querySelector('.sunlayer ~ * stop, defs stop')?.getAttribute('stop-opacity')
|| sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudyFades = domPolys().length > 0 && Math.abs(stopCloudy - 0.3 * 0.4) < 1e-6;
await setWeather('sunny');
const stopClear = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.clearFullAlpha = Math.abs(stopClear - 0.3) < 1e-6;
const stopCloudy = Number(sr().querySelector('stop')?.getAttribute('stop-opacity'));
out.cloudsDoNotDimRays = domPolys().length > 0 && Math.abs(stopCloudy - 0.3) < 1e-6;
delete cfg().settings.weather_entity;
touchCfg();
@@ -185,7 +182,7 @@ const dlg = await page.evaluate(() => {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasModeSelect: !!sr.querySelector('hp-dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('hp-dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
dialogHasNoWeatherList: sr.querySelectorAll('#hp-weather-list option').length === 0,
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
};
c._settingsDialog = null;
@@ -238,6 +235,7 @@ await page.evaluate(async () => {
c._setMode('plan'); c._tool = 'opening';
c.requestUpdate(); await c.updateComplete;
});
await page.waitForTimeout(220); // stage coordinates settle with editor chrome
await settle();
const [dx, dy] = await screenPt(960, 600);
const [, dty] = await screenPt(960, 700);
+21
View File
@@ -62,6 +62,27 @@ const res = await page.evaluate(async () => {
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
// A quick tab switch is not a suspended renderer: keep the current painted
// sky and hover in place. The old visibility handler cleared both on every
// return, producing a one-frame whole-plan flash even after two seconds.
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
const room = c._spaceModel().rooms[0];
c._hoverRoom = { space: c._space, room };
c.requestUpdate();
await c.updateComplete;
const skyBeforeQuickReturn = c._skyElev;
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
await c.updateComplete;
out.quickReturnKeepsSky = c._skyElev === skyBeforeQuickReturn;
out.quickReturnKeepsHover = c._hoverRoom?.room === room;
out.quickReturnHasNoSnapFrame = !stage().classList.contains('skysnap')
&& !stage().classList.contains('hpresume');
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
else delete document.visibilityState;
// the tab came back from the background: the sky catches up at once
await setSun(180, 25); // 12° of sun happened while we were not painting
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
+3 -3
View File
@@ -182,17 +182,17 @@ const res = await page.evaluate(async (fadeMs) => {
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a fully overcast sky takes the rim with the light --------------
// ---- 5) a legacy weather setting cannot hide the rim -------------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.noRimInTheRain = rimsOf().length === 0;
out.rimIgnoresRain = rimsOf().length === 2;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimIsBackInTheSun = rimsOf().length === 2;
out.rimStaysAfterLegacyCleanup = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
+4
View File
@@ -8,6 +8,10 @@ const out = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot;
const calls = [];
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'automation.evening': { entity_id: 'automation.evening', platform: 'automation', disabled_by: null },
'script.curtains': { entity_id: 'script.curtains', platform: 'script', disabled_by: null },
'scene.movie': { entity_id: 'scene.movie', platform: 'scene', disabled_by: null } },
states: { ...c.hass.states,
'automation.evening': { state: 'on', attributes: { friendly_name: 'Вечерний свет' } },
'script.curtains': { state: 'off', attributes: { friendly_name: 'Шторы' } },
+5 -1
View File
@@ -11,7 +11,11 @@ const out = await page.evaluate(async () => {
// via the 6-number matrix directly (0.5 scale: robot 0..2000 -> canvas 0..1000)
const M = [0.5, 0, 0, 0, 0.5, 0];
const mkAttrs = (x, y, extra) => ({ vacuum_position: { x, y, a: 45 }, map_name: 'm1', ...extra });
c.hass = { ...c.hass, states: { ...c.hass.states,
c.hass = { ...c.hass,
entities: { ...c.hass.entities,
'vacuum.robo': { entity_id: 'vacuum.robo', platform: 'demo', disabled_by: null },
'camera.robo_map': { entity_id: 'camera.robo_map', platform: 'demo', disabled_by: null } },
states: { ...c.hass.states,
'vacuum.robo': { state: 'docked', attributes: { friendly_name: 'Робот' } },
'camera.robo_map': { state: 'idle', attributes: mkAttrs(600, 800) },
} };
+2 -2
View File
@@ -64,7 +64,7 @@ const out = await page.evaluate(async () => {
// ---- manual fit from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const dev2 = c._devices.find((x) => x.id === 'd_mower');
o.fitFreshNoMarker = !dev2.marker;
c._vacStartFit(dev2); await c.updateComplete;
@@ -76,7 +76,7 @@ const out = await page.evaluate(async () => {
// ---- live checkbox + trail select from scratch (HP-1540-01) ----
cfg.markers = [];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(c._devices.find((x) => x.id === 'd_mower')); await c.updateComplete;
const liveBox = sr().querySelector('.vacbox input[type=checkbox]');
+5 -5
View File
@@ -142,9 +142,9 @@ const res = await page.evaluate(async () => {
// Opening a stretch takes TWO clicks since v1.59.0-beta.6: anchor, then the
// second point on the same shared wall. A span over y=150..350 must refuse
// thickness at y=250, and the solid remainder above/below must keep its own.
c._tool = 'openwall';
c._openWallClick([550, 150]);
c._openWallClick([550, 350]);
c._tool = 'boundary';
c._boundaryClick([550, 150]);
c._boundaryClick([550, 350]);
await upd();
out.spanOpened = ((sp().open_spans || []).length === 1);
c._tool = 'wallthick';
@@ -157,8 +157,8 @@ const res = await page.evaluate(async () => {
&& c._intervalCm([550, 350, 550, 460]) === 25
&& c._intervalCm([550, 200, 550, 300]) === 0;
c._tool = 'closewall';
c._closeWallClick([550, 250]);
c._tool = 'boundary';
c._boundaryClick([550, 250]);
await upd();
out.spanClosed = !(sp().open_spans || []).length;
delete sp().openings;
+17 -7
View File
@@ -9,7 +9,15 @@ const out = await page.evaluate(() => {
const c = window.__card;
const cls = (d, states) => {
const saved = c.hass;
c.hass = { ...c.hass, states: { ...c.hass.states, ...states } };
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
entity_id,
{ entity_id, platform: 'demo', disabled_by: null },
]));
c.hass = {
...c.hass,
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states },
};
const r = c._stateClass(d);
c.hass = saved;
return r;
@@ -33,13 +41,15 @@ const out = await page.evaluate(() => {
'climate.trv': { state: 'off', attributes: { hvac_action: 'off' } },
}) === '';
// без hvac_action выбранный режим heat не доказывает реальную работу
o.modeWithoutActionStaysDark = cls(trv, {
'climate.trv': { state: 'heat', attributes: {} },
}) === '';
// Некоторые интеграции не отдают hvac_action: тогда их текущий режим из
// hvac_modes — единственный доступный сигнал фактической работы.
o.modeWithoutActionIsYellow = cls(trv, {
'climate.trv': { state: 'heat_cool', attributes: { hvac_modes: ['off', 'heat', 'cool', 'heat_cool'] } },
}) === 'on';
// горящая лампа устройства желтит значок, даже если primary — не она
const lamp = { id: 'l', primary: 'sensor.lamp_power', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
// Функциональная роль настоящей лампы желтит значок. Auxiliary light у
// soundbar/media-player, наоборот, отсечён системным role-resolver тестом.
const lamp = { id: 'l', primary: 'light.lamp', entities: ['sensor.lamp_power', 'light.lamp'], marker: null };
o.litLightWins = cls(lamp, {
'sensor.lamp_power': { state: '5' },
'light.lamp': { state: 'on' },
+13 -2
View File
@@ -86,15 +86,26 @@ out.untouchedEditorNoJump = await page.evaluate(async () => {
const c = window.__card;
const raf2 = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
c._setMode('view');
// The previous case has only waited two frames after leaving an editor.
// Let its 180 ms chrome collapse and ResizeObservers settle before taking a
// new baseline; otherwise this case measures the previous transition, not
// the untouched editor round-trip it is meant to verify.
await new Promise((resolve) => setTimeout(resolve, 240));
c._resetZoom();
c._zoomAt(30, 30, 1.6); c._saveZoom();
const before = { ...c._view };
c._setMode('plan'); await raf2();
c._setMode('view'); await raf2();
// Header ResizeObserver publishes its final measured height one frame after
// the 180 ms chrome collapse, then the stage observer performs the last fit.
await new Promise((resolve) => setTimeout(resolve, 320));
const v = c._view;
const beforeCenter = [before.x + before.w / 2, before.y + before.h / 2];
const afterCenter = [v.x + v.w / 2, v.y + v.h / 2];
return Math.abs(c._zoom - 1.6) < 0.01
&& Math.abs(v.x - before.x) < 0.005 && Math.abs(v.y - before.y) < 0.005
&& Math.abs(v.w - before.w) < 0.005 && Math.abs(v.h - before.h) < 0.005;
&& Math.hypot(afterCenter[0] - beforeCenter[0], afterCenter[1] - beforeCenter[1]) < 0.01
&& Math.abs(v.w - before.w) < 0.005
&& Math.abs(v.h - before.h) < 0.005;
});
// -- the view zoom still survives a space switch, per space ---------------
File diff suppressed because one or more lines are too long
+42 -14
View File
@@ -75,7 +75,7 @@ const LAYOUT = {
let CFG_REV=1, LAYOUT_REV=1;
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}];}
function dev(id,name,model,area){return [id,{id,name,model,area_id:area,identifiers:[['demo',id]],config_entries:['ce_demo'],entry_type:null,via_device_id:null,disabled_by: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'),
@@ -91,19 +91,27 @@ const DEVICES=Object.fromEntries([
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'},
'light.ceiling':{entity_id:'light.ceiling',device_id:'d_light1',platform:'demo',config_entry_id:'ce_demo',disabled_by:null},
'light.floor_lamp':{entity_id:'light.floor_lamp',device_id:'d_lamp',platform:'demo',disabled_by:null},
'media_player.tv':{entity_id:'media_player.tv',device_id:'d_tv',platform:'demo',disabled_by:null},
'sensor.living_temp':{entity_id:'sensor.living_temp',device_id:'d_temp',platform:'demo',disabled_by:null},
'switch.kettle':{entity_id:'switch.kettle',device_id:'d_kettle',platform:'demo',disabled_by:null},
'binary_sensor.sink_leak':{entity_id:'binary_sensor.sink_leak',device_id:'d_leak',platform:'demo',disabled_by:null},
'light.bedroom':{entity_id:'light.bedroom',device_id:'d_bedlight',platform:'demo',disabled_by:null},
'binary_sensor.window':{entity_id:'binary_sensor.window',device_id:'d_window',platform:'demo',disabled_by:null},
'lock.front_door':{entity_id:'lock.front_door',device_id:'d_lock',platform:'demo',disabled_by:null},
'binary_sensor.hall_motion':{entity_id:'binary_sensor.hall_motion',device_id:'d_motion',platform:'demo',disabled_by:null},
'vacuum.mower':{entity_id:'vacuum.mower',device_id:'d_mower',platform:'demo',disabled_by:null},
'cover.gate':{entity_id:'cover.gate',device_id:'d_gate',platform:'demo',disabled_by:null},
};
let REGISTRY_ACCESS='full';
const REGISTRY_LISTENERS={device_registry_updated:new Set(),entity_registry_updated:new Set()};
const CONNECTION={subscribeEvents:async(cb,event)=>{
const listeners=REGISTRY_LISTENERS[event];
if(!listeners)return()=>{};
listeners.add(cb);
return()=>listeners.delete(cb);
},subscribeMessage:async()=>()=>{}};
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}},
@@ -127,6 +135,16 @@ function mkHass(){
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==='config/device_registry/list'){
if(REGISTRY_ACCESS!=='full')throw new Error('unauthorized');
return Object.values(DEVICES);
}
if(m.type==='config/entity_registry/list'){
if(REGISTRY_ACCESS!=='full')throw new Error('unauthorized');
return Object.values(ENTITIES);
}
if(m.type==='config_entries/get')return[{entry_id:'ce_demo',domain:'demo',title:'Synthetic Home'}];
if(m.type==='manifest/list')return[{domain:'demo',name:'House Plan Demo'}];
if(m.type==='houseplan/config/get')return{config:CFG,rev:CFG_REV,can_write:true};
if(m.type==='houseplan/layout/get')return{layout:LAYOUT,rev:LAYOUT_REV};
if(m.type==='houseplan/config/set')return{ok:true,rev:++CFG_REV};
@@ -144,7 +162,8 @@ function mkHass(){
STATES={...STATES,[eid]:{...st,state:on?'off':'on'}};
card.hass=mkHass();}
},
connection:{subscribeEvents:async()=>()=>{}},
connection:CONNECTION,
localize:(key)=>key==='component.demo.title'?'Demo integration':null,
formatEntityState:(st)=>st.state,
};
}
@@ -155,5 +174,14 @@ document.getElementById('host').appendChild(card);
card.hass=mkHass();
window.__card=card;
window.__mkHass=mkHass;
window.__setRegistryAccess=(mode)=>{REGISTRY_ACCESS=mode==='full'?'full':'limited';};
window.__setRegistryDisabled=(kind,id,disabledBy='user')=>{
const row=(kind==='device'?DEVICES:ENTITIES)[id];
if(row){
row.disabled_by=disabledBy;
const event=kind==='device'?'device_registry_updated':'entity_registry_updated';
for(const cb of REGISTRY_LISTENERS[event])cb({action:'update',id});
}
};
</script>
</body></html>
+6 -7
View File
@@ -26,20 +26,19 @@ from homeassistant.helpers.typing import ConfigType
DOMAIN = "demo_robot"
TICK_S = 2 # telemetry cadence
SPEED_MM = 600 # per tick ≈ 0.3 m/s, a realistic robot pace
DOCK = (3900.0, 2600.0)
DOCK = (6100.0, 3600.0) # inside Under-Stairs Closet
# Robot-map rooms, mm, Y up (flipped vs screen). Bboxes mirror the plan's
# room polygons; the names MUST equal the plan room names — auto-calibration
# matches by name.
ROOMS: dict[str, tuple[str, float, float, float, float]] = {
"1": ("Гостиная", 480, 4320, 4160, 7360),
"2": ("Кухня", 4160, 5120, 7520, 7360),
"3": ("Спальня", 4160, 2400, 7520, 5120),
"4": ("Кабинет", 480, 2400, 2720, 4320),
"5": ("Прихожая", 2720, 2400, 4160, 4320),
"1": ("Kitchen & Living", 900, 3067, 6300, 6367),
"2": ("Hallway", 3200, 1033, 4533, 3800),
"3": ("Guest Bedroom", 4533, 1033, 6300, 3067),
"4": ("Under-Stairs Closet", 5300, 3067, 6933, 4467),
}
# cleaning order: start at the dock in the hallway, end next to it
CLEAN_ORDER = ("5", "4", "1", "2", "3")
CLEAN_ORDER = ("4", "2", "3", "1")
INSET = 350.0 # keep lanes away from walls
LANE_MM = 650.0 # serpentine lane spacing
+1364 -543
View File
File diff suppressed because one or more lines are too long
+152 -40
View File
@@ -1,6 +1,6 @@
# House Plan architecture
Updated: 2026-07-04 (v1.2.2). The repository = a HACS integration (category **Integration**)
Updated: 2026-08-08 (maintenance after v1.60.2). The repository = a HACS integration (category **Integration**)
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
## Layout
@@ -10,12 +10,14 @@ houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ hp-dialog.ts # shared HA/native modal shell and focus lifecycle
│ ├─ render/opening-tunnels.ts # immutable SVG projection of resolved tunnel geometry/fills
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
│ └─ data/
│ ├─ house.ts # geometry: ROOMS (rooms→area), FLOOR_VB (viewBox), names
│ └─ backgrounds.ts # VECTOR plans (SVG base64) + FLOOR_BG_RECT (positioning)
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
├─ demo/golden/ # deterministic HP-QA-01 matrix, capture/verify/accept
├─ custom_components/houseplan/ # the HA integration
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
│ ├─ trails.py # server-side vacuum trail recorder (state-change driven)
@@ -27,6 +29,11 @@ houseplan-card/
└─ docs/ # this documentation
```
Rollup embeds a source fingerprint in the bundle. Performance and golden-image
tooling compares it with the current `src/` tree before recording results, so a
committed demo snapshot from an older source revision cannot produce a false
baseline.
## Key decisions
1. **One repository — integration + card.** The integration serves the JS
@@ -37,8 +44,16 @@ houseplan-card/
2. **Icon layout lives on the server.** `helpers.storage.Store(1, "houseplan.layout")` →
`.storage/houseplan.layout`. The card reads/writes via `hass.callWS`
(`houseplan/layout/get|set|update`). Fallback — localStorage (when the integration is absent).
3. **No token.** Everything comes from the frontend `hass` object: `hass.states` (reactive),
`hass.devices/entities/areas` (registries). No direct WS connections.
3. **No token.** Everything comes from the frontend `hass` object and its
authenticated connection: `hass.states` is reactive, while a module-level
`ha-binding-status` cache obtains the complete device/entity registries via
`hass.callWS`. There is one fetch/in-flight request and one pair of registry
subscriptions per HA connection, shared by every full/static card on the
page; House Plan creates no direct socket or token. Newly observed rows in
the live frontend projection augment an older full snapshot immediately,
and a changed projection schedules a debounced full reconciliation. This
keeps discovery and `disabled_by` changes reactive even when a registry
event subscription is unavailable.
4. **Reactivity.** Every state change in HA leads to set hass → re-render.
Temperatures/LQI/on-off are live by definition (verified by substituting state).
5. **One modal contract.** Card modals render through `hp-dialog`. In Home
@@ -67,7 +82,19 @@ houseplan-card/
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
(the first resolved state entity for actions requiring one target), temp,
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
the complete resolved role, not this single compatibility field.
the complete resolved role, not this single compatibility field. `entities[]`
contains active runtime entities only; `allEntities[]` is metadata-only, and
`bindingStatus` distinguishes `active`, `ha_disabled`, `orphaned` and
`unverified` without mutating persisted markers.
`resolveHaBindingStatus()` is the only authority for saved HA bindings. Full
registry data wins; a limited-permission client accepts positive live evidence
but never guesses that a missing shortened row means disabled/deleted. Every
plan-level consumer uses the active registry/state projection, so disabled
bindings cannot leak through Glow, climate, LQI, live text, openings, controls
or vacuum rendering. A bounded local runtime cache stores only the last
authoritative active/disabled decision to avoid a stale warm-remount flash; it
is not part of the server config or layout.
Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the prototype:
- only devices with an area from the room list are shown;
@@ -103,15 +130,26 @@ Built from the registries (`_buildDevices`), rules carried over 1-to-1 from the
## Device markers (v1.6.0+)
Per-marker appearance: `display: badge|icon_ripple|value`. All three use one semantic
resolver (`src/device-visual.ts`) for availability, steady status and activity. `badge`
Per-marker appearance: `display: badge|icon_ripple|value|static_icon`. Entity semantics originate in
`src/device-visual.ts`; `src/device-presentation.ts` resolves the complete renderer-ready
projection (sources, value, icon, classes, metrics and explanation), and
`src/device-face.ts` renders that projection on the full plan, device preview and static
space card. `badge`
shows the icon/morph and status plate; `icon_ripple` additionally shows a finite event,
static presence, mechanical transition or actual-work ring; `value` replaces the icon
with the HA-formatted numeric value. A critical alarm is red in every presentation.
with the HA-formatted numeric or text value. Ambiguous/missing/unavailable sources fall
back to the icon instead of selecting an arbitrary registry row. A critical alarm is red
in every dynamic presentation. `static_icon` deliberately keeps the configured/automatic
base icon on one neutral dark plate: state morphing, work/open/alarm/unavailable paint,
activity, RGB, value, temperature/humidity/LQI badges and live vacuum overlays are all
suppressed. Hover/focus, taps, controls and light aggregation keep their normal behaviour.
`normalizeDeviceDisplay()` is the mandatory compatibility gate for every consumer and maps
legacy `ripple` to `icon_ripple`. The marker dialog builds its unsaved draft through `buildDevices`,
then `hp-device-preview` shows the actual projection, integration provenance from
registry/config-entry metadata and an isolated 3.3 s activity demonstration.
Runtime baselines are seeded as soon as a rebuilt registry becomes authoritative, before
the next HA snapshot is classified; source-key changes reset any finite effect immediately.
Legacy `display: ripple` is read as `icon_ripple` and rewritten on the next config save;
the backend accepts it only for compatibility. `ripple_color` and `ripple_size` remain the
The backend accepts legacy `display: ripple` only for compatibility. `ripple_color` and `ripple_size` remain the
stored names for the ordinary activity effect. `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` +
@@ -130,9 +168,12 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
`removed:true` is a binding tombstone, not a renderable marker. It claims an
HA binding against automatic discovery while intentionally exposing that same
binding to the Add picker. Every plan-level resolver excludes its entity or
all entities of its device. Re-adding replaces the tombstone; virtual markers
need no tombstone because they have no discovery source.
binding to the Add picker. A device tombstone excludes all data of that device;
an entity tombstone excludes the standalone entity binding but does not mutate
the same entity out of a still-live parent device. Runtime-filtered references
such as `controls` remain persisted and become active again after re-add.
Re-adding replaces the tombstone; virtual markers need no tombstone because
they have no discovery source.
## Server-side configuration (current shape, v1.51+)
@@ -143,6 +184,7 @@ need no tombstone because they have no discovery source.
"plan_scale", // legacy optional fallback, docs/BACKDROP.md
"view_box":[4],
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
"room_drafts":[…], "partitions":[…], "wall_columns":[…],
"openings":[…], "decor":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden","removed",
"name","icon","display","controls","is_light","tap_action",
@@ -168,11 +210,19 @@ bounded ±5000). Plan files: `<config>/houseplan/plans/<space>.<token>.<ext>`
`/api/houseplan/content/plans/_/<name>` urls; growth is bounded by store
quotas, nothing is ever deleted for being old (docs/SCOPE.md).
## Room geometry rules (v1.19–v1.21)
## Room and independent wall geometry
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
Room-boundary walls remain *derived* from room outlines (`roomEdges`, deduped by
`segKey`), so deleting a room keeps the boundaries its neighbours still
contribute. Three explicitly typed exceptions are stored per space:
`room_drafts` for persisted unfinished room outlines, `partitions` for
one-segment independent walls and `wall_columns` for square/circular columns.
They do not create a room or HA area and never split a room implicitly. Their
physical bodies are unioned with room walls for rendering and light occlusion,
and subtracted from clean room floor area. Openings (doors, windows and gates)
still belong only to derived room walls and never cut an independent object.
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
@@ -182,8 +232,8 @@ bigger part keeps the room identity (name/area/devices).
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/merge/split/resize/opening/openwall/
closewall/wallthick/delroom), `_path` (the current outline,
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
boundary/wallthick/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).
@@ -195,22 +245,36 @@ a newer external config revision is adopted. Positional placement is always quan
grid. Shift may alter a gesture's geometry (square/circle creation, independent
resize axes or free rotation), but it cannot create off-grid coordinates.
**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).
`boundary` is one contextual UI tool over the existing `open_spans` model.
Before the first click, independent physical bodies block the room boundary
below them; otherwise a dashed span wins over a solid shared boundary, and an
outer wall is rejected. A solid shared wall takes two points to open, while a
dashed canonical span is restored whole with one click. Interaction widths and
junction ambiguity are measured in CSS pixels and converted through the live
viewBox, so zoom never changes the effective target. The first point is a
transient gesture only: Esc, Undo/Redo, navigation, external config adoption,
pointer cancellation and multi-touch discard it without touching history.
Every completed segment of an unfinished room contour is persisted in
`room_drafts`, including the thickness selected when that segment was placed.
Switching tools keeps it; the same editing session resumes automatically, and
after reload either endpoint can be selected to continue. Closing converts the
draft into a room, while the room dialog's secondary action converts its edges
to independent partitions. `partition` creates exactly one wall per gesture;
`column` creates a square object whose size comes from the current Thickness
field. Double click edits physical-object properties, pointer drag moves the
whole object on the grid, and Delete removes only the selected object. The
legacy root `space.segments` array is still stripped on every save.
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+)
## Doors, windows & gates (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?}`
`{id, type: door|window|gate, 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
@@ -219,13 +283,34 @@ 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
are two casement leaves. A gate has the same data/light/contact/lock semantics as a door, but
uses two half-width leaves opening only 10° toward the exterior face and no large swing arc.
Its default width in the editor is 300 cm. `openingAmount` (pure) maps the contact state to
0..1: no sensor → door/gate 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.
For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall
interval and adjacent room on each side of the centreline. Opening symbols,
wall cuts and room-coloured tunnel patches all consume this association; none
has a separate nearest-wall fallback. A candidate must be genuinely adjacent
to the opening axis, so a detached parallel room inside one grid cell cannot
own the far half. Full-width coverage, signed inner-face distance, room area
and stable room id form the deterministic tie order.
The full card caches that index and the batch tunnel geometry by space,
`_cfgEpoch` and complete room/wall/opening geometry. A normal HA state tick
therefore resolves only live room fill values, not `roomWallProfile` again.
The batch helper removes already-painted intervals from later overlapping
openings, preventing double alpha. A base patch beneath Glow/sun repeats the
same frame-local effective fill as the room shape. Outer openings give the one
room both halves; shared openings use a local-coordinate hard stop at `y=0`.
Virtual spans, unfinished drafts and zero-thickness walls are ignored; legacy
spans are clipped per atomic body.
## Integration WS API
| Command | Parameters | Response |
@@ -401,9 +486,11 @@ Shared, framework-light modules keep the two views from diverging:
`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 filtering) 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.
borders/names + device markers (via `buildDevices`, same filtering) with NO marker
handlers. Current states, values, alarms, temperature/LQI badges and witnessed activity
use the shared `ResolvedDevicePresentation` and `renderDeviceFace`; optional card settings
can disable ordinary live dressing, temperature or signal without creating another
semantic implementation.
- `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`.
@@ -426,8 +513,9 @@ hash falls back to the default.
decor+room magnet; `hp-color-opacity` is the shared colour/alpha control.
`houseplan-card.ts` still owns orchestration, but every kind uses one
selection/transform/history pipeline. `DECOR_SCHEMA` accepts canonical
`width_cm`, text `size_cm`, opacity/fill fields and legacy width/text-size
representations for read compatibility.
`width_cm`, text `size_cm`, opacity/fill fields, optional per-line
`line_style` (`solid` / `dashed`; the frontend omits the legacy solid
default), and legacy width/text-size representations for read compatibility.
- **Plan image transform**: `planRect()` resolves the fitted image plus
`plan_x/y`, independent `plan_scale_x/y` and `plan_angle`; legacy
`plan_scale` feeds both axes. The image is interactive only in its own
@@ -440,7 +528,16 @@ hash falls back to the default.
cancel on opposite windings — field bug v1.36.3). Radius: global
`settings.glow_radius_cm` + per-marker `glow_radius_cm`. On a thick wall the
doorway sector is the angular intersection of its near- and far-face clear
spans, so the jamb returns clip the spill as a real opening tunnel.
spans, so the jamb returns clip the spill as a real opening tunnel. The
shared light resolver marks external `controls` as non-spatial: they still
vote in room state/statistics and drive group actions, but never place a Glow
pool at the controller. If the same entity also has a real lamp marker, that
physical marker owns its unique Glow position regardless of registry order.
Room-coloured opening tunnels are a separate layer below Glow/sun. Their
geometry and cache remain owned by `wall-thickness.ts`/the card orchestrator;
`render/opening-tunnels.ts` only maps resolved immutable inputs to the stable
SVG contract. This is the first render-only HP-ARCH-01 extraction and must not
become a second geometry or fill model.
- **Open boundaries** (v1.37, revised 2026-08): `space.open_spans` hold
geometric virtual stretches; `room.open_to` remains the light-zone index
derived from spans (legacy `open_to`-only configs expand to full
@@ -452,17 +549,32 @@ hash falls back to the default.
span to **every** surviving shared segment. Adjacent pieces owned by different
room pairs stay separate: their midpoints are the source of the corresponding
`open_to` links after Split (`AUD-159B7-01`).
- **Marker controls** (v1.36): `marker.controls[]` (lights/switches only)
toggled as one HA-group-semantics service call on the marker's EXPLICIT
tap_action=toggle; icon state/tint mirrors the targets.
- **Marker controls** (v1.36): persisted `marker.controls[]` is a lossless,
ordered external-target list. Opening and saving the dialog preserves
duplicates and temporarily unknown/vendor targets, removing only the
marker's own bound/device entities. The runtime projection separately
de-duplicates and filters to currently controllable lights/switches, then
toggles them as one HA-group-semantics service call on the marker's explicit
`tap_action=toggle`; icon state/tint mirrors those effective targets.
- **Resolved device state** (2026-08-06): HA provides states per entity, not
one state per device. `resolvedDeviceStateEntities` therefore starts from
uncategorised registry entities, resolves one functional role (whole-device
domains, then semantic binary signals, then switches), and aggregates
domains, then semantic binary signals, then one representative switch), and aggregates
passive readings as the final fallback. `_visualSamples` consumes the full
result; `primaryEntity` only selects its first member where a single action
target is required. Integration option switches can no longer make an
otherwise healthy device working or unavailable merely by list order.
otherwise healthy device working or unavailable merely by list order. For
A switch-only device never aggregates sibling option switches: integrations
which fail to categorise night mode, voice enhancement or child lock cannot
paint the whole marker as working. When generic HA metadata identifies a
dedicated Power switch in such a composite controller, `on` is a neutral
powered lifecycle and `off` reuses the faded unavailable presentation; a
lone relay keeps normal working-state yellow. For `climate`, a recognized
explicit `hvac_action` is authoritative: idle remains
neutral and heating/cooling/preheating/defrosting are working. Unknown vendor
pseudo-actions are ignored; when no recognized action exists, a current
non-off state advertised by `hvac_modes` (or a standard HA HVAC mode) is the
best available enabled-mode fallback.
- **Resolved light sources** (UX-12): `resolvedLightSources(hass, devices,
room?)` is the only light-membership resolver. Per marker the precedence is
`controls[]` -> the primary controllable entity when `is_light` is set ->
+1 -1
View File
@@ -1,6 +1,6 @@
# Plan image backdrop
Status: current in v1.60.0-beta.1. The complete Background editor contract is in
Status: current in v1.60.0. The complete Background editor contract is in
`DECOR-EDITOR.md`.
## Placement model
+16 -5
View File
@@ -79,7 +79,7 @@ tested. Input is a list of **items**, one per drawn/placed object:
* every room (its own bounding box — polygon or legacy rect);
* the backdrop image rectangle, when the space has one;
* every opening (door/window) end-to-end segment;
* every opening (door/window/gate) end-to-end segment;
* every decor shape;
* every device the layout actually places in this space **and that the card
actually draws** — a HIDDEN device (docs/FILTERING.md) is not content: the
@@ -395,10 +395,11 @@ Three things were fixed here besides the new coverage:
There is no free-position mode. `Shift` never suspends coordinate
snapping: free anchors always land on grid nodes, and wall-bound objects
always remain projected and quantised along their wall. It modifies only
the current gesture: square/circle creation, independent resize axes, free
decor/backdrop rotation, the compass step, or bypassing the furniture wall
magnet while the ordinary decor/room/grid magnet remains active. It cannot
create an off-grid object.
the current gesture: a room-outline segment is constrained to the nearest
45° direction and the nearest grid node on that ray; square/circle creation,
independent resize axes, free decor/backdrop rotation, the compass step, or
bypassing the furniture wall magnet while the ordinary decor/room/grid magnet
remains active. It cannot create an off-grid object.
### 9.5 «Оптимизировать планы» — explicit whole-plan maintenance
@@ -520,3 +521,13 @@ undo because a grid projection is not invertible.
breaking storage change for old clients and buys nothing.
* The outlier hint has no "hide this object" action. Deciding what to
do with a stray marker is the device editor's job.
## Independent wall geometry
`room_drafts[].points`, `partitions[].a/b` and `wall_columns[].center` use the
same normalized-X coordinate convention as `room.poly`; both axes are divided
by `NORM_W`. Every interactive write passes through the global grid snap and
the ±`CANVAS_LIMIT` guard. Rigid partition drag clamps one shared delta against
both endpoints, so it cannot deform the segment or let its far endpoint cross
the backend boundary. Hit areas and drag thresholds are expressed in CSS
pixels, therefore selection remains usable at every zoom.
+203 -1
View File
@@ -1,6 +1,194 @@
# Changelog
## v1.60.0-beta.1 — 2026-08-07
## v1.60.3-beta.1 — 2026-08-08
- Returning to a briefly backgrounded browser tab no longer forces a one-frame
day/night/hover reset that could make the whole plan flash. Long suspended
tabs keep the protected settle and immediate sun catch-up.
- Thick-wall openings now use one cached wall association for their symbol,
wall cut and room-coloured tunnel. Nearby detached walls and perpendicular
T-junction arms can no longer steal a tunnel side, while overlapping openings
no longer darken the room fill twice. A legacy opening whose saved angle or
offset no longer actually aligns with a wall is no longer allowed to cut or
shift that wall; re-snap the opening in the Plan editor to restore it.
- Expanded always-static device coverage now verifies RGB suppression,
size/rotation, lifecycle priority, mode round trips, plan/preview/static-card
parity and live-vacuum overlay removal.
- Maintenance groundwork adds an offline compatibility-field registry/config
audit, a schema-valid large-house performance baseline with stale-bundle and
precise-memory guards, deterministic golden-image capture/review
infrastructure and the first render-only extraction from the root card.
These changes do not alter saved data or the plan's SVG/interaction contract.
## v1.60.2 — 2026-08-08
- Door, window and gate tunnels in thick walls now continue the effective room
fill instead of exposing a white paper strip. An exterior opening uses its
room colour through the full wall depth; a shared opening splits cleanly on
the wall centreline when its rooms use different fills. Glow, window sun
rays and opening symbols keep their existing behaviour.
- Device display now has an **Always static icon** option. It keeps the normal
dark icon unchanged by work, open, alarm, unavailable, RGB, values or sensor
badges, and hides live vacuum puck/trail overlays. Hover, actions, controls
and the device's separate contribution to room light remain unchanged. The
former **Icon** option is now named **Icon + dynamic plate**.
- A controller no longer becomes a physical Glow origin merely because it has
managed light targets. `controls` still drives group toggling, room light
state and statistics, while the actual lamp marker owns the light pool. A
marker's own legacy `switch.*` self-control is no longer inferred as a light;
relay-driven fixtures require the explicit “This device is a light source” flag.
## v1.60.2-beta.3 — 2026-08-08
- Saved devices disabled in Home Assistant are now forced off the plan and all
plan calculations until re-enabled, while the Device editor keeps a labelled
service ghost. Registry refresh now also recovers from unavailable update
subscriptions, and preview/static-card status hooks and explanations stay in
sync. Small accessibility, routing and preview-parity fixes are included.
- Openings now include Gate. It keeps the same contact, lock, wall cut and Glow
behaviour as a door, but defaults to 300 cm and uses two half-width leaves
opening only 10° outwards, without a plan-obscuring full swing arc.
- Sunlight through windows no longer depends on a weather entity: clouds,
rain and snow cannot dim or hide geometrically valid rays. The obsolete
weather selector is removed; legacy stored values are safely ignored.
- Device settings now include a live display preview with the actual current
state, effective source entities, the providing Home Assistant integration,
an explanation of the resulting marker, and a safe local activity demo.
The full plan, preview and read-only space card now share one presentation
resolver and one marker-face renderer.
- Existing `Value instead of icon` markers can now show localized text states,
not only numeric measurements. Long valid values are ellipsized without
changing the marker hit area, while their full text remains accessible.
- When several equally valid visual sources exist, `Value instead of icon` no
longer picks the first registry entity arbitrarily: it falls back to the
device icon and the preview explains the ambiguity.
- Decorative lines can now be switched between Solid and Dashed in the
properties dialog opened by double-clicking a finished line. Existing and
newly drawn lines stay solid by default, and dashed gaps retain a comfortable
selection target in the Background editor.
- The Plan editor now has one contextual Boundary tool instead of separate
Virtual wall and Physical wall buttons. Two points on a solid shared wall
open a virtual stretch; one click on a dashed stretch restores its complete
physical body with the inherited thickness previewed before the click.
Screen-space hit zones stay usable at every zoom, ambiguous junctions and
independent masonry block unsafe edits, and an unfinished first point is
cancelled by Esc, Undo/Redo, navigation, reconnect or multi-touch.
- The shared `hp-dialog` now wraps long translated titles and device names
instead of clipping them horizontally. Numeric angle fields across editors
display at most three decimal places without rounding stored geometry.
## v1.60.2-beta.2 — 2026-08-08
- Composite switch-only devices now use one representative lifecycle entity
instead of treating every uncategorised feature toggle as whole-device
activity. A dedicated Power switch is selected from generic Home Assistant
metadata: powered-on appliances stay neutral, while off/unavailable ones use
the existing faded presentation. Standalone relays retain their normal
yellow working state.
- Device information uses the wide responsive dialog shell and a wrapping
footer which keeps Edit, Open in HA and Close aligned on narrow screens.
- New Room has one save path: choosing “No area” in the area list and entering
a name enables the regular Save action and stores `area: null`; the duplicate
“No area” footer button has been removed.
- Erase in the Background editor gives thin lines and outlined shapes an
invisible 16 px screen-space target without changing their appearance. The
target remains usable at every zoom level.
## v1.60.2-beta.1 — 2026-08-07
- The final review follow-up makes geometry cache invalidation root-safe and
LRU-bounded, keeps pan/pinch available over saved outline hit targets,
rejects partially numeric editor values, preserves absent column angles in
Optimize Plans and gives rotation handles a full 24 px touch target. Boolean
geometry now has a lossless sequential fallback, and draft joining plus
editor-to-editor height animation are covered by realistic browser smokes.
- The Plan editor now supports persistent unfinished room outlines,
room-independent partitions and square/circular columns. They stay on the
grid, participate in clean floor area, Glow and window-ray occlusion, and
share the geometry Undo/Redo stack. Select provides drag, properties and a
rotation handle for square columns; saved outlines can be resumed or joined
endpoint-to-endpoint. Client and backend limits are aligned and invalid
thicknesses are rejected visibly instead of being silently clamped.
- Audit hardening adds cached clean-floor/light geometry, complete blocking for
a light embedded in masonry, leak-free point shadows behind long partitions,
strict column validation, reconnect gesture cancellation and browser smoke
coverage for the new physical objects.
- While drawing a room outline, Shift now locks the new segment to the nearest
45° direction. The preview and committed vertex match, and the endpoint
remains strictly grid-bound.
- The Plan editor toolbar now uses the compact labels Merge, Virtual wall,
Physical wall and Thickness. Undo and Redo are icon-only while retaining
localized tooltips and accessible names.
- The Background editor now de-emphasizes physical, thick and dashed virtual
walls to the same 35% opacity as every other non-decor plan layer.
- Switching between editors on the same space now visibly fades in the new
toolbar and interpolates its measured height. Different wrapping and tool
composition no longer make the plan jump; rapid switches continue smoothly
from the current intermediate height.
- Audit hardening makes editor history unambiguous on QWERTZ/AZERTY and
non-Latin layouts, gives SVG text an atomic Gecko/WebKit hit-test fallback,
and fully resets or disables hidden navigation/editor chrome after lifecycle
changes. Exact header sizing no longer leaves a stale viewport.
- Opening and saving a marker no longer drops ordered, duplicate or temporarily
unknown external controls. Climate recognizes preheating/defrosting and
ignores vendor pseudo-actions before using its documented mode fallback;
Optimize Plans repairs zero, null and negative grid scales to the 5 cm default.
## v1.60.1-beta.1 — 2026-08-07
- Climate markers whose integrations omit `hvac_action` now fall back to the
current non-off HA mode advertised in `hvac_modes`, so enabled air
conditioners regain their yellow working plate. A present action remains
authoritative, therefore `hvac_action: idle` stays neutral.
- Room settings now opens at the medium dialog width. Long radio labels and
source names wrap or ellipsize within the content area, eliminating the
horizontal scrollbar while retaining responsive mobile sizing.
- Erase now treats a text label as one atomic decor object across its full
bounding box. Clicking between glyphs or on its selection glow can no longer
remove only the apparent outline; an empty-canvas miss is a true no-op.
- Space tabs, View/editor changes and switches between editors now use one
short, subtle transition. Editor controls expand and collapse smoothly, and
reduced-motion preferences still disable all navigation motion.
- While drawing a room outline, Ctrl/Cmd+click now closes the last point back
to the first without adding another vertex. Closure requires at least two
existing edges and refuses degenerate or self-intersecting contours.
- Editor Undo/Redo shortcuts now use layout-independent keyboard codes, so
Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y work with non-Latin layouts too.
Focused text and number fields retain their native browser history.
- Closed the v1.60.0 follow-up audit: reconnecting during resume settle can no
longer strand the plan veil; legacy self-controlled switches retain their
light-source intent and migrate to `is_light`; colour/opacity pickers use the
browser top layer and flip/clamp inside small dialogs; Optimize repairs
out-of-range `cell_cm` while preserving unknown/duplicate control targets;
entity-bound legacy relays discard only their exact self-control and keep
explicitly added sibling light targets; wall SVG fallbacks now use valid
literal colours.
## v1.60.0 — 2026-08-07
- **Audit hardening.** Deleting one virtual marker no longer removes its
neighbours; real `v_*` ids remain positionable; deletion tombstones are
hidden for old clients and binding-scoped for entity members of live HA
devices. Runtime-filtered controls are no longer destructively saved. Trail
deletion is transactional. Optimisation clamps migrated physical styles,
validates `cell_cm`, preserves invalid legacy transforms for manual repair
and reports model-version bookkeeping honestly. Flat shapes are rejected,
furniture selectors initialise reliably, rotated resize keeps its fixed
corner, mode switches cancel live transforms, and the demo bundle is rebuilt
from the same source as production. English device names containing
`thermometer` now resolve as temperature sensors instead of matching the
broader meter rule.
- Auto icons now update immediately after rebinding and can be explicitly
pinned; imperial stroke-field bounds use inches; decor magnet geometry is
memoised; Undo and Redo share the same live-gesture boundary.
- A marker can no longer list its own bound entity (or an entity of its bound
device) as an external light control. Legacy self-controls are ignored immediately;
marker Save removes them, and plan optimisation also cleans a directly bound
entity self-reference. The explicit `is_light` switch
remains the only way to classify that bound relay as a light source. Active
fans/hoods still use the yellow working-state plate without entering Glow,
Light fill or room light statistics.
- **The Background editor now has one coherent transform and style system.**
Every decor kind can be selected, moved, resized proportionally (Shift for
@@ -42,6 +230,20 @@
a model exception. Mixed media entities fade only when none of them is
currently available and powered.
- **Returning to a long-suspended View is stable.** In normal View (not kiosk),
transient zero/partial ResizeObserver boxes no longer overwrite the saved
viewport. After a genuinely long background sleep the plan is revealed only
after the stage size is measurable and quiet, then zoom and centre are
applied in one frame. Critical wall fill/stroke attributes also live directly
on the SVG paths, so a warm Lovelace re-mount cannot show the browser's black
default paint while component styles are being restored. Editors, kiosk and
quick tab switches keep their existing immediate behaviour.
- **Device-info actions stay inside narrow dialogs.** The Edit, Open in HA and
Close actions now wrap as complete buttons when the small HA dialog cannot
fit one row; reduced mobile padding preserves useful width. The leading Edit
action can no longer be pushed beyond the left edge of the modal.
## v1.59.2 — 2026-08-07
- **A11Y-02: one accessible dialog shell.** Every House Plan modal now uses

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