Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1deb0beab | ||
|
|
9590011ddb | ||
|
|
ddbd3288fe | ||
|
|
e177c14603 | ||
|
|
164f7cf76a | ||
|
|
2219700d63 | ||
|
|
854a6944a0 | ||
|
|
2a8302f4d6 | ||
|
|
f1537b2108 | ||
|
|
5e1315f61d | ||
|
|
4e29db1afb | ||
|
|
953063b984 | ||
|
|
d48d220a8c | ||
|
|
1c949ae49d | ||
|
|
9956a6cfe6 | ||
|
|
fe5f5b6a24 | ||
|
|
6db9eb0a66 | ||
|
|
3028122016 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -5,3 +5,4 @@ test-build/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 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,6 +15,11 @@
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
@@ -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.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -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", [])
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {})),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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.`);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -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) };
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
@@ -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'));
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`, // та же константа, что в баннере
|
||||
|
||||
@@ -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) «Оптимизировать планы» -----------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/, ''),
|
||||
};
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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'); });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: 'Шторы' } },
|
||||
|
||||
@@ -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) },
|
||||
} };
|
||||
|
||||
@@ -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]');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 ---------------
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||