Compare commits

...
Author SHA1 Message Date
Matysh 6d0f97ef82 test: accept beta.5 visual baselines 2026-08-12 13:10:46 +03:00
Matysh bd9b6a6d75 Stabilize beta.5 transition validation 2026-08-12 13:02:22 +03:00
Matysh 4b03b888ff Release v1.62.0-beta.5 candidate 2026-08-12 12:43:27 +03:00
Matysh 58d952e94e test: align beta.4 action smokes 2026-08-12 10:36:10 +03:00
Matysh 5d3f580df0 test: accept beta.4 help icon golden baselines 2026-08-12 10:26:56 +03:00
Matysh 4dc3fdef36 Release v1.62.0-beta.4 candidate 2026-08-12 10:19:51 +03:00
Matysh 8a417539e0 test: align beta.3 action smokes
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m57s
Validate / backend (push) Failing after 5m9s
Validate / performance_smoke (push) Failing after 1m40s
Validate / golden (push) Failing after 1m44s
Validate / smoke (push) Failing after 12m18s
2026-08-12 02:42:57 +03:00
Matysh c41231a7ba Release v1.62.0-beta.3 candidate 2026-08-12 02:33:54 +03:00
Matysh 2158e5d3f6 test: align beta.2 visual and badge gates
Validate / hacs (push) Failing after 44s
Validate / hassfest (push) Failing after 1m19s
Validate / frontend (push) Successful in 4m49s
Validate / golden (push) Failing after 2m9s
Validate / smoke (push) Failing after 2m15s
Validate / backend (push) Failing after 7m27s
Validate / performance_smoke (push) Failing after 6m30s
2026-08-11 22:21:09 +03:00
Matysh 554d2e6544 Release v1.62.0-beta.2 candidate 2026-08-11 22:12:08 +03:00
Matysh 2cf5c2748e test: accept v1.62.0-beta.1 golden matrix 2026-08-11 19:37:53 +03:00
Matysh 59d028caf7 fix: wrap backup import confirmation 2026-08-11 19:30:56 +03:00
Matysh 4381f65fde test: restore wall thickness in golden fixture 2026-08-11 19:23:49 +03:00
Matysh 446f33ed31 Release v1.62.0-beta.1 candidate 2026-08-11 19:15:21 +03:00
Matysh 9419842333 fix(hacs): keep exactly one *manifest.json in the tree
Validate / hassfest (push) Failing after 14s
Validate / frontend (push) Successful in 8m18s
Validate / backend (push) Failing after 10m34s
Validate / smoke (push) Failing after 24m45s
Validate / performance_smoke (push) Failing after 9m42s
Full Performance / performance (push) Failing after 54m31s
Validate / golden (push) Failing after 6m50s
Validate / hacs (push) Failing after 11s
The HACS submission check does not read hacs.json to find the integration: it
globs `*manifest.json` over the whole clone of the default branch and exits 1
unless there is exactly one (hacs/default, scripts/helpers/integration_path.py).
Three files matched — the two stand-only integrations added on 2026-07-31 and
the golden baseline index added on 2026-08-11 — so the Hassfest job of PR #9004
went red five weeks into the review queue, with a log that named no file.

The stand manifests ship as manifest.template.json and demo/stand/install.sh
renames them at install time; the golden index becomes baselines-index.json
(the exported constant keeps its name, so no consumer changes).
test/repo-hygiene.test.mjs fails if a second manifest ever appears, and the
existing golden-policy assertion — which compared against 'manifest.json' and
happily passed on 'baseline-manifest.json' — now checks the suffix.
2026-08-11 14:03:38 +03:00
Matysh 02373bb31f Release v1.61.0
Validate / performance_smoke (push) Failing after 8m49s
Validate / hacs (push) Failing after 14s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 7m12s
Validate / golden (push) Failing after 6m12s
Validate / backend (push) Failing after 11m16s
Validate / smoke (push) Failing after 25m49s
Full Performance / performance (push) Failing after 53m37s
2026-08-11 08:32:16 +03:00
Matysh 9a4ecbf961 test: keep Linux golden baselines authoritative 2026-08-11 05:44:58 +03:00
Matysh 48eebfd8a5 Release v1.61.0-beta.8 candidate 2026-08-11 05:40:32 +03:00
Matysh 5c5833d69a fix: stabilize prerelease signing checks 2026-08-11 03:14:56 +03:00
Matysh a41a75eba5 test: accept visual continuity golden matrix v8 2026-08-11 02:57:05 +03:00
Matysh 6f34c06d74 test: repair persisted golden marker fixture 2026-08-11 02:53:47 +03:00
Matysh c237baaffd Release v1.61.0-beta.7 candidate 2026-08-11 02:49:42 +03:00
Matysh d2bc908280 Release v1.61.0-beta.6 candidate 2026-08-11 01:14:59 +03:00
Matysh b0c29fb57f fix: accept skipped prerelease announcement
Validate / hacs (push) Failing after 14s
Validate / hassfest (push) Failing after 11s
Validate / frontend (push) Successful in 4m0s
Validate / backend (push) Failing after 5m37s
Validate / smoke (push) Failing after 2m25s
Validate / golden (push) Failing after 2m23s
Validate / performance_smoke (push) Failing after 35m49s
2026-08-10 18:18:20 +03:00
Matysh 37d71d8cbb test: accept light-source golden matrix v6 2026-08-10 18:05:04 +03:00
Matysh f8f1718ad2 Release v1.61.0-beta.5 candidate 2026-08-10 18:01:16 +03:00
Matysh c8b06996b1 Fix exact-blob prerelease artifacts
Validate / hacs (push) Failing after 15s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 2m41s
Validate / backend (push) Failing after 7m59s
Validate / golden (push) Failing after 6m45s
Validate / smoke (push) Failing after 18m41s
Validate / performance (push) Failing after 2h36m42s
2026-08-10 15:21:18 +03:00
Matysh 1ed281b0dd Release v1.61.0-beta.4 candidate 2026-08-10 14:32:20 +03:00
Matysh d55816acaf test: accept custom fill golden matrix v5 2026-08-10 10:05:05 +03:00
Matysh cd55a8e897 Release v1.61.0-beta.3 candidate 2026-08-10 09:44:31 +03:00
Matysh 7d152e04f9 test: align smoke contracts with independent Glow
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 10s
Validate / frontend (push) Successful in 2m21s
Validate / backend (push) Failing after 8m22s
Validate / golden (push) Failing after 7m32s
Validate / smoke (push) Failing after 16m23s
Validate / performance (push) Failing after 1h35m4s
2026-08-10 00:35:23 +03:00
Matysh 764129a45c test: accept Glow golden matrix v4 2026-08-10 00:29:26 +03:00
Matysh fb382bfa11 Release v1.61.0-beta.2 candidate 2026-08-10 00:24:42 +03:00
Matysh 112c260314 Release v1.61.0-beta.1
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 13s
Validate / frontend (push) Successful in 3m2s
Validate / golden (push) Failing after 51s
Validate / backend (push) Failing after 6m50s
Validate / smoke (push) Failing after 13m44s
Validate / performance (push) Failing after 24m13s
2026-08-09 21:51:33 +03:00
Matysh 5814cfe0c2 ci: handle first performance-gated main promotion
Validate / smoke (push) Failing after 19m59s
Validate / hacs (push) Failing after 26s
Validate / hassfest (push) Failing after 22s
Validate / frontend (push) Successful in 3m58s
Validate / golden (push) Failing after 1m8s
Validate / backend (push) Failing after 10m47s
Validate / performance (push) Failing after 25m35s
2026-08-09 15:44:05 +03:00
Matysh d839eb88eb test: accept editor tray golden baselines 2026-08-09 15:30:14 +03:00
Matysh bf5a040508 Release v1.60.3 2026-08-09 15:20:50 +03:00
Matysh faa1f4ea9a fix: settle editor chrome and opening render regressions 2026-08-09 15:14:58 +03:00
Matysh caf3c44ad9 ci: validate branch pushes without duplicate tag runs 2026-08-09 08:52:28 +03:00
Matysh 08b19363fd ci: make performance gate runner-noise aware 2026-08-09 08:50:26 +03:00
Matysh d5e6c5cff0 test: accept editor context tray golden baselines 2026-08-09 08:40:57 +03:00
Matysh c85dbaf4cb v1.60.3-beta.2: stabilize editor context tray 2026-08-09 08:34:05 +03:00
Matysh b1deb0beab Avoid hassfest manifest collision
Validate / hacs (push) Failing after 8s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 3m52s
Validate / backend (push) Failing after 8m33s
Validate / golden (push) Failing after 6m9s
Validate / smoke (push) Failing after 22m51s
2026-08-08 22:03:09 +03:00
Matysh 9590011ddb Add reviewed Linux golden baselines 2026-08-08 21:52:26 +03:00
Matysh ddbd3288fe Fix prerelease smoke regressions 2026-08-08 21:42:49 +03:00
Matysh e177c14603 v1.60.3-beta.1: harden rendering and QA tooling 2026-08-08 21:33:09 +03:00
Matysh 164f7cf76a chore(hacs): zip_release + workflow attaching houseplan.zip to every release
HACS will install from the named asset instead of the auto zipball, so
GitHub's public download counter becomes a per-version install metric.
The workflow also has a manual dispatch to backfill an existing release.
2026-08-08 19:43:24 +03:00
Matysh 2219700d63 Release v1.60.2
Validate / hacs (push) Failing after 12s
Validate / hassfest (push) Failing after 12s
Validate / frontend (push) Successful in 3m58s
Validate / backend (push) Failing after 8m11s
Validate / smoke (push) Failing after 22m39s
2026-08-08 17:39:25 +03:00
Matysh 854a6944a0 test: align smokes with active registry projection 2026-08-08 15:55:33 +03:00
Matysh 2a8302f4d6 v1.60.2-beta.3: unify boundaries and device presentation 2026-08-08 15:46:07 +03:00
Matysh f1537b2108 v1.60.2-beta.2: harden device state and editor interactions
Validate / hassfest (push) Failing after 19s
Validate / hacs (push) Failing after 20s
Validate / frontend (push) Successful in 3m2s
Validate / backend (push) Failing after 9m17s
Validate / smoke (push) Failing after 14m1s
2026-08-08 00:12:47 +03:00
Matysh 5e1315f61d ci: announce releases and prereleases in the Telegram chat
Validate / hacs (push) Failing after 20s
Validate / hassfest (push) Failing after 23s
Validate / frontend (push) Successful in 3m38s
Validate / smoke (push) Failing after 53s
Validate / backend (push) Failing after 7m14s
2026-08-07 22:35:10 +03:00
Matysh 4e29db1afb test: settle zoom baseline after editor collapse 2026-08-07 22:18:03 +03:00
Matysh 953063b984 fix: keep wall thickness beside room outline 2026-08-07 22:09:42 +03:00
Matysh d48d220a8c v1.60.2-beta.1: add persistent physical geometry 2026-08-07 22:02:41 +03:00
Matysh 1c949ae49d test: poll navigation background transitions
Validate / hacs (push) Failing after 11s
Validate / hassfest (push) Failing after 9s
Validate / frontend (push) Successful in 4m38s
Validate / backend (push) Failing after 10m49s
Validate / smoke (push) Failing after 25m55s
2026-08-07 14:39:24 +03:00
Matysh 9956a6cfe6 test: settle navigation transitions in browser smokes 2026-08-07 14:31:02 +03:00
Matysh fe5f5b6a24 v1.60.1-beta.1: harden editing and device state 2026-08-07 14:19:02 +03:00
Matysh 6db9eb0a66 test: use access token for non-admin websocket
Validate / hacs (push) Failing after 9s
Validate / hassfest (push) Failing after 8s
Validate / frontend (push) Successful in 4m33s
Validate / backend (push) Failing after 10m31s
Validate / smoke (push) Failing after 20m7s
2026-08-07 13:10:27 +03:00
Matysh 3028122016 v1.60.0: harden background editing and device state 2026-08-07 13:02:46 +03:00
Matysh 29fb9deb43 v1.60.0-beta.1: unify background editing and device deletion 2026-08-07 11:14:20 +03:00
Matysh 6a9122f41f v1.59.2: make dialogs accessible 2026-08-07 07:47:37 +03:00
Matysh 25f43da1bd v1.59.1: unify device and light state
Validate / smoke (push) Failing after 19m18s
Validate / hacs (push) Failing after 1m11s
Validate / hassfest (push) Failing after 1m37s
Validate / frontend (push) Successful in 3m0s
Validate / backend (push) Failing after 15m20s
2026-08-06 21:45:18 +03:00
Matysh e7aca9c678 v1.59.0: release stable plan editing 2026-08-06 17:43:53 +03:00
Matysh 5ca4c7e5c5 v1.59.0-rc.2: make plan editing predictable 2026-08-06 16:48:45 +03:00
Matysh e0f6746d7f v1.59.0-rc.1: optimize plans and polish editor feedback
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m12s
Validate / backend (push) Failing after 8m53s
Validate / smoke (push) Failing after 13m51s
2026-08-06 10:14:52 +03:00
Matysh d2bec266ed v1.59.0-beta.10: unify device visuals and wall refinements
Validate / hacs (push) Failing after 6s
Validate / hassfest (push) Failing after 6s
Validate / frontend (push) Successful in 3m19s
Validate / smoke (push) Failing after 1m12s
Validate / backend (push) Failing after 7m45s
2026-08-05 23:38:01 +03:00
Matysh 4868cc0786 v1.59.0-beta.9: fix mixed-wall resize and virtual T-junctions
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 10s
Validate / frontend (push) Successful in 3m1s
Validate / backend (push) Failing after 14m22s
Validate / smoke (push) Failing after 19m22s
2026-08-05 20:44:26 +03:00
Matysh e188f9d609 v1.59.0-beta.8: audit follow-ups and inner-corner sun rays 2026-08-05 20:07:27 +03:00
MatyshandCursor 3ad4e9d803 docs: drop unshipped release-gate bullet from beta.7 notes
AUD-159B6-05 needs a workflow-scoped push of release.yml; it is not in the tag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:25 +03:00
MatyshandCursor 7333223a55 v1.59.0-beta.7: audit fixes, wall fill under hatch
Atomic wall intervals, open_spans in resize/Undo/Split/Delete, backend
open_spans schema, warm-nav ownership, smoke hygiene.
Wall fill colour paints under the hatch (both, not either/or).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:52:26 +03:00
MatyshandCursor de5e8129a1 v1.59.0-beta.6: partial open spans and wall-centric Delete
Two-click openwall writes space.open_spans; openings forbidden on virtual;
Delete closes virtual then merges or deletes rooms with confirm.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:07:23 +03:00
MatyshandCursor b44d2fb958 docs: design for open spans and wall-centric Delete
Capture brainstorming decisions for partial virtual walls and Delete merge/room flow before implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:53:10 +03:00
MatyshandCursor ee87d3d00e v1.59.0-beta.5: wall-thickness redesign, draw thickness, plan on new space
Seamless ±½ wall rings with inner floor/area/sun; Draw toolbar thickness
(default 15 cm); new spaces open Plan; audit hygiene from beta.4 recheck.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:13:19 +03:00
MatyshandCursor a7d956a072 v1.59.0-beta.4: wall thickness + white editor sheet with backdrop
Validate / backend (push) Failing after 9m24s
Validate / smoke (push) Failing after 22m47s
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 9s
Validate / frontend (push) Successful in 2m58s
Plan-editor wall thickness (docs/WALL-THICKNESS.md) and keep the white drawing sheet under the grid in editors even when a backdrop image is loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:27:09 +03:00
MatyshandCursor 309bd59358 v1.59.0-beta.3: furniture, hide layers, styling hooks
Third 1.59 pre-release: top-view furniture at real size, hide-decor /
hide-openings toggles, stable card-mod data-* hooks, HA value formatting,
editor polish, and the audit P0/P3 follow-ups. Wall thickness is spec-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:46:11 +03:00
MatyshandCursor 31cd4142ac feat(dev): furniture, hide layers, styling hooks, wall-thickness spec
Ship the unreleased 1.59 batch on dev: top-view furniture in the decor
layer, space toggles to hide decor/openings, stable card-mod data-*
hooks, HA entity value formatting, and the approved wall-thickness
spec (docs only — not implemented yet).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:41:00 +03:00
Cursor AgentandMatysh cb2b064c6a docs(AGENTS): note local houseplan-dev path is invisible to cloud agents
Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:26:42 +00:00
Cursor AgentandMatysh e6579f1e55 fix(dev): audit P0/P3 — write policy, README diff, validation
- Default admin_only on; config/get returns can_write; card editors follow it
  and fail closed without hass.user (P0-4).
- README EN/RU differentiation vs GUI draw cards / easy-floorplan (P0-3).
- Tighten marker binding, ripple_color, decor extents, space id (P3-4).
- quality_scale test path + deprecated card tap_action note (P3-5).
- Demo hass.user + can_write; unique marker id in upload overwrite test.

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:10:51 +00:00
Cursor AgentandMatysh 1b37427f1d merge main: AGENTS.md + audit pack into dev
Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 08:05:03 +00:00
MatyshandGitHub 4c260f0ea0 Merge pull request #5 from Matysh/cursor/project-audit-6009
Validate / frontend (push) Successful in 2m3s
Validate / smoke (push) Failing after 20m45s
Validate / hacs (push) Failing after 11s
Validate / backend (push) Failing after 7m50s
Validate / hassfest (push) Failing after 11s
docs: полный аудит проекта (рынок, качество, пробелы, рекомендации)
2026-08-05 11:04:55 +03:00
MatyshandGitHub 159f4f43c0 Merge pull request #4 from Matysh/cursor/setup-dev-environment-6009
chore: document Cursor Cloud dev environment setup
2026-08-05 11:04:53 +03:00
Cursor AgentandMatysh 99f7c3a4a9 docs: full project audit pack for agents (market, quality, gaps)
Add docs/AUDIT*.md covering competitive landscape (easy-floorplan 11→430★),
implementation quality, functional integrity, and P0–P3 recommendations.
Refresh PRODUCT.md competitor claim and point STATUS watchlist at the pack.

Co-authored-by: Matysh <Matysh@users.noreply.github.com>
2026-08-05 04:56:01 +00:00
Matysh 0ee80a6a52 v1.59.0-beta.2: live text labels, text block frame, warm-memo owners
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Failing after 13m24s
2026-08-04 23:44:59 +03:00
Matysh 1397a71f84 v1.59.0-beta.1: warm remount keeps your view and dialogs, sun ray rim
Validate / smoke (push) Failing after 22m41s
Validate / frontend (push) Successful in 2m47s
Validate / backend (push) Failing after 8m13s
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
2026-08-04 18:20:47 +03:00
402 changed files with 88985 additions and 6813 deletions
+14
View File
@@ -0,0 +1,14 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.mp4 binary
*.webm binary
*.zip binary
*.woff binary
*.woff2 binary
+95
View File
@@ -0,0 +1,95 @@
name: Announce release
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
# Stable releases are announced; prereleases are deliberately silent.
# workflow_dispatch exists purely as a connectivity test button and therefore
# remains allowed to send a test message.
on:
release:
types: [published]
workflow_dispatch: {}
workflow_call:
inputs:
reusable:
required: true
type: boolean
tag:
required: true
type: string
release_name:
required: true
type: string
url:
required: true
type: string
prerelease:
required: true
type: boolean
ref:
required: true
type: string
secrets:
TELEGRAM_BOT_TOKEN:
required: true
TELEGRAM_CHAT_ID:
required: true
permissions:
contents: read
jobs:
telegram:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
runs-on: ubuntu-latest
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
CALLED: ${{ inputs.reusable }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_NAME: ${{ inputs.release_name }}
INPUT_URL: ${{ inputs.url }}
INPUT_PRE: ${{ inputs.prerelease }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
RELEASE_BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
if [ "$CALLED" = "true" ]; then
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
else
TAG=$RELEASE_TAG
NAME=$RELEASE_NAME
URL=$RELEASE_URL
PRE=$RELEASE_PRE
BODY=$RELEASE_BODY
fi
if [ "$PRE" = "true" ]; then
echo "Prerelease Telegram announcement is disabled"
exit 0
fi
KIND="🏠 Релиз"
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
+172
View File
@@ -0,0 +1,172 @@
name: Full Performance
on:
# Every main promotion is a stable-release candidate and must have an
# exact-SHA full comparison before stable assets are published.
push:
branches:
- main
schedule:
- cron: "0 4 * * 1"
workflow_dispatch:
inputs:
comparison_ref:
description: "Optional baseline tag, branch or SHA; empty uses the candidate parent"
required: false
type: string
permissions:
contents: read
concurrency:
group: full-performance-${{ github.ref }}
cancel-in-progress: false
jobs:
performance:
# Base and candidate stay sequential on one hosted runner. Splitting them
# across runners would turn machine variance into a false regression.
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out candidate
uses: actions/checkout@v4
with:
path: candidate
fetch-depth: 2
- name: Resolve comparison SHA
id: base
working-directory: candidate
env:
EVENT_NAME: ${{ github.event_name }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
MANUAL_BASE: ${{ inputs.comparison_ref }}
run: |
set -euo pipefail
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --force --tags --prune --unshallow origin
else
git fetch --force --tags --prune origin
fi
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$MANUAL_BASE" ]; then
sha="$(git rev-parse "${MANUAL_BASE}^{commit}" 2>/dev/null || true)"
source="manual comparison ref $MANUAL_BASE"
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
sha="$PUSH_BEFORE_SHA"
source="push before"
else
sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
source="candidate parent"
fi
requested_sha="$sha"
usable=true
reason=""
if [ -z "$sha" ] || ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
usable=false
reason="commit is not present after fetching all remote refs"
elif [ "$source" = "push before" ] && ! git merge-base --is-ancestor "$sha" HEAD; then
usable=false
reason="commit is no longer an ancestor of the pushed revision"
fi
if [ "$usable" != true ]; then
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
if [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
sha="$parent_sha"
source="candidate parent (unusable requested-base fallback)"
echo "::warning::Comparison SHA ${requested_sha:-none} is unusable ($reason); using candidate parent $sha."
usable=true
fi
fi
if [ "$usable" != true ]; then
fallback_tag=""
fallback_sha=""
head_sha="$(git rev-parse HEAD)"
while IFS= read -r tag; do
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) continue ;;
esac
tag_sha="$(git rev-list -n 1 "$tag")"
if [ "$tag_sha" != "$head_sha" ]; then
fallback_tag="$tag"
fallback_sha="$tag_sha"
break
fi
done < <(git tag --merged HEAD --sort=-version:refname)
if [ -z "$fallback_sha" ]; then
echo "::error::No usable comparison commit or previous release tag is reachable from HEAD."
exit 1
fi
sha="$fallback_sha"
source="release tag $fallback_tag"
echo "::warning::Using $fallback_tag ($sha) as the comparison base."
fi
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
sha="$(git rev-parse HEAD^)"
source="candidate parent (HP-PERF-01 compatibility)"
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
- name: Check out base SHA
uses: actions/checkout@v4
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
candidate/package-lock.json
baseline/package-lock.json
- name: Install candidate and baseline dependencies
run: npm ci --prefix candidate && npm ci --prefix baseline
- name: Install pinned Chromium
working-directory: candidate
run: npx playwright install --with-deps chromium
- name: Build both exact source trees
run: |
npm --prefix candidate run build
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
npm --prefix baseline run build
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
- name: Capture base and candidate profiles
working-directory: candidate
run: |
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
- name: Enforce relative and absolute performance budgets
working-directory: candidate
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v4
with:
name: full-performance
path: artifacts/performance
+205
View File
@@ -0,0 +1,205 @@
name: Publish prerelease
run-name: Publish ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: "Exact prerelease tag, for example v1.61.0-beta.4"
required: true
type: string
permissions:
contents: write
actions: read
concurrency:
group: publish-prerelease-${{ inputs.tag }}
cancel-in-progress: false
jobs:
gate:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Pin the current dev candidate
id: candidate
env:
TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
test "$REF_NAME" = "dev" || {
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
exit 1
}
SHA=$(git rev-parse HEAD)
git fetch origin dev
test "$(git rev-parse origin/dev)" = "$SHA" || {
echo "::error::The dispatched SHA is no longer the origin/dev tip"
exit 1
}
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Verify version, changelogs and bilingual release notes
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
publish:
needs: gate
runs-on: ubuntu-latest
outputs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Build and verify both release assets before publication
env:
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
npm ci
npm run build
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
VERSION=${TAG#v}
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
unzip -l houseplan.zip | grep -q "manifest.json"
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
test "$ZIP_VERSION" = "$VERSION" || {
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
exit 1
}
test -s dist/houseplan-card.js
test -s houseplan.zip
- name: Create or verify the annotated tag
env:
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$PEELED" || {
echo "::error::Existing remote tag $TAG is not annotated"
exit 1
}
test "$PEELED" = "$SHA" || {
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
exit 1
}
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" "$SHA" -m "$TAG"
git push origin "$TAG"
fi
- name: Stage, verify and publish the prerelease
id: release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
fi
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
--repo "$GITHUB_REPOSITORY" --clobber
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
- name: Verify the public release and assets
id: verify
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
throw new Error('release is not a public prerelease for the requested tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
echo "url=$URL" >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v7
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((release) => release.prerelease && !release.draft);
if (first?.tag_name !== process.env.EXPECTED_TAG) {
core.setFailed(
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
process.env.EXPECTED_TAG,
);
}
announce:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ needs.gate.outputs.tag }}
release_name: ${{ needs.gate.outputs.tag }}
url: ${{ needs.publish.outputs.url }}
prerelease: true
ref: ${{ needs.gate.outputs.tag }}
secrets: inherit
+40
View File
@@ -0,0 +1,40 @@
name: Attach HACS zip to release
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
# dev, stable tags once it reaches main.
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
# once for the latest stable after the hacs.json change reaches main).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach the zip to"
required: true
permissions:
contents: write
jobs:
zip:
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: unzip -l houseplan.zip | grep -q "manifest.json"
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
+80 -1
View File
@@ -4,16 +4,95 @@ on:
types: [published]
permissions:
contents: write
actions: read
jobs:
build:
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
# workflow only built and uploaded, so an asset shipped while both Validate
# runs for the very same commit were red. The asset now waits for a green
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
#
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# HEAD is the peeled commit even when TAG is annotated. Do not trust
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
build:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Verify compositor frame continuity for a stable release
if: ${{ !github.event.release.prerelease }}
run: |
npx playwright install --with-deps chromium
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v4
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v2
with:
files: dist/houseplan-card.js
hacs-discovery:
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
# sorting SemVer. A valid asset can therefore be invisible to beta users
# (beta.10 appeared after beta.9). Keep the release asset, but
# make that distribution failure impossible to miss in the release run.
if: ${{ github.event.release.prerelease }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v7
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((r) => r.prerelease && !r.draft);
const expected = context.payload.release.tag_name;
if (first?.tag_name !== expected) {
core.setFailed(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
`Use an rc/new version line or correct the release ordering before announcing the update.`,
);
}
+93 -10
View File
@@ -1,9 +1,19 @@
name: Validate
on:
push:
# The branch commit is the release-gate authority. An annotated tag points
# to the same SHA and must not duplicate the browser validation jobs.
branches:
- '**'
pull_request:
schedule:
- cron: "0 4 * * 1"
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
concurrency:
group: validate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
hacs:
runs-on: ubuntu-latest
@@ -13,18 +23,22 @@ jobs:
uses: hacs/action@main
with:
category: integration
hassfest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
with:
node-version: 22
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
@@ -32,23 +46,25 @@ 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.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
with:
node-version: 22
cache: npm
- run: npm ci
- 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)
- name: Build a fresh bundle for the smokes
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
@@ -72,10 +88,77 @@ jobs:
name: smoke-logs
path: /tmp/smoke-logs
golden:
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- 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
performance_smoke:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: frontend
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
- name: Build the exact candidate source
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
- name: Enforce absolute smoke ceilings
run: |
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-smoke
path: artifacts/performance-smoke
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
+2
View File
@@ -5,3 +5,5 @@ test-build/
__pycache__/
.pytest_cache/
.venv-backend/
artifacts/
.agents/
+55 -3
View File
@@ -8,12 +8,64 @@ House Plan is one HACS package with two parts plus a demo harness:
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Two-agent workflow
House Plan is developed by two agents: **Codex is the author** (analysis,
estimate, spec, implementation) and **Claude is the reviewer** (estimate, spec,
code). They exchange remarks through a local, git-ignored message bus —
**read `.agents/PROTOCOL.md` before acting on any task**. It defines the
message format, the estimate scales every agent must use, the three stages
(`estimate` → `spec` → `code`), the three-round convergence limit and what gets
published to GitHub.
Two rules that matter even if you read nothing else:
- write only into the *other* agent's inbox, never edit a file you did not
create, and move a processed message to `.agents/archive/`;
- "verified" without a command and its output is not evidence — from either side.
## Canonical backlog
GitHub is the only active backlog for House Plan:
- [GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the
canonical task records: problem, scope, acceptance criteria and discussion.
- [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is
the canonical prioritization and workflow-status view. Every open in-scope
issue must be present there.
Before starting planned work, find or create its issue and keep its description,
labels and Project status current as decisions and implementation state change.
Close an issue only after the result is verified. Specs, audits and ADRs may
remain under `docs/`, but must link to their issue and must not become a parallel
task list. When repository documentation disagrees with Issues or Project v2,
the GitHub backlog wins.
## Cursor Cloud specific instructions
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
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
expect to `ls` or diff that folder. To bring local work into the cloud
agent: push a branch to GitHub and say its name, or run a **local** Cursor
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.
+82
View File
@@ -0,0 +1,82 @@
# Code review — issue #68 (contextual help)
Date: 2026-08-12
Branch: `dev`
Scope: `hp-help`, Houseplan help factory, localization contract, dialog/overlay lifecycle,
keyboard and touch interaction, responsive placement, consumers and regression coverage.
## Outcome
The overlay, focus, Escape, outside-click, scroll, Popover/fallback and visual-viewport
paths are internally consistent. The shared dialog overlay registry is used correctly,
the trigger remains reachable in disabled fieldsets through `legend`, and all seven
current call sites have non-empty RU/EN body and ARIA strings.
Four hardening findings were accepted and fixed locally. No model, saved configuration
or user data contract changed.
## Findings and resolutions
### CR68-01 — dead trigger is rendered without help content (P1)
`hp-help` blocked `_openHelp()` when `text` was empty, but `render()` still returned a
focusable button. The result was the exact reported defect: a visible help glyph that
could not open any explanation.
Resolution: `hp-help` renders nothing unless both trimmed `text` and `ariaLabel` exist.
The same predicate guards opening and closes an already-open surface if either value is
removed dynamically.
### CR68-02 — missing translation could be displayed as a key (P1)
The card factory called `t()` directly. Its intended generic fallback returns the key
name when neither dictionary contains a value, so a future incomplete help pair could
produce a real trigger with implementation text such as `marker.foo.help`.
Resolution: the factory now checks the localized value and its derived `.aria` value
through `hasTranslation()` before it creates `hp-help`. English fallback remains valid;
a genuinely absent or whitespace-only pair produces no host and no layout gap.
### CR68-03 — accessible name had a hard-coded English fallback (P1)
Direct use without `ariaLabel` produced `aria-label="Help"`. This violated the issue
contract requiring a complete localized accessible name and made an incomplete component
look valid to keyboard and screen-reader users.
Resolution: the fallback was removed. Missing ARIA copy suppresses the affordance just
like missing visible copy.
### CR68-04 — icon did not follow the product icon system (P2)
The trigger used a font `?`, whose shape and optical alignment depended on the platform
font and did not visually mean “question in a circle”.
Resolution: the glyph is now the shared MDI `help-circle-outline` vector inside the same
32/40 px target. It remains decorative because the button already has a full ARIA label.
### CR68-05 — regression coverage missed incomplete content (P2)
The smoke covered all open/close and overlay paths but never instantiated an empty or
half-configured component, so CR68-01/03 could pass the release gate.
Resolution: the #68 smoke now asserts that empty body and empty ARIA copy create no
trigger, and that restoring a complete pair creates the circled-question SVG.
## Reviewed without changes
- Mouse hover timing, keyboard focus, touch click and the second-Escape dialog path.
- `aria-describedby` only while open; the visible bubble stays hidden from the
accessibility tree to avoid duplicate announcements.
- Exclusive transient-surface ownership with the colour/opacity picker and toast.
- Popover API path and dialog-owned fallback portal.
- Cached dialog scroll-listener cleanup and disconnect cleanup.
- Visual viewport placement, flipping and edge clamping.
- Existing call sites and RU/EN localization parity.
## Verification policy
Per project policy, no tests were run during this local edit. Static type checking,
syntax checking and whitespace validation are recorded in the handoff; the updated
targeted smoke is intended for the next prerelease gate.
+180
View File
@@ -0,0 +1,180 @@
# Код-ревью issue #94 — универсальное «Переключить состояние»
- **Дата:** 2026-08-12
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
- **Проверенная версия:** локальный `dev` после `v1.62.0-beta.3`, включая
незакоммиченные исправления #95–#97
- **Итог ревью до правок:** changes requested — 2 high, 4 medium, 1 minor
- **Итог после локальных правок:** замечания устранены; проверки отложены до
ближайшего pre-release по принятому правилу владельца
## 1. Охват
Проверены:
1. нормативный алгоритм и acceptance criteria в
`docs/specs/094-universal-state-toggle.md`;
2. pure resolver `src/device-toggle.ts`;
3. target selection через exact binding, device role и `controls`;
4. capability/security/service guards;
5. dialog projection, hint, lossless Save и preview;
6. обычный click, confirmation re-resolve и обработка ошибок;
7. общий cover target для действия и presentation;
8. backend schema и import/export round-trip;
9. unit/smoke-матрица и архитектурная документация;
10. совместимость с визуальной непрерывностью #73 и локальными правками
#95–#97.
## 2. Найденные и исправленные замечания
### CR94-01 — High: domain service ошибочно считался capability конкретной entity
**Было:** `POWER_DOMAINS` разрешал `climate`, `water_heater`, `siren` и
`camera`, если нужный service существовал на уровне domain. Но HA публикует
services для всего domain; неподдерживающая их конкретная entity всё равно
оставалась «исполняемой» в hint, а вызов затем отклонялся Home Assistant.
Это прямо противоречило §9.1 и mutation gate 6 ТЗ. Home Assistant Core
подтверждает entity-level guards:
- Climate `TURN_OFF=128`, `TURN_ON=256`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/climate/const.py
- Water heater `ON_OFF=8`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/water_heater/__init__.py
- Siren `TURN_ON=1`, `TURN_OFF=2`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/siren/const.py
- Camera `ON_OFF=1`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py
**Исправлено:** введён декларативный `POWER_ADAPTERS` с state semantics,
unknown policy и точными feature masks. Feature-gated entity теперь получает
команду только при наличии требуемых bits; service catalog остаётся вторым
guard. Media player и legacy vacuum включены в тот же реестр.
**Покрытие:** параметрические unit-матрицы для всех базовых power adapters и
для climate, media player, siren, water heater, camera, legacy vacuum — как
разрешённые, так и запрещённые/missing-feature варианты; отдельная матрица
`unknown` проверяет полный/неполный capability mask.
### CR94-02 — High: click мог использовать target из сохранённого визуального frame
**Было:** #73 намеренно может некоторое время показывать последний цельный
`_renderDevices` snapshot, но `_clickDevice(ev, d)` разрешал action прямо по
переданному `d`. Если binding/controls изменились до атомарной смены frame,
нажатие без confirmation могло вызвать прежнюю цель. Confirmation уже делал
повторное разрешение, обычный click — нет.
**Исправлено:** в View действие сначала находит текущий `DevItem` в
`this._devices` по стабильному marker id. Action, binding, controls и command
разрешаются только из него; исчезнувший marker даёт no-op. Локальная
House Plan info-card по-прежнему может использовать видимый snapshot — это
безопасная read-only поверхность и намеренный контракт исправления #96.
**Покрытие:** smoke сохраняет старый `DevItem`, меняет controls, перестраивает
live devices и проверяет, что click вызывает только новую группу.
### CR94-03 — Medium: неизвестный persisted action расходил UI и runtime
**Было:** неизвестный token на light проецировался как default `toggle`, тогда
как `toggleOriginOf()` правильно не признавал его toggle-origin. Селектор мог
показать «Переключить состояние», hint оставался пустым, а click был no-op.
**Исправлено:** light default применяется только к действительно отсутствующему
token (`null`, `undefined`, пустая legacy-строка). Неизвестное значение fail-
closed проецируется в локальную карточку; backend по-прежнему отклоняет его при
записи.
### CR94-04 — Medium: legacy cover терял identity после disable в HA
**Было:** legacy `tap_action: cover` искал cover только в active
`device.entities`, если рядом оставался хотя бы один активный sibling. После
disable cover в HA старое явное намерение превращалось в анонимный no-target и
presentation переставал знать прежнюю cover entity.
**Исправлено:** legacy-cover origin сначала сохраняет приоритет активной cover,
а при её отсутствии ищет историческую цель в `allEntities`. Общий resolver
возвращает `ha-disabled` и сохраняет тот же cover identity для hint/presentation,
но более ранняя disabled registry row не может заслонить рабочую cover. Новый
device-role toggle по-прежнему исключает disabled rows.
### CR94-05 — Medium: пустой service catalog считался поддержкой всех services
**Было:** отсутствие/пустой `hass.services` давало optimistic `true` для любого
service. Это нарушало runtime guard из ТЗ и позволяло построить команду без
доказательства её существования.
**Исправлено:** отсутствующий catalog/domain/service теперь означает
`unsupported`. После появления актуального HA snapshot resolver автоматически
пересчитывает hint и command. Синтетический HA в `demo/srv/demo.html` теперь
публикует явный service catalog, поэтому smoke-среда проверяет тот же fail-closed
контракт и не создаёт ложные no-op.
### CR94-06 — Medium: device binding не выбирал первую действительно поддерживаемую entity роли
**Было:** resolver выбирал первую entity «подходящего domain», а затем мог
остановиться на `unsupported`, хотя следующая равноправная entity той же
functional role имела требуемую capability. Это не соответствовало формулировке
§8.1 «первая поддерживаемая entity».
**Исправлено:** проверка идёт по уже выбранной shared functional role.
Capability-unsupported peer можно пропустить только внутри неё; missing,
unavailable и secure identity сохраняются без retarget. Config/diagnostic
switch более слабой роли по-прежнему никогда не подставляется.
### CR94-07 — Minor: статус ТЗ оставался «готово к реализации»
**Исправлено:** ТЗ, specs index, архитектура, STATUS, TESTING и RU/EN changelog
актуализированы под опубликованную beta.3 и этот локальный hardening pass.
## 3. Проверенные инварианты без изменений
- exact `entity:` binding не ищет sibling при unsupported/missing/unavailable;
- raw external controls владеют tap только у explicit toggle и не дают fallback
на собственную entity контроллера;
- passive forced-light marker сохраняет единственное документированное driver-
исключение и дедупликацию;
- partial group вызывает только отображённое доступное подмножество;
- any-on/all-off group semantics соответствует ТЗ;
- lock, alarm и cover classes `garage`/`door`/`gate` остаются secure no-op;
- cover/valve open/close/stop используют одновременно feature bit и service;
- confirmation сравнивает target set, а направление намеренно пересчитывается
по текущему state;
- legacy `cover` и отсутствующий default-light action сохраняются lossless до
явного изменения select;
- backend принимает текущие actions и legacy `cover`, неизвестные tokens
отклоняет; import/export сохраняет action-поля без преобразования;
- отдельного `cover` в текущем UI нет;
- right-click, long press, touch/pinch и confirmation UX этим проходом не
менялись.
## 4. Изменённые файлы
- `src/device-toggle.ts`
- `src/houseplan-card.ts`
- `test/device-toggle.test.mjs`
- `demo/smoke_controls.mjs`
- `demo/srv/demo.html`
- `docs/specs/094-universal-state-toggle.md`
- `docs/specs/README.md`
- `docs/ARCHITECTURE.md`
- `docs/STATUS.md`
- `docs/TESTING.md`
- `docs/CHANGELOG.md`
- `docs/CHANGELOG.ru.md`
## 5. Проверка
Локально выполнены только read-only/static проверки ревью:
- `git diff --check`;
- `npm run typecheck`;
- `node --check test/device-toggle.test.mjs`;
- `node --check demo/smoke_controls.mjs`;
- поиск всех consumers `resolveToggleIntent`, `projectedTapAction`,
`toggleCoverEntity`, `sameToggleCommandTargets`;
- сверка backend schema/import-export;
- сверка capability flags с официальным Home Assistant Core.
Unit, browser smoke, backend tests, build и generated bundles **не запускались**
по правилу проекта: локальные правки делаются без тестов, минимальный целевой
прогон выполняется при следующем pre-release.
+10
View File
@@ -16,6 +16,16 @@ writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
is the quickest route to the author and other users. Bugs and concrete feature
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
## Backlog and work status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
only active project backlog. Issues own scope and acceptance criteria; Project
v2 owns prioritization and workflow status. Before starting planned work, link
it to an existing issue or create one, add it to the Project, and keep both
surfaces current until the verified result is closed. Design specs and ADRs may
support an issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
```bash
+72 -28
View File
@@ -15,6 +15,11 @@ room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wa
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
right on your Lovelace dashboard.
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
> on phones and tablets. The editors are designed primarily for a desktop
> browser with a mouse and keyboard; individual editing operations on touch
> devices may be awkward, limited, or unavailable.
![Interactive Home Assistant floor plan: live rooms, devices, lights and climate on a real floorplan card](docs/images/demo.gif)
> ### 🚀 Try it live — no install needed
@@ -39,8 +44,9 @@ right on your Lovelace dashboard.
you drag, so the drawing and the photo of your plan finally line up.
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
whole groups of lights (works for dumb switches and stateless remotes too).
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
of its own color that spills through doorways and open zone boundaries.
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
the floor it can see: through doorways and open boundaries, stopped by walls,
columns and partitions, which cast real shadows.
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
the day (white noon → golden hour → deep night), while windows on exterior
walls cast real wedges of sunlight into the rooms; optional cloud cover
@@ -57,8 +63,10 @@ right on your Lovelace dashboard.
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
drives the plan in real time, pouring its path out from under itself;
current and previous cleanup runs are recorded server-side. Calibration is
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
source picker also covers registry-less map cameras without silently
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
dreame-vacuum and Valetudo.
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
**server-side** — one shared plan for every user and screen, synced live.
@@ -84,16 +92,27 @@ The integration consists of two parts that are installed together:
## How it differs from alternatives
A house plan in Home Assistant is usually built with `picture-elements`, `ha-floorplan` and similar solutions. There you have to write YAML by hand, calculate the coordinates of every icon, and edit the config again after every change. House Plan works differently:
A house plan in Home Assistant is usually built with `picture-elements`,
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
dashboard. Those either lock you into YAML/SVG, or store the plan in the
Lovelace card config. House Plan is a **shared live map** backed by a Home
Assistant integration:
| | House Plan | Typical solutions (picture-elements / ha-floorplan) |
|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML and code editing |
| **Adding devices** | Automatic, by room | You type in every entity by hand |
| **Icon coordinates** | Drag with the mouse | You count pixels and write them into the config |
| **Room markup** | Built-in outline editor | You draw in an external SVG editor |
| **Storage** | On the HA server (shared by all devices) | In the dashboard YAML |
| **Zoom** | Smooth zoom, everything stays crisp (vector) | Usually a fixed image |
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|---|---|---|---|
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
**One sentence:** House Plan is the shared, area-aware live map of your home —
not a general-purpose CAD package. Its Background editor covers practical
decor, labels and furniture; if you need unrestricted architectural drafting,
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
every tablet on the same live layout.
Key advantages in short:
@@ -102,11 +121,17 @@ Key advantages in short:
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
- **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, media is playing, a vacuum is
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
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). 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.
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
furniture, edit physical styles and transform every object with the same
selection model. The plan image has its own move/resize/rotate tool, numeric
properties and shared Undo/Redo.
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
---
@@ -188,7 +213,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
![Empty plan — prompt to add a space](docs/images/02-onboarding-empty.png)
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
![Space creation dialog](docs/images/03-space-dialog.png)
@@ -212,15 +237,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.
@@ -231,7 +258,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.
@@ -251,12 +278,15 @@ Switch to the **Devices** tab to arrange icons: drag them with the mouse, click
### Tap actions: control devices from the plan
By default a tap on an icon opens its info card. In the card settings you can switch
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
specific device (except locks and alarms — those never toggle from the plan) in its
edit dialog. A **long press** always opens the info card.
By default a tap on an icon opens its info card. A device can instead use the
universal **Toggle state** action. Its editor shows the exact entity or configured
group, the current state and what the next tap will do; when nothing can be toggled,
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
Lights keep their convenient toggle default. Covers and valves use open/close/stop
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
remain blocked. An exact entity binding never falls through to a sibling switch, and
temporarily unavailable group members are skipped without erasing the configuration.
A **long press** still opens the info card and right-click still opens HA more-info.
### Icon rules
@@ -272,6 +302,16 @@ You can also place a **single entity** (not just a whole device): start typing i
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
To represent a dumb physical lamp controlled by a smart relay, place a virtual
point where the lamp really is and set **Light source → Always**. Manual colour,
brightness and radius stay available even though the point has no HA entity.
Then open the relay and add that plan source under **Controls other light
sources**. The relay continues to show the aggregate working state, while Glow,
room fill and statistics belong to the lamp's position. An unlinked passive
Always source is deliberately constant-on. With several own `light.*`/`switch.*`
entities, Always also offers a leading-entity selector; a missing saved choice
is warned about and retained while a deterministic fallback is used.
The same dialog controls how the device looks on the plan. **Display** switches between the
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
@@ -280,6 +320,10 @@ turned the way it is mounted.
![Adding a device manually](docs/images/07-marker-dialog.png)
### Styling the plan with card-mod (advanced, unsupported)
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Uninstalling
@@ -313,7 +357,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.
+49 -23
View File
@@ -6,6 +6,8 @@
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
своих местах: живые состояния, свет по клику, температура и влажность по
@@ -13,6 +15,11 @@
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
редакторов — весь план настраивается прямо на дашборде.
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
![Интерактивный план дома для Home Assistant: комнаты, устройства, свет и климат на реальном поэтажном плане](docs/images/demo.gif)
> ### 🚀 Попробовать вживую — без установки
@@ -29,14 +36,15 @@
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
план обратно в экран.
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
линейка в реальных метрах.
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
освещает ровно тот пол, который видит: через проёмы и открытые границы,
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
в комнаты настоящие клинья солнечного света; облачность — опционально, от
@@ -53,8 +61,10 @@
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
выбор источника поддерживают registry-less камеры карт и не подменяют молча
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
(Tasshack) и Valetudo.
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
@@ -81,16 +91,26 @@ House Plan показывает ваш умный дом так, как он в
## Чем отличается от аналогов
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
план** на серверной интеграции Home Assistant:
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода |
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг |
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG |
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда |
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка |
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|---|---|---|---|
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
универсальная CAD-система. Редактор подложки покрывает практический декор,
надписи и мебель; для свободного архитектурного черчения лучше отдельный
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
на всех планшетах.
Ключевые преимущества коротко:
@@ -99,12 +119,14 @@ House Plan показывает ваш умный дом так, как он в
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
Оранжевый = открыто / не заперто. Пульсирующее красное
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
Полупрозрачный значок = недоступно. Тёмный = покой.
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
---
@@ -188,7 +210,7 @@ title: План дома
![Пустой план — предложение добавить пространство](docs/images/02-onboarding-empty.png)
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка сохраняет свои пропорции, а подвинуть и отмасштабировать её можно в любой момент в редакторе подложки.
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
![Диалог создания пространства](docs/images/03-space-dialog.png)
@@ -212,15 +234,15 @@ title: План дома
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
### Двери, окна и замки
### Двери, окна, ворота и замки
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
окно 120 см), датчик открытия и — для двери — **замок**.
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
переключить — клик по проёму показывает карточку с обоими статусами.
@@ -231,7 +253,7 @@ title: План дома
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) приходят с уже установленной галкой **«Скрыть устройство с плана»**. Дальше галка принадлежит вам — она есть в диалоге каждого устройства, включая виртуальные. Чтобы увидеть и вернуть скрытые, откройте редактор устройств и нажмите **«Показать скрытые»**: скрытые отобразятся полупрозрачными синими призраками, клик открывает диалог. Скрытые устройства учитываются в Zigbee-сигнале комнаты, но света не дают.
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
@@ -282,6 +304,10 @@ title: План дома
![Добавление устройства вручную](docs/images/07-marker-dialog.png)
### Свои стили через card-mod (для продвинутых, без поддержки)
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
---
## Удаление
@@ -314,7 +340,7 @@ title: План дома
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
+503
View File
@@ -0,0 +1,503 @@
# Ревью ТЗ `docs/specs/089-isometric-view-stage1.md`
Дата ревью: 2026-08-11
Issue: [#89](https://github.com/Matysh/houseplan-card/issues/89)
Проверенная версия ТЗ: локальный `dev`, после `v1.62.0-beta.1`, SHA `2cf5c27`
## Итог
Направление выбрано правильно: фиксированная SVG-проекция, отсутствие новой
модели данных, каноническая геометрия стен, плоские редакторы, скрытая поставка
через Labs и обязательные golden/performance gates хорошо соответствуют
текущей архитектуре House Plan.
Однако статус **«готово к реализации» пока преждевременен**. В ТЗ остаются
восемь блокирующих неоднозначностей. Главная из них — документ описывает
проекцию точек, но не определяет переход между тремя реально существующими
системами координат и не задаёт новый контракт viewport/frame. Если начать
реализацию буквально по текущему тексту, наиболее вероятный результат —
прыжок масштаба при переключении, рассинхронизация HTML-маркеров и SVG, неверный
warm-remount либо двойное обратное преобразование pointer events.
Рекомендация: внести блокеры B1–B8 и существенные замечания M1–M8 в ТЗ, после
чего документ можно переводить в `approved`. Переписывать продуктовую часть
или менять выбранный renderer не требуется.
## Что уже зафиксировано хорошо
1. Labs не меняет backend, schema/config/layout и не попадает в backup.
2. Плоский вид остаётся default и fallback; редакторы остаются плоскими.
3. CSS 3D, WebGL и многократное клонирование SVG явно запрещены.
4. Источник wall geometry — `wallBodiesGeometry()`, а не новый параллельный
контур.
5. Проёмы должны быть настоящими разрывами masonry geometry.
6. Glow сохраняет один регион на источник и один blur на слой по `LIGHT.md`.
7. Кэш не должен зависеть только от `_cfgEpoch`.
8. У stage 1 нет публичного обещания и пользовательской миграции.
---
## Блокирующие замечания
### B1 — Не определены системы координат, pivot и projected frame
**Где:** §4, §7, §8, AC 8.
**Критичность:** blocker.
`projectPoint(p, z, cam)` и `unprojectPoint(screen, cam)` недостаточны для
существующего renderer. Сейчас House Plan различает как минимум:
- plan/model units (`room`, `wall`, `marker`);
- координаты SVG scene/viewBox (`_view`);
- client pixels внутри `.stage` (`_screenToVb()`).
В объёмном виде plan units и scene units перестают совпадать. Кроме того,
`IsoCamera` не содержит pivot/origin и масштаба оси Z. Проекция вокруг `(0, 0)`
сместит план при смене пространства, а старый `_baseVb()` не включает поднятые
верхние грани и начнёт обрезать стены.
**Что добавить в ТЗ:**
```ts
type PlanPoint = readonly [number, number];
type ScenePoint = readonly [number, number];
interface IsoCamera {
rotDeg: number;
tiltDeg: number;
xyScale: number;
zScale: number;
origin: PlanPoint;
}
projectPlanPoint(p: PlanPoint, zUnits: number, cam: IsoCamera): ScenePoint;
unprojectFloorPoint(p: ScenePoint, cam: IsoCamera): PlanPoint; // только z=0
clientToScenePoint(client: readonly [number, number], stageRect: DOMRectReadOnly,
view: ViewRect): ScenePoint;
projectedFrame(input: IsoFrameInput, cam: IsoCamera): ViewRect;
```
Нормативно определить:
1. `projectPoint` возвращает **scene**, а не screen/client coordinates.
2. `unprojectFloorPoint` инвертирует только плоскость `z=0`; высотная грань не
имеет единственной plan-точки.
3. Pivot — одна фиксированная plan-space константа (рекомендуемо
`[NORM_W / 2, NORM_W / 2]`), а не центр viewport/content frame и не
положение курсора. Иначе появление far marker или переключение `_showFar`
сдвинет уже построенные стены без изменения их геометрии.
4. Wall height сначала переводится из общей константы в plan units, затем
применяется `zScale`; выбранные значения фиксируются ADR.
5. `fit`, pan clamp, home arrow, far-object hint и initial view используют
`projectedFrame`, включающий floor content и поднятые wall faces.
6. Projected frame не зависит от текущего zoom/pan и входит в geometry cache.
Без этого нельзя проверить «не меняет фокус плана скачком» и «не обрезает
объекты».
### B2 — Не задано преобразование viewport при flat ↔ iso и при входе в редактор
**Где:** §7, §10, AC 7–9.
**Критичность:** blocker.
Текущий `_view` хранит прямоугольник именно в координатах текущего SVG. Его
нельзя без преобразования перенести из flat scene в iso scene. Текущий
`_viewModeSnap` также хранит `cx/cy` в flat units. Требование «не менять zoom и
фокус» сейчас не имеет алгоритма.
**Добавить нормативный алгоритм:**
1. Перед сменой проекции получить логический центр пола:
- flat: центр `_view` уже является plan point;
- iso: центр `_view` пропустить через `unprojectFloorPoint`.
2. Построить target frame и target fit.
3. Сохранить тот же scalar zoom.
4. Спроецировать логический центр в target scene и вызвать `_applyView()` с
этим scene center.
5. Не переиспользовать raw `x/y/w/h` между видами.
6. Вход в editor выполняет тот же iso → flat переход; выход — flat → прежний
view kind. Смена пространства внутри editor сбрасывает старый snapshot по
существующему правилу.
Предпочтение вида (`flat|iso`) и viewport — разные сущности. В localStorage
пишется только предпочтение и существующий scalar zoom; raw viewport остаётся
runtime/warm state.
### B3 — ТЗ не совместимо с `docs/WARM-REMOUNT.md`
**Где:** §7 «Непрерывность», §10.
**Критичность:** blocker.
#73 переносит через `warmBoot` точный `_view`, `_viewModeSnap`, mode и
fingerprint кадра. После введения iso один и тот же `ViewRect` имеет два разных
смысла. Если новый экземпляр восстановит iso rectangle в flat mode (например,
флаг снят/истёк) либо наоборот, получится именно тот скачок/пустой кадр, который
#73 устраняет.
**Добавить:**
- warm viewport хранит `projection: 'flat'|'iso'` и `logicalCenter`;
- raw `_view` усыновляется только при совпадении space, projection и активного
Labs contract;
- при несовпадении восстанавливаются scalar zoom + logical center через
алгоритм B2, а не чужой rectangle;
- frame fingerprint включает effective projection и iso geometry fingerprint;
- выключение/expiry Labs никогда не может воскресить iso DOM из memo;
- отдельный smoke: iso → remount → тот же iso frame; iso → снять flag →
remount → корректный flat frame без veil/flash.
### B4 — Правило «все попадания через unproject» технически неверно
**Где:** §7 Pointer, §11 smoke Pointer.
**Критичность:** blocker.
SVG сам hit-тестирует элементы внутри трансформированного `<g>`. Room hover и
SVG opening symbols не нужно вручную unproject-ить: это даст двойное
преобразование. HTML marker также получает click как обычный DOM-элемент.
Кроме того, marker drag выполняется в Device editor, а по этому же ТЗ все
редакторы плоские; smoke «перетаскивание маркера в объёмном виде» противоречит
scope.
**Заменить правило на:**
- SVG/HTML interactive children используют нативный DOM/SVG hit-test;
- pan и zoom anchor работают в scene coordinates;
- `client → scene → unprojectFloor` применяется только там, где stage event
действительно должен получить plan coordinate;
- в stage 1 iso mode не создаёт/редактирует geometry и не перетаскивает
markers, поэтому editor `_svgPoint()` остаётся flat;
- тесты кликают реальные room/device/opening DOM targets и проверяют action;
отдельный unit проверяет `client → scene → floor` для будущего использования.
### B5 — Kiosk UX противоречит фактическому DOM
**Где:** §3, §7, AC 2/4.
**Критичность:** blocker.
ТЗ обещает кнопку «в режиме просмотра (и в киоске) рядом с шапкой». В текущем
kiosk вся `.hdr.kioskhide` имеет `display:none`; такой кнопки физически не
будет. Одновременно §7 говорит, что скрытая панель не должна лишить пользователя
возврата в flat.
Для скрытого stage 1 рекомендуется закрепить простой вариант:
1. Кнопка существует только в обычном View под активным Labs.
2. Kiosk читает последнее per-space предпочтение этого браузера.
3. `hp-labs=-iso` или `hp-labs=off` — обязательный аварийный путь: kiosk сразу
становится flat и не может восстановить iso из warm memo.
4. В kiosk нет новой панели/диалога stage 1.
5. Smoke покрывает загрузку kiosk с сохранённым `iso` и возврат в flat через
URL operation.
Если владельцу нужен переключатель прямо в kiosk, его надо отдельно поместить
в существующий long-press kiosk dialog; «рядом с шапкой» всё равно неверно.
### B6 — Грамматика Labs содержит противоречия и ломает комбинированный hash
**Где:** §2.2–2.4.
**Критичность:** blocker.
Не определено:
- кто сильнее при одновременных `?hp-labs=` и `#hp-labs=`;
- является URL полным replacement или операциями над storage;
- что делает `iso,-iso`, `off,iso`, повторный параметр;
- §2.2 требует не удалять параметр из URL, а §2.3 говорит, что `off` «очищает
и то, и другое»;
- как `#space=x&hp-labs=iso` сохраняет существующий deep link;
- что происходит при `history.back()`/`popstate`.
**Предлагаемый точный контракт:**
1. База — валидный набор из storage.
2. Query operations применяются слева направо, затем hash operations слева
направо; hash сильнее, потому что именно он реактивен внутри Lovelace.
3. `id` добавляет, `-id` удаляет, `off` очищает набор в этой позиции; следующие
токены снова могут добавлять.
4. Повторные `hp-labs` обрабатываются в порядке появления.
5. Если в URL был хотя бы один известный operation или `off`, итог пишется в
storage. Неизвестные значения сами по себе storage не переписывают.
6. URL никогда не переписывается механизмом Labs. Из §2.3 убрать слова об
очистке URL: `off` очищает **effective set и storage**, но остаётся видимым.
7. Hash разбирается общим helper вместе с `space`; оба порядка параметров и
percent-encoding тестируются. `_hashSpace()` не остаётся вторым regex parser.
8. `hashchange` реактивен; `popstate` перечитывает query/hash, если URL реально
сменился без reload.
### B7 — Не определена топология side faces и смысл «нет торцов в проёме»
**Где:** §5, AC 3/5/6.
**Критичность:** blocker.
`wallBodiesGeometry().geom` — MultiPolygon с внешними и внутренними rings, уже
после union, junction patches и opening cuts. «Граничные рёбра» недостаточно:
нужно определить winding, outward normal, holes, culling и порядок отрисовки.
Фраза «без торцов внутри проёма» двусмысленна. При полном разрыве стены
вертикальные jamb faces по краям проёма являются корректной частью объёма;
запретить их — значит получить визуально обрезанную плёнку вместо стены.
**Добавить:**
- faces строятся непосредственно из rings канонического MultiPolygon после
union/cuts; исходные room edges для extrusion не используются;
- winding нормализуется один раз, outward normal учитывает outer/hole ring;
- face видима по знаку dot product normal и фиксированного view direction;
- для фиксированной камеры задаётся детерминированный stable depth order;
- opening slot создаёт две exposed jamb faces по концам разрыва — они нужны;
- запрещены face/полоса, пересекающая сам gap, и cap на floor тоннеля;
- на stage 1 дверь, окно и ворота являются full-height gaps осознанно, так как
модель не хранит высоту подоконника;
- opening никогда не вырезает coincident partition/column — сохраняется
текущий порядок union extras после room opening cuts;
- top face использует whole geometry с `fill-rule:evenodd`;
- unit fixtures включают outer ring, hole, multipolygon, T/X join, opening у
угла и coincident independent body.
### B8 — Fallback может зациклить exception и оставить кнопку во лжи
**Где:** §9, AC 10.
**Критичность:** blocker.
«Вернуться в flat на этом кадре» не отвечает на вопросы: будет ли следующий
Lit render снова падать, что показывает `aria-pressed`, сохраняется ли `iso` в
localStorage и когда разрешён retry.
**Добавить state machine:**
- `desiredView` — сохранённое предпочтение;
- `effectiveView` — реально нарисованный `flat|iso`;
- исключение в pure geometry/iso template ловится на границе
`renderIsoScene()`, для `(space, geometryFingerprint)` ставится session latch;
- при latch effective view = flat, iso geometry больше не вызывается на каждом
HA state update;
- конфиг/layout и сохранённое предпочтение не меняются автоматически;
- кнопка отражает `effectiveView` (`aria-pressed=false`), явное повторное
нажатие или новый geometry fingerprint очищает latch и делает один retry;
- console error содержит issue, space, fingerprint и короткий reason, но без
config/entity payload; один раз на latch;
- ошибка HTML overlay projection также входит в эту границу, иначе получится
«стены flat, markers iso».
---
## Существенные замечания
### M1 — Spike ADR должен фиксировать больше, чем выбор renderer
Сейчас D6 требует ADR, но его обязательные решения не перечислены. ADR должен
закрыть до основной реализации:
- формулу и pivot проекции;
- camera constants, wall-height units и zScale;
- top/side fill, stroke, hatch и side shading в light/dark theme;
- ring normalization, face visibility и depth order;
- z-order floor → Glow/sun/decor → faces/top → screen-facing HTML overlays;
- осознанное правило stage 1: markers/room cards всегда выше wall faces и не
получают геометрическую occlusion;
- projected frame и flat↔iso viewport conversion;
- результат проверки SVG filter/clip/mix-blend на Chromium, Firefox, WebKit;
- причины отказа от проигравшего прототипа.
До ADR issue остаётся в статусе spike/implementation-prep, не renderer-ready.
### M2 — Fingerprint перечисляет не все входы iso geometry
В §8 добавить как минимум:
- `room_drafts` и их segment thickness;
- нормализованные `openCuts`/virtual intervals;
- canonical opening cuts;
- partitions и columns с shape/angle/diameter;
- `cell_cm`, grid pitch, coordinate scale/NORM;
- camera constants и wall-height constant;
- версию алгоритма projection/faces.
Массивы должны сериализоваться детерминированно, числа — нормализоваться как в
существующих geometry fingerprints. Display state (`hover`, HA states,
`show_borders`) не должен инвалидировать geometry cache. `show_borders:false`
просто не рисует cached top/sides, но physics остаётся прежней.
### M3 — `since`/`expires` требуют точной version semantics
В проекте нет зависимости `semver`; строкового сравнения допускать нельзя.
Зафиксировать parser `major.minor.patch[-prerelease]`, fail-closed для
некорректной registry entry и инвариант `since < expires`.
Рекомендуемое продуктовое правило: сравнивать numeric core, поэтому
`1.65.0-beta.1` уже достигает `expires: 1.65.0` и не тащит мёртвый флаг в новый
release cycle. Добавить тесты `1.64.9`, `1.65.0-beta.1`, `1.65.0`, malformed.
### M4 — Не определён runtime owner механизма Labs
Нужно указать, что availability flags глобальны для загруженного JS-модуля, а
effective `flat|iso` остаётся состоянием конкретной карточки/пространства.
Один module-level resolver/subscription не должен создавать по listener на
каждый render.
`window.__hpLabs` должен иметь нормативную форму, например frozen sorted array:
```ts
Object.freeze(['iso'])
```
При изменении URL property заменяется новым frozen array, все подключённые
карточки получают update. Нельзя отдавать внутренний mutable `Set`.
### M5 — Scope `houseplan-space-card` не указан
В репозитории есть второй renderer: `src/space-card.ts` + `src/space-render.ts`.
Текущий текст можно прочитать как требование объёмного вида для обеих карточек.
Рекомендация для stage 1: явно записать, что `houseplan-space-card` остаётся
flat и Labs `iso` на него не влияет. Его поддержка — отдельный будущий scope.
Иначе придётся сразу заводить вторую композицию сцены, что противоречит цели
скрытого первого этапа.
### M6 — Performance contract не совпадает с существующей инфраструктурой
`compare.mjs` использует profile-specific budgets, noise allowance,
relative+absolute thresholds и exact same runner. Просто потребовать «≤20% по
трём полям» недостаточно; `longTask.maxSingleMs` особенно нестабилен около
нуля, а `modelReadyMs` почти не измеряет переключение renderer.
Добавить отдельный профиль `large-house-isometric-v1`:
- текущий benchmark harness запускает candidate bundle с `hp-labs=iso` и
переключает view; тот же harness запускает base bundle, который игнорирует
неизвестный flag и остаётся flat;
- profile id в обоих reports одинаков, runtime/browser/fingerprint проверяются
существующим fail-closed контрактом;
- отдельный reviewed budget JSON задаёт 20% relative allowance **плюс**
абсолютный noise allowance;
- обязательные метрики: first stable iso frame, view toggle, pan/zoom,
HA-state update, space switch, long-task count/total/max, heap growth,
iso-cache entries/growth, rendered devices;
- candidate-only prerelease smoke получает абсолютные ceilings;
- перед завершением этапа выполняется exact-SHA full performance workflow, а
не локальное сравнение с другой машиной.
Фразу «flat не должен подорожать вообще» заменить на проверяемое: при
выключенном флаге iso geometry/cache/DOM отсутствуют и нет дополнительного
прохода по room/device collections; timing находится внутри noise allowance.
### M7 — Golden coverage слишком мала для новой системы координат
Две картинки не покрывают заявленный scope. Минимальная матрица stage 1:
1. desktop dark: mixed walls + openings + Glow/sun + devices;
2. desktop light: theme/shading/filter parity;
3. mobile portrait или узкий kiosk: fit, marker/label alignment, no clipping;
4. `show_borders:false`: стены не нарисованы, room fill/Glow сохраняются;
5. remount/toggle sequence проверяется smoke, а финальный кадр — golden при
необходимости.
Существующие flat baselines действительно не принимаются заново, если diff не
нулевой. Новые baselines принимаются только из полного Linux CI artifact по
действующему HP-QA-01 контракту.
### M8 — A11y toggle contract неполон
Для кнопки добавить:
- `aria-pressed="true|false"` по `effectiveView`;
- стабильный accessible name «Объёмный вид» / `Volumetric view`;
- focus остаётся на той же кнопке после переключения;
- active visual state не кодируется только цветом;
- DOM/tab order устройств и room actions совпадает с flat;
- stage 1 не добавляет projection animation: swap атомарный. Если анимация
будет добавлена через #82, `prefers-reduced-motion` делает её мгновенной.
---
## Замечания к тестам и формулировкам
### T1 — «innerHTML до и после ветки» нужно сделать воспроизводимым
Обычный тест не может сравнить текущий commit с кодом до ветки. Разделить
контракт:
- в одном candidate build сравнить no-param и unknown-param: нет iso nodes,
нет дополнительных WS/HTTP и config/layout writes;
- golden гарантирует нулевой diff существующих flat scenes между revisions;
- unit spy подтверждает, что iso geometry builder не вызывался;
- чтение собственного Labs localStorage не считать сетевым/сторным изменением,
но при отсутствии URL оно не должно переписывать ключ.
### T2 — Мутанты должны быть исполнимыми
Пункт «отдельная формула проекции HTML» нельзя надёжно поймать текстовым
поиском. Нормативный mutant: внести controlled offset только в overlay mapping;
smoke должен увидеть расхождение anchor больше 1 CSS px. Для cache mutant тест
меняет geometry in-place без `_cfgEpoch`; iso faces обязаны обновиться. Для
layer-copy mutant тест проверяет upper bound DOM face count как `O(E)`.
Для каждого из пяти mutants сохранить команду/patch id и имя краснеющего теста
в PR/issue evidence; ручной тезис «проверено» недостаточен.
### T3 — Opening wording
В AC 6 заменить «без швов и торцов внутри проёма» на:
> Проём является full-height gap. Внутри gap нет wall top/side полосы;
> вертикальные jamb faces на двух границах masonry разрыва являются ожидаемыми.
Это снимает конфликт с B7 и делает golden однозначным.
### T4 — Первый запуск и сохранённое предпочтение
В §9/§10 уточнить:
- без записи `houseplan_card_view_v1[space]` effective view всегда flat, даже
при активном Labs;
- toggle в обычном View пишет `flat|iso` per space;
- при неактивном/expired flag сохранённое `iso` игнорируется, не меняет DOM и
не попадает в warm memo;
- вход/выход editor не перезаписывает предпочтение;
- fallback B8 не перезаписывает предпочтение автоматически.
### T5 — Backlog — канонический источник
Issue #89 сейчас говорит «черновик продуктового и технического решения», тогда
как файл говорит «готово к реализации». По `AGENTS.md` Issue/Project являются
каноническими. После принятия новой редакции:
- добавить в body issue ссылку на stage 1 spec как нормативную;
- синхронизировать scope/acceptance criteria issue с утверждённой редакцией;
- оставить Project `Todo` до фактического начала, затем перевести в
`In progress`;
- не закрывать #89 после одного spike ADR: закрытие только после всех AC этапа.
---
## Рекомендуемая новая структура нормативных разделов
Чтобы не раздувать основной текст, достаточно добавить четыре подраздела:
1. **§4.4 Coordinate spaces and viewport** — B1, B2.
2. **§5.1 Wall-face topology and visual tokens** — B7, M1.
3. **§7.1 Native hit testing and warm continuity** — B3, B4, B5.
4. **§2.2.1 Labs operation precedence and version lifecycle** — B6, M3, M4.
Остальные замечания можно встроить в §8–§13.
## Definition of Ready после следующей итерации
ТЗ можно считать готовым к реализации, когда:
- [ ] определены plan/scene/client spaces, camera pivot/zScale и projected frame;
- [ ] записан алгоритм flat↔iso viewport conversion;
- [ ] обновлён warm-remount contract;
- [ ] исправлено pointer rule и убран iso marker-drag smoke;
- [ ] выбран однозначный kiosk escape contract;
- [ ] полностью определена Labs grammar и expiry semantics;
- [ ] определены ring/face/jamb/depth rules;
- [ ] определена fallback state machine;
- [ ] ADR имеет обязательный список решений;
- [ ] fingerprint содержит все входы;
- [ ] указан scope `houseplan-space-card`;
- [ ] создан исполнимый performance profile/budget plan;
- [ ] расширена golden/a11y/mutant matrix;
- [ ] issue #89 ссылается на утверждённое ТЗ и не противоречит ему.
После этого оценка stage 1 остаётся **L/XL с высоким риском**, но работа станет
декомпозируемой и проверяемой; менять выбранную продуктовую концепцию не нужно.
+87 -5
View File
@@ -1,6 +1,7 @@
"""House Plan: server-side house plan configuration + Lovelace card serving."""
from __future__ import annotations
import inspect
import logging
from datetime import timedelta
from pathlib import Path
@@ -23,7 +24,7 @@ from .const import (
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
_LOGGER = logging.getLogger(__name__)
@@ -32,16 +33,27 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanContentView, HouseplanUploadView
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
hass.http.register_view(HouseplanImportPreviewView())
return True
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
data = create_data(hass)
# Home Assistant's installation id never leaves the instance. Exports
# carry only a salted SHA-256 fingerprint so same-instance internal files
# can be distinguished from cross-instance references.
try:
from homeassistant.helpers import instance_id as ha_instance_id
value = ha_instance_id.async_get(hass)
data.instance_id = str(await value if inspect.isawaitable(value) else value)
except Exception: # noqa: BLE001 - old HA/test harness fallback
data.instance_id = str(entry.entry_id)
# test-before-setup: storage must be readable, otherwise retry later
try:
await data.store.async_load()
@@ -133,15 +145,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await data.store.async_save(
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
await async_save_layout_state(
data, lay_stored, layout, lay_rev,
metadata={"geom_pending": merged}, remove=("geom_pending",),
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await data.config_store.async_save({"config": cfg, "rev": rev})
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
await async_save_layout_state(
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
)
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
@@ -149,6 +164,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
# Finish an explicit whole-plan optimization/undo interrupted between the
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
recovered_import = False
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
pending = lay_stored.get("optimize_pending")
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
and isinstance(pending.get("layout"), dict):
target_config = pending["config"]
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
target_config_rev = int(pending.get(
"config_rev", config_rev + (stored.get("config") != target_config)
))
target_layout_rev = int(pending.get(
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
config_rev = max(config_rev, target_config_rev)
await data.config_store.async_save({
"config": target_config,
"rev": config_rev,
})
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
replace_metadata = isinstance(exact_metadata, dict)
metadata = dict(exact_metadata) if replace_metadata else None
if not replace_metadata and not pending.get("clear_backup") \
and "optimize_backup" in lay_stored:
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
remove_metadata = ["optimize_pending", "optimize_backup"]
if pending.get("clear_backup"):
# A recovered whole-plan undo replaces the complete layout;
# a point-wise repair snapshot from the replaced layout must
# not survive and later restore coordinates into the new pair.
remove_metadata.append("repair_backup")
await async_save_layout_state(
data,
lay_stored,
target_layout,
layout_rev,
metadata=metadata,
remove=tuple(remove_metadata),
replace_metadata=replace_metadata,
)
optimize_revs = (config_rev, layout_rev)
recovered_import = str(pending.get("kind") or "").startswith("import")
_LOGGER.warning(
"House Plan: completed an interrupted %s",
str(pending.get("kind") or "plan optimization").replace("_", " "),
)
if optimize_revs is not None:
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
if recovered_import:
await recorder.async_refresh()
current = (await data.config_store.async_load() or {}).get("config") or {}
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
for marker_id in list(recorder.book.data):
if marker_id not in live_ids:
await recorder.async_delete(marker_id)
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
+4 -1
View File
@@ -24,5 +24,8 @@ def may_write(hass: HomeAssistant, user) -> bool:
entry = get_entry(hass)
if entry is None:
return is_admin
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
# Default TRUE when the key is absent (audit P0-4, 2026-08-05): the card
# UI has always been admin-gated, and an unset option must not open every
# write WS/HTTP path to every authenticated household user.
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
return is_admin if admin_only else True
+3 -2
View File
@@ -18,7 +18,7 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="House Plan", data={}, options=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=False): bool}),
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
)
@staticmethod
@@ -32,7 +32,8 @@ class HouseplanOptionsFlow(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current = self.config_entry.options.get(CONF_ADMIN_ONLY, False)
# Match auth.may_write: missing key ⇒ admin-only (audit P0-4).
current = self.config_entry.options.get(CONF_ADMIN_ONLY, True)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=current): bool}),
+15 -1
View File
@@ -45,7 +45,21 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.58.0"
VERSION = "1.62.0-beta.5"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 6
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
MAX_IMPORT_PREVIEWS_PER_USER = 3
# Parsed documents are larger than their wire representation. Keep the
# original three-preview memory ceiling global as well as per user so turning
# off the admin-only policy cannot multiply it by the number of household
# accounts.
MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict = {
"spaces": [],
@@ -31,6 +31,9 @@ async def async_get_config_entry_diagnostics(
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"room_drafts": len(s.get("room_drafts", [])),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
}
for s in config.get("spaces", [])
],
File diff suppressed because one or more lines are too long
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for draft in space.get("room_drafts") or []:
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
for part in space.get("partitions") or []:
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
for column in space.get("wall_columns") or []:
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
+65 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
import tempfile
from functools import partial
from pathlib import Path
from aiohttp import web
@@ -22,10 +23,13 @@ from homeassistant.core import HomeAssistant
from .const import (
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, PLANS_DIR,
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
)
from .auth import may_write
from .import_export import ImportFailure, create_preview
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .registry_snapshot import import_registry_snapshot
from .store import get_data
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
@@ -52,6 +56,66 @@ _MIME = {
}
class HouseplanImportPreviewView(HomeAssistantView):
"""Upload a bounded JSON backup and return a server-side preview token."""
url = "/api/houseplan/import/preview"
name = "api:houseplan:import-preview"
requires_auth = True
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
user = request.get("hass_user")
if not may_write(hass, user):
return web.json_response({"error": "unauthorized"}, status=403)
runtime = get_data(hass)
if runtime is None:
return web.json_response({"error": "not_ready"}, status=503)
policy = request.query.get("duplicate_policy", "skip")
if policy not in ("skip", "virtual"):
return web.json_response({"error": "invalid_format"}, status=400)
declared = request.content_length
if declared is not None and declared > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks: list[bytes] = []
size = 0
async for block in request.content.iter_chunked(_CHUNK):
size += len(block)
if size > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks.append(block)
owner_id = str(getattr(user, "id", ""))
try:
async with runtime.write_lock:
config_data = await runtime.config_store.async_load() or {}
layout_data = await runtime.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block a valid backup
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
create_preview,
runtime,
b"".join(blocks),
owner_id=owner_id,
duplicate_policy=policy,
current_config_data=config_data,
current_layout_data=layout_data,
config_root=Path(hass.config.path("")),
registry_snapshot=registry_snapshot,
)
)
except ImportFailure as err:
status = 413 if err.code == "too_large" else 400
return web.json_response({"error": err.code, "message": err.message}, status=status)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import preview failed")
return web.json_response({"error": "invalid_format"}, status=400)
return web.json_response(result)
class HouseplanContentView(HomeAssistantView):
"""Authenticated read access to plans and marker files (audit B1).
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.58.0"
"version": "1.62.0-beta.5"
}
@@ -20,7 +20,7 @@ rules:
status: done
config-flow-test-coverage:
status: done
comment: tests_backend/test_config_flow.py (runs in CI on Python 3.13).
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
dependency-transparency:
status: done
comment: No external requirements.
@@ -0,0 +1,47 @@
"""Small registry projection shared by import HTTP and WebSocket previews."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
def import_registry_snapshot(hass: HomeAssistant) -> dict[str, set[str]]:
"""Return non-sensitive target inventory used only for preview counts."""
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
entities = list(er.async_get(hass).entities.values())
active_entity: set[str] = set()
disabled_entity: set[str] = set()
entities_by_device: dict[str, list[Any]] = {}
for entry in entities:
entity_id = str(entry.entity_id)
if getattr(entry, "disabled_by", None) is None:
active_entity.add(entity_id)
else:
disabled_entity.add(entity_id)
if entry.device_id:
entities_by_device.setdefault(str(entry.device_id), []).append(entry)
# Synthetic/runtime entities may legitimately have no registry row.
active_entity.update(str(state.entity_id) for state in hass.states.async_all())
active_device: set[str] = set()
disabled_device: set[str] = set()
for entry in dr.async_get(hass).devices.values():
device_id = str(entry.id)
children = entities_by_device.get(device_id, [])
disabled = getattr(entry, "disabled_by", None) is not None or (
bool(children)
and all(getattr(child, "disabled_by", None) is not None for child in children)
)
(disabled_device if disabled else active_device).add(device_id)
return {
"active_device": active_device,
"disabled_device": disabled_device,
"active_entity": active_entity,
"disabled_entity": disabled_entity - active_entity,
"areas": {str(entry.id) for entry in ar.async_get(hass).areas.values()},
}
+59
View File
@@ -54,6 +54,11 @@ class HouseplanData:
# directly — a test that fakes a 24 h jump proves the timer fires, not that
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
# Stable HA instance id used only through a one-way export fingerprint.
instance_id: str = ""
# Parsed import candidates are short-lived, user-bound and memory-only.
# dict keeps insertion order, which lets the preview service evict oldest.
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
HouseplanConfigEntry = ConfigEntry[HouseplanData]
@@ -79,3 +84,57 @@ def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""The loaded config entry, or None."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return entries[0] if entries else None
OPTIMIZE_BACKUP = "optimize_backup"
OPTIMIZE_PENDING = "optimize_pending"
def layout_store_payload(
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Build one layout-store write without silently dropping metadata.
Layout used to be saved by several independent dict comprehensions. Every
new metadata key therefore had to be added to every caller or was lost on
the next drag. All writers now express only the metadata they intentionally
add/remove and this helper preserves the rest.
"""
excluded = {"layout", "rev", *remove}
out = {} if replace_metadata else {
key: value for key, value in stored.items() if key not in excluded
}
if metadata:
out.update(metadata)
out["layout"] = layout
out["rev"] = rev
return out
async def async_save_layout_state(
runtime: HouseplanData,
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Persist layout and return the exact store document written."""
payload = layout_store_payload(
stored,
layout,
rev,
metadata=metadata,
remove=remove,
replace_metadata=replace_metadata,
)
await runtime.store.async_save(payload)
return payload
@@ -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", {})),
}
+102 -11
View File
@@ -90,6 +90,10 @@ class TrailBook:
return True
return False
def delete(self, marker: str) -> bool:
"""Forget every stored run of one plan marker."""
return self.data.pop(marker, None) is not None
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
@@ -106,6 +110,9 @@ class TrailRecorder:
self._unsub_track = None
self._unsub_save = None
self._last_fire = 0.0
# One active incident per saved marker/source. `reason` is mutable so
# missing↔disabled changes do not create warning storms.
self._source_health: dict[tuple[str, str], str] = {}
# HP-1540-05: config/set fires refresh as a detached task; two of them
# interleaving across the awaited load both subscribed and the loser's
# unsub handle was overwritten — a leak until HA restart
@@ -130,27 +137,24 @@ class TrailRecorder:
return
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
health_pairs: set[tuple[str, str]] = set()
for m in cfg.get("markers") or []:
if m.get("removed") is True:
continue
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
marker_id = str(m.get("id"))
health_pairs.add((marker_id, str(src)))
vac = self._vacuum_entity(m)
if vac:
# HP-1540-03: append, never overwrite — every floor's
# marker records its own copy of the run
pairs.setdefault(src, []).append((str(m.get("id")), vac))
pairs.setdefault(src, []).append((marker_id, vac))
self._refresh_source_health(health_pairs)
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
@@ -158,6 +162,93 @@ class TrailRecorder:
for src in self.pairs:
self._sample(src, time.time())
def _source_failure_reason(self, source: str) -> str | None:
"""Classify only refresh-time health evidence.
A registry row or exact live state proves existence. No registry access
is neutral: it can neither create a loss incident nor recover one.
"""
registry = er.async_get(self.hass)
state = self.hass.states.get(source)
if registry is None or not hasattr(registry, "async_get"):
return None if state is not None else "unverified"
entry = registry.async_get(source)
if entry is not None and getattr(entry, "disabled_by", None) is not None:
return "disabled"
# Registry-less YAML entities are valid: exact live state is stronger
# evidence than a missing registry row.
if entry is not None or state is not None:
return None
return "missing"
def _refresh_source_health(self, expected: set[tuple[str, str]]) -> None:
"""Refresh deduplicated source incidents during config refresh/restart.
`unavailable` and unsupported-but-existing states count as proven
recovery. There is intentionally no registry subscription in Stage 1;
the next config refresh or restart observes a later transition.
"""
for key in list(self._source_health):
if key not in expected:
del self._source_health[key]
for marker_id, source in sorted(expected):
key = (marker_id, source)
reason = self._source_failure_reason(source)
previous = self._source_health.get(key)
# Limited/unavailable registry evidence is neutral: keep an
# existing incident as-is, and never create or recover one.
if reason == "unverified":
continue
if reason is None:
if previous is not None:
_LOGGER.info(
"Vacuum source recovered: marker=%s source=%s (was %s)",
marker_id, source, previous,
)
del self._source_health[key]
continue
if previous is None:
_LOGGER.warning(
"Vacuum source %s: marker=%s source=%s",
reason, marker_id, source,
)
self._source_health[key] = reason
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]
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
await self.store.async_save(self.book.data)
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
+552 -25
View File
@@ -4,6 +4,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
"""
from __future__ import annotations
from collections import Counter
import re
import voluptuous as vol
@@ -19,6 +20,195 @@ _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# The name length the content view will accept back in a request. Anything a
# generated name must fit inside, collision tag included (HP-1460-01).
MAX_FILENAME = 120
MARKER_CONTROL_PREFIX = "marker:"
_CONTROL_ENTITY_ID_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+\Z")
class MarkerControlError(ValueError):
"""Semantic marker-link error with a stable public code."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
VALUE_BADGE_ATTRIBUTES = {
"current_temperature", "temperature", "current_humidity", "humidity",
"current_position", "percentage", "brightness", "volume_level",
"battery_level", "fan_speed",
}
VALUE_BADGE_POSITIONS = {"right", "bottom", "left", "top"}
VALUE_BADGE_SOURCE_KINDS = {
"entity_state", "entity_attribute", "derived_lqi", "derived_marker_state",
}
def _matching_previous_marker(
marker: dict, marker_id: str, old_by_id: dict[str, dict], old_markers: list[dict],
new_ids: set[str], consumed_old_ids: set[str], validate_all: bool,
) -> dict | None:
"""Match the previous marker across the binding-stable id rename path."""
old_marker = old_by_id.get(marker_id)
if old_marker is not None:
consumed_old_ids.add(marker_id)
return old_marker
if validate_all:
return None
binding = marker.get("binding")
matches = [
old for old in old_markers
if binding not in (None, "virtual")
and old.get("binding") == binding
and str(old.get("id")) not in new_ids
and str(old.get("id")) not in consumed_old_ids
]
if len(matches) == 1:
consumed_old_ids.add(str(matches[0].get("id")))
return matches[0]
return None
def validate_marker_value_badges(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if not validate_all and badge == old_badge:
continue
if badge is None:
continue
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
if source is None:
continue
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
kind = source["kind"]
allowed_fields = {
"entity_state": {"kind", "entity_id"},
"entity_attribute": {"kind", "entity_id", "attribute"},
"derived_lqi": {"kind"},
"derived_marker_state": {"kind", "ref"},
}[kind]
if (known_source_fields & set(source)) - allowed_fields:
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
if kind in {"entity_state", "entity_attribute"}:
entity_id = source.get("entity_id")
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
if kind == "entity_attribute":
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
if kind == "derived_marker_state":
ref = source.get("ref")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
if target is None or target.get("removed") is True:
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
if target.get("is_light") is not True:
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
def validate_marker_controls(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate newly introduced marker:* edges without rewriting old data.
Existing broken refs remain editable and round-trip losslessly. Imports use
validate_all because their complete candidate graph is new to this plan.
"""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
old_by_id = {
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
}
new_ids = set(by_id)
consumed_old_ids: set[str] = set()
graph: dict[str, list[str]] = {}
added: list[tuple[str, str]] = []
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
raw_controls = [
ref for ref in marker.get("controls") or [] if isinstance(ref, str)
]
old_controls = [] if validate_all else [
ref for ref in (old_marker or {}).get("controls") or [] if isinstance(ref, str)
]
refs = [
ref for ref in raw_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
graph[marker_id] = [ref[len(MARKER_CONTROL_PREFIX):] for ref in refs]
old_refs = [
ref for ref in old_controls
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
]
remaining = list(old_refs)
for ref in refs:
if ref in remaining:
remaining.remove(ref)
else:
added.append((marker_id, ref[len(MARKER_CONTROL_PREFIX):]))
new_counts, old_counts = Counter(refs), Counter(old_refs)
if any(count > 1 and count > old_counts[ref] for ref, count in new_counts.items()):
raise MarkerControlError("duplicate_marker_control", "Duplicate marker light target")
remaining_controls = list(old_controls)
for ref in raw_controls:
if ref in remaining_controls:
remaining_controls.remove(ref)
elif not ref.startswith(MARKER_CONTROL_PREFIX) and not _CONTROL_ENTITY_ID_RE.fullmatch(ref):
raise MarkerControlError("invalid_marker_control", f"Invalid entity target: {ref}")
def reaches(start: str, wanted: str) -> bool:
stack, seen = [start], set()
while stack:
node = stack.pop()
if node == wanted:
return True
if node in seen:
continue
seen.add(node)
stack.extend(graph.get(node, []))
return False
for controller, target in added:
if not target:
raise MarkerControlError("invalid_marker_control", "Marker target id is empty")
if target == controller:
raise MarkerControlError("marker_control_self", "A marker cannot control itself")
target_marker = by_id.get(target)
if target_marker is None or target_marker.get("removed") is True:
raise MarkerControlError("marker_control_missing", f"Marker target does not exist: {target}")
if target_marker.get("is_light") is not True:
raise MarkerControlError("marker_control_not_light", f"Marker target is not a forced light: {target}")
if reaches(target, controller):
raise MarkerControlError("marker_control_cycle", "Marker light controls contain a cycle")
# ---------- sanitizers ----------
@@ -62,12 +252,34 @@ def _finite(value):
return f
# Persisted colours deliberately use one small, browser-independent format.
# Keep this exact contract in sync with src/color.ts.
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
# match the whole string exactly, in parity with the frontend validator.
_COLOR = vol.Match(r"\A#[0-9a-fA-F]{6}\Z")
_CUSTOM_FILL = vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
}
)
# generous caps: the product targets 20-200 devices and a handful of floors
MAX_SPACES = 50
MAX_ROOMS = 400
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).
MAX_OPEN_SPANS = 500
MAX_LAYOUT = 5000
# Inner limits (HP-1454-05). The outer collections were capped, the collections
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
@@ -90,6 +302,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)
@@ -142,6 +356,45 @@ def _view_box(value):
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
# A virtual stretch shorter than this is a click, not a span. Mirrors
# OPEN_SPAN_MIN_UNITS in src/open-spans.ts (normalised units).
OPEN_SPAN_MIN_LEN = 1e-3
def _open_span(value):
"""One virtual stretch: exactly two distinct finite points a/b.
AUD-159B6-03: the field used to ride on `extra=ALLOW_EXTRA` — any shape
passed the backend and the card crashed reading `e.a[0]` on render, for
every reader of that space. A degenerate span (a == b) is not a stretch
either: it can never be hit, closed or drawn, so it is corruption, not data.
"""
if not isinstance(value, dict):
raise vol.Invalid("open_span must be an object with a/b points")
a = POINT(value.get("a"))
b = POINT(value.get("b"))
if abs(a[0] - b[0]) < OPEN_SPAN_MIN_LEN and abs(a[1] - b[1]) < OPEN_SPAN_MIN_LEN:
raise vol.Invalid("open_span: a and b must differ")
out = {k: v for k, v in value.items() if k not in ("a", "b")}
out["a"] = a
out["b"] = b
return out
def _dedupe_open_spans(value):
"""Drop repeats of the same stretch (either direction) — one wall, one span."""
seen = set()
out = []
for span in value:
a, b = span["a"], span["b"]
fwd = (round(a[0], 6), round(a[1], 6), round(b[0], 6), round(b[1], 6))
key = min(fwd, (fwd[2], fwd[3], fwd[0], fwd[1]))
if key in seen:
continue
seen.add(key)
out.append(span)
return out
def _require_geometry(room: dict) -> dict:
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
@@ -160,7 +413,9 @@ ROOM_SCHEMA = vol.All(
None,
vol.Schema(
{
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp", "custom", "glow"])),
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
vol.Optional("glow"): vol.Any(bool, None),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
@@ -199,14 +454,19 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
{
vol.Optional("show_borders"): bool,
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_color"): _COLOR,
# per-space background around the plan; absent = inherit the global one
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("bg_color"): _COLOR,
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "custom", "glow"]),
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
vol.Optional("glow_enabled"): bool,
vol.Optional("temp_min"): vol.Coerce(float),
vol.Optional("temp_max"): vol.Coerce(float),
vol.Optional("show_lqi"): bool,
# "draw less" switches; absent = False = everything is drawn as before
vol.Optional("hide_decor"): bool,
vol.Optional("hide_openings"): bool,
vol.Optional("label_temp"): bool,
vol.Optional("label_hum"): bool,
vol.Optional("label_lqi"): bool,
@@ -220,9 +480,43 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
# underscores in both halves. The bound is a sanity limit, not a policy.
MAX_ENTITY_ID = 255
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
# A caption is a caption: the inline-reference template is bounded. Attribute
# and unit bounds below apply only to legacy beta.9 link fields, which remain
# accepted so an older saved plan can reach the frontend and migrate on edit.
MAX_DECOR_TEXT = 200
MAX_DECOR_ATTR = 64
MAX_DECOR_UNIT = 16
# The text block is scaled by dragging its corners; the range is what a human
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
DECOR_TEXT_CM_MAX = 2000.0
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
# the backend must accept a plan written by a NEWER card, and a card that has
# learnt a new symbol must not have to wait for the integration to be updated
# before the user can save. What is enforced is the shape of the id — a flat
# lowercase name — and its length; an id this backend has never heard of simply
# renders as nothing in an older card.
MAX_FURN_SYMBOL = 32
_FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
vol.Match(r"^[a-z0-9_]+$"))
# …and its size: strictly positive, capped by the same canvas insurance limit
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("color"): _COLOR,
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
# Physical centimetres are canonical. `width` remains accepted so plans
# written by older cards keep their exact appearance until edited.
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
}
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
@@ -231,24 +525,181 @@ _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,
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
vol.Optional("fill"): bool},
# sizes are extents — negative/zero is garbage, not "canvas slack"
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): _COLOR,
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
# the template: newlines are the user's own line breaks and are
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
# — the block is scaled by its corner handles — but a plan
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# Canonical physical font size plus the legacy scale. Both are
# accepted so older plans remain pixel-identical until edited
# or explicitly optimized.
vol.Optional("size_cm"): vol.All(
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
# Legacy one-value link (beta.9 and earlier). New labels store
# every `{entity[:attribute]}` reference directly in `text`;
# these stay accepted solely for backward compatibility.
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
extra=vol.ALLOW_EXTRA),
# A piece of furniture (docs/FURNITURE.md): a symbol id, a normalised box
# and an optional rotation. It is a NEW kind, so no existing plan carries
# it, nothing is migrated, and an integration that has this branch reads
# every older config byte-for-byte as before.
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "furniture",
vol.Required("symbol"): _FURN_SYMBOL,
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
extra=vol.ALLOW_EXTRA),
)
SPACE_SCHEMA = vol.Schema(
def _wall_endpoints_pair(entry: dict) -> dict:
"""Exact wall endpoints are useful only as a complete a/b pair."""
if ("a" in entry) != ("b" in entry):
raise vol.Invalid("wall exact endpoints require both a and b")
return entry
WALL_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
# New writes retain exact normalized interval endpoints. The old
# key remains the compatibility lookup; endpoints preserve a
# differing-thickness breakpoint after a virtual span is closed.
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
},
extra=vol.ALLOW_EXTRA,
),
_wall_endpoints_pair,
)
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"): str,
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
@@ -260,10 +711,10 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
# scaled UNIFORMLY on the canvas. All three are optional and their
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
# and an old config validates unchanged. The offset is a normalised
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
# resized per axis and rotated. Every transform field is optional; its
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
# config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
@@ -271,6 +722,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
# New writes may stretch each axis independently and rotate. The old
# uniform field remains a read-compatible fallback for both axes.
vol.Optional("plan_scale_x"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_scale_y"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Optional("plan_angle"): vol.Any(
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
@@ -278,7 +740,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)),
@@ -294,6 +756,26 @@ SPACE_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
], vol.Length(max=MAX_OPENINGS)),
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
# (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
# keeps working on its own (AUD-159B6-03).
vol.Optional("open_spans"): vol.All(
vol.Length(max=MAX_OPEN_SPANS), [_open_span], _dedupe_open_spans,
),
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
# independent existence. Still accepted so a stale browser tab cannot fail a save;
# the card strips the field on every write.
@@ -303,15 +785,22 @@ SPACE_SCHEMA = vol.Schema(
vol.Remove("segments"): object,
},
extra=vol.ALLOW_EXTRA,
)
), _space_geometry_invariants)
MARKER_SCHEMA = vol.Schema(
{
vol.Required("id"): str,
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
vol.Required("binding"): str,
vol.Required("binding"): vol.All(
str,
vol.Length(min=1, max=MAX_TEXT),
vol.Match(r"^(device:.+|entity:.+|virtual)$"),
),
vol.Optional("space"): vol.Any(str, None),
vol.Optional("area"): vol.Any(str, None),
vol.Optional("hidden"): bool,
# A binding-level tombstone: not rendered or aggregated, but retained
# so automatic discovery does not put a deleted device straight back.
vol.Optional("removed"): bool,
vol.Optional("name"): _TEXT_OR_NONE,
vol.Optional("icon"): _TEXT_OR_NONE,
vol.Optional("model"): _TEXT_OR_NONE,
@@ -344,14 +833,49 @@ MARKER_SCHEMA = vol.Schema(
),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("glow_color"): vol.Any(
None,
vol.Schema(
{
vol.Required("c"): _COLOR,
vol.Optional("bri"): vol.Any(
None,
vol.All(_finite, vol.Range(min=0.01, max=1.0)),
),
}
),
),
vol.Optional("is_light"): vol.Any(bool, None),
# Explicit leading entity for composite Always sources. It is kept
# literally when temporarily absent; runtime falls back without
# deleting the user's choice.
vol.Optional("light_entity"): vol.Any(
None,
vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(?:light|switch)\.[a-z0-9_]+\Z")),
),
vol.Optional("value_badge"): vol.Any(
None,
vol.Schema(
{
# Required semantically for changed/new data. Optional here
# keeps old/future configs readable until the user edits it.
vol.Optional("enabled"): object,
vol.Optional("position"): object,
vol.Optional("source"): vol.Any(
None,
vol.Schema({}, extra=vol.ALLOW_EXTRA),
),
},
extra=vol.ALLOW_EXTRA,
),
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
# asserts every option the editor offers is accepted here (issue #3)
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
vol.Optional("ripple_color"): 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", "static_icon", None),
vol.Optional("ripple_color"): vol.Any(None, _COLOR),
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
@@ -370,11 +894,14 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("bg_color"): _COLOR,
# sun on the plan (docs/SUN.md): global defaults
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)),
@@ -382,7 +909,7 @@ CONFIG_SCHEMA = vol.Schema(
{
str: vol.Schema(
{
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Required("c"): _COLOR,
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
)
+728 -16
View File
@@ -7,6 +7,9 @@ import base64
import binascii
import json
import secrets
import time
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from typing import Any
@@ -23,37 +26,115 @@ from .const import (
PLANS_DIR, PLANS_URL,
)
from .auth import may_write
from .import_export import (
ImportFailure,
create_export,
get_candidate,
live_layout,
prepare_apply,
revalidate_candidate,
)
from .plans import (
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
)
from .store import HouseplanData, get_data, get_entry
from .store import (
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_layout_state,
get_data,
get_entry,
)
from .registry_snapshot import import_registry_snapshot
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
validate_marker_controls, validate_marker_value_badges, valid_space_id,
)
_LOGGER = logging.getLogger(__name__)
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
"""An optimization can be undone before any later ordinary plan edit."""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return False
try:
return (
int(backup.get("after_config_rev", -1)) == int(config_data.get("rev", 0))
and int(backup.get("after_layout_rev", -1)) == int(layout_data.get("rev", 0))
)
except (TypeError, ValueError):
return False
def _optimizer_backup_after_layout_maintenance(
layout_data: dict[str, Any], new_layout_rev: int,
) -> dict[str, Any]:
"""Carry a one-deep plan snapshot across explicit layout maintenance.
Geometry repair is part of plan maintenance, not an ordinary user edit:
losing the Optimize/Import undo there makes the advertised safety net
disappear. The snapshot must also follow the new layout revision or the
freshness guard will correctly, but unhelpfully, classify it as stale.
"""
backup = layout_data.get(_OPTIMIZE_BACKUP)
if not isinstance(backup, dict):
return {}
return {
_OPTIMIZE_BACKUP: {
**backup,
"after_layout_rev": new_layout_rev,
}
}
def _undo_kind(config_data: dict[str, Any], layout_data: dict[str, Any]) -> str | None:
if not _optimizer_backup_is_current(config_data, layout_data):
return None
backup = layout_data.get(_OPTIMIZE_BACKUP)
return str(backup.get("kind") or "optimize") if isinstance(backup, dict) else None
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
"""Free a snapshot made stale by a later ordinary config edit."""
data = await rt.store.async_load() or {}
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
return
await async_save_layout_state(
rt,
data,
data.get("layout") or {},
int(data.get("rev", 0)),
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
@callback
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
websocket_api.async_register_command(hass, ws_trail_delete)
websocket_api.async_register_command(hass, ws_layout_set)
websocket_api.async_register_command(hass, ws_geometry_repair)
websocket_api.async_register_command(hass, ws_layout_update)
websocket_api.async_register_command(hass, ws_layout_delete)
websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_optimize)
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plans_list)
websocket_api.async_register_command(hass, ws_plans_delete)
websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign)
websocket_api.async_register_command(hass, ws_export_create)
websocket_api.async_register_command(hass, ws_import_revalidate)
websocket_api.async_register_command(hass, ws_import_apply)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -74,9 +155,340 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
return may_write(hass, getattr(connection, "user", None))
def _connection_user_id(connection) -> str:
return str(getattr(getattr(connection, "user", None), "id", ""))
def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
connection.send_error(msg_id, err.code, err.message)
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
"""Return the exact non-layout portion of a layout-store document."""
return {
key: value for key, value in stored.items()
if key not in {"layout", "rev"}
}
async def _persist_pair_intent(
rt: HouseplanData,
pending: dict[str, Any],
) -> None:
"""Make a target pair recoverable before either visible half moves."""
stored = await rt.store.async_load() or {}
metadata = dict(pending.get("final_metadata") or {})
metadata[_OPTIMIZE_PENDING] = pending
await async_save_layout_state(
rt,
stored,
stored.get("layout") or {},
int(stored.get("rev", 0)),
metadata=metadata,
replace_metadata=True,
)
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
"""Write both target halves and remove the durable intent last."""
await rt.config_store.async_save({
"config": pending["config"],
"rev": int(pending["config_rev"]),
})
stored = await rt.store.async_load() or {}
await async_save_layout_state(
rt,
stored,
pending["layout"],
int(pending["layout_rev"]),
metadata=dict(pending.get("final_metadata") or {}),
replace_metadata=True,
)
async def _commit_import_pair(
rt: HouseplanData,
pending: dict[str, Any],
rollback: dict[str, Any],
) -> None:
"""Commit, retry once, or restore the before-pair before returning.
A Store write may raise after the bytes reached disk, so recovery always
reloads and converges an explicit pair instead of guessing which half won.
If the target cannot be completed, a rollback intent replaces it before we
restore the old pair; setup will therefore restore, never unexpectedly
finish an import that was reported as failed.
"""
try:
await _persist_pair_intent(rt, pending)
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - one retry handles fail-after-write too
_LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True)
try:
await _persist_pair_intent(rt, pending)
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - target is no longer the recovery policy
_LOGGER.exception("House Plan import target retry failed; restoring previous pair")
try:
await _persist_pair_intent(rt, rollback)
await _converge_pair(rt, rollback)
except Exception as rollback_error: # noqa: BLE001
_LOGGER.exception(
"House Plan import rollback could not finish; rollback intent remains for setup"
)
raise ImportFailure(
"commit_failed", "Import failed; the previous plan is pending recovery"
) from rollback_error
raise ImportFailure("commit_failed", "Import failed and the previous plan was restored")
# ---------------- portable backup / transfer ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/export/create",
vol.Required("kind"): vol.In(["full", "space"]),
vol.Optional("space_id"): str,
vol.Optional("card_version", default=""): str,
}
)
@websocket_api.async_response
async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Build a consistent full or one-space JSON snapshot."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may export House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
try:
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
document, filename = await hass.async_add_executor_job(
partial(
create_export,
rt,
config_data,
layout_data,
kind=msg["kind"],
space_id=msg.get("space_id"),
card_version=msg.get("card_version", ""),
config_root=Path(hass.config.path("")),
)
)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan export failed")
connection.send_error(msg["id"], "invalid_config", "Could not create export")
return
connection.send_result(msg["id"], {"document": document, "filename": filename})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/import/revalidate",
vol.Required("token"): str,
vol.Optional("duplicate_policy", default="skip"): vol.In(["skip", "virtual"]),
}
)
@websocket_api.async_response
async def ws_import_revalidate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Re-evaluate a space candidate after its duplicate policy changes."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
try:
async with rt.write_lock:
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block revalidation
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
revalidate_candidate,
candidate,
config_data,
layout_data,
duplicate_policy=msg["duplicate_policy"],
registry_snapshot=registry_snapshot,
config_root=Path(hass.config.path("")),
)
)
result["token"] = msg["token"]
result["expires_at"] = datetime.fromtimestamp(
candidate["expires"], UTC
).isoformat().replace("+00:00", "Z")
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/import/apply",
vol.Required("token"): str,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
vol.Optional("duplicate_policy"): vol.In(["skip", "virtual"]),
vol.Optional("confirm_missing_content", default=False): bool,
}
)
@websocket_api.async_response
async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Commit exactly the previewed candidate as one crash-resumable pair."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
kind = ""
details: dict[str, Any] = {}
try:
async with rt.write_lock:
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (
msg["expected_config_rev"] != config_rev
or msg["expected_layout_rev"] != layout_rev
or candidate.get("config_rev") != config_rev
or candidate.get("layout_rev") != layout_rev
):
raise ImportFailure("conflict", "Plan changed after the preview")
kind = str(candidate["document"]["kind"])
requested_policy = msg.get("duplicate_policy", candidate.get("duplicate_policy"))
if kind == "space" and requested_policy != candidate.get("duplicate_policy"):
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
target_config, target_layout, details = await hass.async_add_executor_job(
partial(
prepare_apply,
candidate,
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}},
layout_data.get("layout") or {},
duplicate_policy=candidate.get("duplicate_policy"),
confirm_missing_content=msg["confirm_missing_content"],
)
)
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
target_config,
config_data.get("config"),
)
if missing:
raise ImportFailure(
"missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = None
if kind == "full":
backup = {
"kind": "import",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
original_metadata = _layout_metadata(layout_data)
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
}
if backup is not None:
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "import",
"config": target_config,
"layout": target_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"final_metadata": final_metadata,
}
rollback = {
"kind": "import_rollback",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout") or {},
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
# A token becomes single-use only after both durable halves land.
get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import commit failed")
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
return
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
if kind == "full":
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
live_marker_ids = {
str(marker.get("id")) for marker in target_config.get("markers") or []
}
if recorder is not None:
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
if marker_id not in live_marker_ids:
try:
await recorder.async_delete(marker_id)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan: removing orphan import trail failed")
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {
"ok": True,
"kind": kind,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"counts": details.get("counts", {}),
"space_id": details.get("space_id"),
"can_undo": kind == "full",
})
# ---------------- layout ----------------
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
"""Drop positions which a deleted marker can no longer own.
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 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_`.
"""
return live_layout(config, layout)
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
@websocket_api.async_response
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
@@ -85,8 +497,14 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
data = await rt.store.async_load() or {}
config_data = await rt.config_store.async_load() or {}
connection.send_result(
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
msg["id"], {
"layout": data.get("layout", {}),
"rev": int(data.get("rev", 0)),
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
"undo_kind": _undo_kind(config_data, data),
}
)
@@ -113,6 +531,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
data = await rt.store.async_load() or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
@@ -120,9 +539,12 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
)
return
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
new_rev = current_rev + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": msg["layout"], "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -144,6 +566,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
if rt is None:
return
async with rt.write_lock:
# A stale browser may still finish a drag after another client deleted
# the marker. Its tombstone is the server-side authority: acknowledge
# but ignore the late point so re-adding starts without a zombie
# position.
config_data = await rt.config_store.async_load() or {}
config = config_data.get("config") or {}
markers = config.get("markers") or []
deleted = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
for m in markers
)
live_virtual = any(
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
and m.get("binding") == "virtual"
for m in markers
)
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"], {
"ok": True,
"ignored": "removed" if deleted else "missing_virtual",
"rev": int(data.get("rev", 0)),
})
return
data = await rt.store.async_load() or {}
layout = data.get("layout", {})
layout[msg["device_id"]] = msg["pos"]
@@ -151,8 +606,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# optimistic locking on layout/set meaningless — every drag reset the
# counter to 0 (HP-1454-08)
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -205,7 +662,11 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
for key, pos in (backup.get("positions") or {}).items():
restored[key] = pos
new_rev = current_rev + 1
await rt.store.async_save({"layout": restored, "rev": new_rev})
await async_save_layout_state(
rt, data, restored, new_rev,
metadata=_optimizer_backup_after_layout_maintenance(data, new_rev),
remove=("repair_backup",),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
"restored": len(backup.get("positions") or {})})
@@ -237,10 +698,14 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
# the backup rides the same store write: either both are durable or
# neither — the deletion-shy rules of this project apply to positions
# too
await rt.store.async_save({
"layout": new_layout, "rev": new_rev,
"repair_backup": {"space": space_id, "positions": touched},
})
await async_save_layout_state(
rt, data, new_layout, new_rev,
metadata={
**_optimizer_backup_after_layout_maintenance(data, new_rev),
"repair_backup": {"space": space_id, "positions": touched},
},
remove=("repair_backup",),
)
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
@@ -548,8 +1013,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
if msg["device_id"] in layout:
del layout[msg["device_id"]]
new_rev = int(data.get("rev", 0)) + 1
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
"layout": layout, "rev": new_rev})
await async_save_layout_state(
rt, data, layout, new_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
if new_rev is not None:
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
@@ -561,13 +1028,28 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
@websocket_api.async_response
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Return the configuration and its revision."""
"""Return the configuration, its revision, and whether this user may write.
`can_write` is the single source of truth for the card's editor chrome
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
check that drifted from the integration option.
"""
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = {**DEFAULT_CONFIG, **data.get("config", {})}
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
connection.send_result(
msg["id"],
{
"config": config,
"rev": data.get("rev", 0),
"can_write": may_write(hass, getattr(connection, "user", None)),
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
"undo_kind": _undo_kind(data, layout_data),
},
)
@@ -660,6 +1142,15 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
)
return
# Marker-to-marker light links are a semantic graph layered on top of
# the lossless controls array. Validate only edges introduced by this
# write so an unrelated edit can still round-trip a legacy broken ref.
try:
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_value_badges(msg["config"], data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
# An internal plan url must name a file that exists. The card can pick a
# plan and then delete it from the same dialog, and two clients can do
# the same thing in either order — the lock serialises them but says
@@ -679,6 +1170,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return
new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
try:
await _discard_optimizer_snapshot(rt)
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
# Still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1).
# It is best-effort housekeeping behind an already durable write — a
@@ -704,6 +1199,204 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
# ---------------- whole-plan maintenance ----------------
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize",
vol.Required("config"): CONFIG_SCHEMA,
vol.Required("layout"): LAYOUT_SCHEMA,
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Atomically-intended replacement of config+layout with one-deep undo.
Home Assistant stores are separate files, so a literal cross-file
transaction is impossible. Persisting the target as an intent before
either half changes makes a crash resumable during the next setup; the UI
only receives success once both halves are durable.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may optimize plans")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
size = len(json.dumps(msg["config"], separators=(",", ":")))
if size > MAX_CONFIG_BYTES:
connection.send_error(
msg["id"], "too_large",
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
)
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(
msg["id"], "conflict",
f"Plan changed elsewhere (config {config_rev}, layout {layout_rev})",
)
return
# Optimization is a normal configuration write with an additional
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
try:
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_value_badges(msg["config"], config_data.get("config"))
except MarkerControlError as err:
connection.send_error(msg["id"], err.code, str(err))
return
missing = await hass.async_add_executor_job(
_missing_internal_plans,
Path(hass.config.path(PLANS_DIR)),
msg["config"],
config_data.get("config"),
)
if missing:
connection.send_error(
msg["id"], "missing_plan",
"Plan file no longer exists: " + ", ".join(sorted(missing)),
)
return
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
backup = {
"kind": "optimize",
"config": config_data.get("config") or DEFAULT_CONFIG,
"layout": layout_data.get("layout", {}),
"created": int(time.time()),
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
pending = {
"config": msg["config"],
"layout": msg["layout"],
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
}
# Intent first. A setup-time finisher completes whichever half a crash
# interrupted; until then the visible layout/revision remain unchanged.
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": True,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/optimize_undo",
vol.Required("expected_config_rev"): int,
vol.Required("expected_layout_rev"): int,
}
)
@websocket_api.async_response
async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Restore the snapshot, but never overwrite edits made after optimization."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may undo optimization")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
restored_kind = "optimize"
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
return
if not _optimizer_backup_is_current(config_data, layout_data):
connection.send_error(
msg["id"], "no_backup",
"The optimization backup is unavailable or a later edit made it stale",
)
return
backup = layout_data[_OPTIMIZE_BACKUP]
restored_kind = str(backup.get("kind") or "optimize")
restored_config = backup.get("config") or DEFAULT_CONFIG
restored_layout = backup.get("layout") or {}
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
pending = {
"kind": "import_undo" if restored_kind == "import" else "optimize_undo",
"config": restored_config,
"layout": restored_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": True,
}
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
await async_save_layout_state(
rt, layout_data, restored_layout, new_layout_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
_refresh_trail_recorder(hass)
if restored_kind == "import":
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
live_marker_ids = {
str(marker.get("id")) for marker in restored_config.get("markers") or []
}
if recorder is not None:
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
if marker_id not in live_marker_ids:
try:
await recorder.async_delete(marker_id)
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan: removing orphan undo trail failed")
entry = get_entry(hass)
if entry is not None:
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {
"ok": True,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"can_undo": False,
})
# ---------------- plan uploads ----------------
@@ -785,3 +1478,22 @@ async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConn
"""Current + previous cleanup runs per marker, raw robot coordinates."""
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/trail/delete",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
}
)
@websocket_api.async_response
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
"""Permanently forget one deleted marker's current and previous runs."""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
return
if _runtime(hass, connection, msg["id"]) is None:
return
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
removed = await rec.async_delete(msg["marker_id"]) if rec else False
connection.send_result(msg["id"], {"ok": True, "removed": removed})
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env node
/** Isolated 1/10/30/60-pool performance profiles for #19 and #55. */
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
import { launch } from './serve.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const profile = valueArg('profile') || 'large-light-blend-v1';
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
throw new Error(`unknown Glow profile: ${profile}`);
const parsedSamples = Number(valueArg('samples'));
const parsedWarmups = Number(valueArg('warmups'));
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
const warmups = Math.max(0, Math.min(5, Number.isFinite(parsedWarmups) && parsedWarmups >= 0 ? parsedWarmups : 1));
const requestedVariants = valueArg('variants')?.split(',').map(Number);
if (requestedVariants?.some((count) => ![1, 10, 30, 60].includes(count)))
throw new Error(`invalid Glow variants: ${valueArg('variants')}`);
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const additiveFixture = JSON.parse(readFileSync(
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
));
additiveFixture.sourceIds = Object.keys(additiveFixture.ha.states)
.filter((entityId) => entityId.startsWith('light.'));
additiveFixture.roomCount = additiveFixture.config.spaces
.reduce((sum, space) => sum + space.rooms.length, 0);
additiveFixture.deviceCount = Object.keys(additiveFixture.ha.devices).length;
const makeOverlayFixture = () => {
const large = makeLargeHouseFixture();
const firstSpace = large.config.spaces[0].id;
const sourceDeviceIds = Object.entries(large.layout)
.filter(([, position]) => position.s === firstSpace)
.slice(0, 60)
.map(([deviceId]) => deviceId);
const sourceIds = [];
sourceDeviceIds.forEach((deviceId, index) => {
for (const [entityId, entity] of Object.entries(large.entities)) {
if (entity.device_id !== deviceId) continue;
delete large.entities[entityId];
delete large.states[entityId];
}
const entityId = `light.glow_overlay_${String(index + 1).padStart(3, '0')}`;
large.entities[entityId] = {
entity_id: entityId, device_id: deviceId, platform: 'houseplan_perf',
config_entry_id: 'perf_entry', disabled_by: null,
};
large.states[entityId] = {
entity_id: entityId, state: 'on',
attributes: {
friendly_name: `Overlay light ${index + 1}`,
brightness: 96 + (index % 5) * 32,
rgb_color: index % 2 ? [255, 154, 72] : [92, 156, 255],
},
};
sourceIds.push(entityId);
});
// The shared large-house fixture already contains a few ordinary lights.
// Keep them as devices but turn them off so the profile's pool cardinality
// is exactly the declared 1/10/30/60, not N plus an unrelated background lamp.
for (const [entityId, state] of Object.entries(large.states)) {
if (entityId.startsWith('light.') && !sourceIds.includes(entityId)) {
large.states[entityId] = { ...state, state: 'off' };
}
}
for (const space of large.config.spaces) {
space.settings = { ...(space.settings || {}), fill_mode: 'temp', glow_enabled: true };
}
return {
fixture: 'large-house-glow-overlay-v1', variants: [1, 10, 30, 60],
config: large.config, layout: large.layout,
ha: { devices: large.devices, entities: large.entities, areas: large.areas, states: large.states },
sourceIds,
roomCount: large.counts.rooms,
deviceCount: large.counts.devices,
};
};
const fixture = profile === 'large-light-blend-v1' ? additiveFixture : makeOverlayFixture();
if (requestedVariants?.length) fixture.variants = [...new Set(requestedVariants)];
const viewport = { width: 1280, height: 900 };
const { page, browser } = await launch(
viewport, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{}, resolve(targetRoot, 'demo/srv'),
);
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const sample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, profile, sample, cardContract }) => {
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('Glow benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const observeLongTasks = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask'))
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return { stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const values = entries.map((entry) => entry.duration);
return {
supported: true,
count: values.length,
maxMs: Number((values.length ? Math.max(...values) : 0).toFixed(2)),
totalMs: Number(values.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
}};
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc(); await frame(); globalThis.gc(); await frame();
return true;
};
const configFor = () => {
const config = structuredClone(fixture.config);
if (profile === 'large-light-blend-v1') {
const settings = config.spaces[0].settings;
settings.fill_mode = 'glow';
delete settings.glow_enabled;
}
return config;
};
const statesFor = (count, brightnessDelta = 0) => {
const active = new Set(fixture.sourceIds.slice(0, count));
const sources = new Set(fixture.sourceIds);
return Object.fromEntries(Object.entries(fixture.ha.states).map(([entityId, state]) => {
if (!sources.has(entityId)) return [entityId, state];
return [entityId, {
...state,
state: active.has(entityId) ? 'on' : 'off',
attributes: {
...state.attributes,
brightness: Math.max(1, Math.min(255, Number(state.attributes.brightness) + brightnessDelta)),
},
}];
}));
};
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
language: 'en', locale: { language: 'en' },
user: { id: 'glow-perf', name: 'Glow performance', is_admin: true },
devices: fixture.ha.devices, entities: fixture.ha.entities,
areas: fixture.ha.areas, states, floors: {}, connection,
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: configFor(), 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.ha.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.ha.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'glow_fixture', domain: 'houseplan_fixture', title: 'Glow fixture' }];
if (message.type === 'manifest/list')
return [{ domain: 'houseplan_fixture', name: 'House Plan Glow Fixture' }];
return { ok: true };
},
callService: async () => undefined,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const cacheSnapshot = (card) => ({
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,
});
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
host.replaceChildren(card);
card.hass = hassFor(statesFor(1));
window.__hpAssertCardContract(card, cardContract);
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
if ('_glowScreenBlend' in card) {
const probeDeadline = performance.now() + 2500;
while (!card._glowScreenBlend && performance.now() < probeDeadline)
await new Promise((done) => setTimeout(done, 10));
}
await card.updateComplete;
await frame();
for (const count of fixture.variants) {
// Mount cost is not part of this profile. Prime each source-count
// state on the same full plan, then measure only the following HA tick.
card.hass = hassFor(statesFor(count));
await card.updateComplete;
await frame();
let renders = 0;
const originalUpdate = card.performUpdate.bind(card);
card.performUpdate = () => { renders++; return originalUpdate(); };
const longTasks = observeLongTasks();
const started = performance.now();
card.hass = hassFor(statesFor(count, 1));
await card.updateComplete;
await frame();
result[`stateUpdate${count}Ms`] = Number((performance.now() - started).toFixed(2));
result.longTasks[`stateUpdate${count}`] = await longTasks.stop();
result.renderCounts[count] = renders;
result.poolCounts[count] = card.renderRoot.querySelectorAll('.glow-pool, .glowlayer circle').length;
}
window.__card = card;
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let index = 0; index < 5; index++) {
card.hass = hassFor(statesFor(60, index % 2));
await card.updateComplete;
await frame();
}
await forceGc();
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
result.cacheEntries = cacheEntries;
result.cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
result.heapGrowthBytes = heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore;
result.preciseGc = typeof globalThis.gc === 'function';
result.renderedDevices = card._devices?.length ?? 0;
result.screenBlend = card._glowScreenBlend === true;
return result;
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
const captureStarted = performance.now();
await page.screenshot({ type: 'png' });
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
if (sample >= 0) rows.push(row);
}
} finally {
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => undefined);
await browser.close();
}
const metricNames = [
...fixture.variants.map((count) => `stateUpdate${count}Ms`), 'screenshotCaptureMs',
];
const report = {
schema: 2,
profile,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version, chromium, platform: process.platform, arch: process.arch,
viewport, deviceScaleFactor: 1, cpuThrottleRate: 4, reducedMotion: true,
},
fixture: {
id: fixture.fixture, variants: fixture.variants,
rooms: fixture.roomCount, devices: fixture.deviceCount,
},
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
};
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);
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
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';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const fixture = makeLargeHouseFixture();
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
viewport,
1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{},
resolve(targetRoot, 'demo/srv'),
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, sample, cardContract }) => {
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 startLongTaskWindow = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
}
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return {
stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const durations = entries.map((entry) => entry.duration);
return {
supported: true,
count: durations.length,
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
},
};
};
const duration = async (action) => {
const longTasks = startLongTaskWindow();
const started = performance.now();
await action();
await frame();
return {
ms: Number((performance.now() - started).toFixed(2)),
longTasks: await longTasks.stop(),
};
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc();
await frame();
globalThis.gc();
await frame();
return true;
};
const cacheSnapshot = (card) => ({
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,
});
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 connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
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,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
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 loadLongTaskResult = await loadLongTasks.stop();
const spaceSwitch = 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 stateUpdate = await duration(async () => {
card.hass = hassFor(nextStates);
await card.updateComplete;
});
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
await card.updateComplete;
const room = card._rszRooms()[0];
const pointerId = 777;
const quietEvent = {
pointerId,
stopPropagation: () => undefined,
preventDefault: () => undefined,
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
const plan = card._rszDrag?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
plan.a[1] + plan.n[1] * card._gridPitch,
];
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
card._rszMove({
...quietEvent,
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
});
await card.updateComplete;
card._rszCancelDrag();
card._setMode('view');
await card.updateComplete;
});
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const panZoom = 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 settingsDialog = await duration(async () => {
card._openSettingsDialog();
await card.updateComplete;
});
card._settingsDialog = null;
await card.updateComplete;
const switchCycle = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
// A user cannot produce twelve tab clicks in one JavaScript task.
// Yield between interactions so Long Task entries describe one
// switch, while switchCycleMs still measures the complete cycle.
await new Promise((done) => setTimeout(done, 0));
}
});
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let round = 0; round < 4; round++) {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
await new Promise((done) => setTimeout(done, 0));
}
await forceGc();
}
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
const result = {
sample,
modelReadyMs,
firstStableRenderMs,
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
panZoomMs: panZoom.ms,
settingsDialogMs: settingsDialog.ms,
switchCycleMs: switchCycle.ms,
longTasks: {
load: loadLongTaskResult,
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
panZoom: panZoom.longTasks,
settingsDialog: settingsDialog.longTasks,
switchCycle: switchCycle.longTasks,
},
cacheEntries,
cacheGrowth,
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
preciseGc: typeof globalThis.gc === 'function',
renderedDevices: card._devices?.length ?? 0,
};
card.remove();
await frame();
return result;
}, { fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT });
if (measuredSample >= 0) rows.push(row);
}
} finally {
await browser.close();
}
const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
const report = {
schema: 2,
profile: 'large-house-v1',
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version,
chromium,
platform: process.platform,
arch: process.arch,
viewport,
deviceScaleFactor: 1,
reducedMotion: true,
},
fixture: LARGE_HOUSE_COUNTS,
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
note: 'Compare with a base-SHA report captured by the same runner and evaluate demo/performance/budgets.json.',
};
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else {
process.stdout.write(text);
}
+15
View File
@@ -0,0 +1,15 @@
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
const expected = sourceFingerprint(root);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
);
}
return expected;
}
+238
View File
@@ -0,0 +1,238 @@
/**
* 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);
const lightMarkers = Object.entries(runtime.entities)
.filter(([entityId]) => entityId.startsWith('light.'))
.map(([_entityId, entity]) => ({
id: entity.device_id,
binding: `device:${entity.device_id}`,
is_light: true,
}));
return {
config: { spaces, markers: lightMarkers, settings: { glow_radius_cm: 300 } },
...runtime,
counts: LARGE_HOUSE_COUNTS,
};
};
+206
View File
@@ -0,0 +1,206 @@
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
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;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
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: fixtureWallKey(edge.a, edge.b),
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: 'none', glow_enabled: true, 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 },
},
};
entities['sun.sun'] = {
entity_id: 'sun.sun', device_id: null, platform: 'sun', disabled_by: null,
};
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)],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
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,
});
+61
View File
@@ -0,0 +1,61 @@
# 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, the open contextual tray at wide/medium/narrow widths in English and
Russian (selection, tool options, group and palette), long dialog
titles/footers, mobile clipping, themes and zoom/remount. The desktop and
mobile device-dialog scenarios use a real light and make the complete
source-role, Glow colour, brightness and radius controls visible; capturing
only the top of that section fails the scenario before comparison.
The Glow matrix also keeps one deliberately opaque custom-fill scene with a
single source and two doorways: it makes hard spill wedges and fully unlit
radial spokes visible instead of hiding them under a translucent room fill.
## 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.
Scenarios may also declare a semantic pixel region (for example, a receiving
room that must contain warm light). `golden:capture` and `golden:verify` reject
the capture before baseline comparison when that visual precondition is empty;
a reviewed but meaningless PNG therefore cannot become the contract.
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
const reportPath = resolve(from, 'golden-report.json');
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
if (report.buildFingerprint !== sourceFingerprint(ROOT))
throw new Error('candidate screenshots were not captured from the current frontend source');
if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const byId = new Map(report.results.map((result) => [result.id, result]));
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(baselineRoot, { recursive: true });
const hashes = {};
const candidates = [];
for (const scenario of GOLDEN_SCENARIOS) {
const result = byId.get(scenario.id);
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
if (!result?.actualSha256 || !existsSync(candidate))
throw new Error(`review candidate missing: ${scenario.id}`);
const bytes = readFileSync(candidate);
const digest = createHash('sha256').update(bytes).digest('hex');
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
candidates.push({ scenario, candidate });
hashes[scenario.id] = digest;
}
// Validate the complete set first: a broken report must never leave a half-
// updated baseline directory behind.
for (const { scenario, candidate } of candidates)
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
schema: 1,
matrixVersion: GOLDEN_MATRIX_VERSION,
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
scenarios: hashes,
}, null, 2)}\n`, 'utf8');
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
+1
View File
@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -0,0 +1,47 @@
{
"schema": 1,
"matrixVersion": 12,
"acceptedAt": "2026-08-12T10:10:45.897Z",
"sourceFingerprint": "117e675d4314f3ef3d16b7d94885f953bbc856da645e488f37045ef5a04716eb",
"chromium": "151.0.7922.34",
"scenarios": {
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
"geometry-plan-editor-dark": "9b4cf27319ec91b0d58ca9ad6f93db84938eb5ac52d92c60f18cb46741e4363d",
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
"tray-wide-selection-en": "29dcae474864028eead4e661354c57340221f471c503366150e9ca6c76f65a2c",
"tray-wide-tool-ru": "7a5c9a9464a2efd28edde85bffcaf62dab48abde84566974c524fae19e5580b8",
"tray-medium-group-en": "79fcdf2815db456aa2934bce7982e09332d8d327ea7813e3f2c542e78f03ae90",
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
"geometry-diagonal-45-opening-dark": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "074fb859d2ae1dc2ccd2045e2d6fb6d8389e430cbef20287419b8840cbbd5094",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "87b893df5f5f2d75cbf022edcb353a519e0221595c64ced993246cfb5c9f9c22",
"device-dialog-mobile-ru": "69fc5f5d9640b68893e5143890b69ca63a1a7817a4aa6a3cf04b3c6162ae4510",
"device-help-popover-light-ru": "0fd2b02c33f9cd3af3e8bd5f326a4b1e52f02875ec4ef02255b45b215aeb76c4",
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

+432
View File
@@ -0,0 +1,432 @@
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 });
}
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
return space;
};
if (scenario.deviceName) {
if (!scenario.deviceId || !fixture.devices?.[scenario.deviceId])
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean') {
const space = requireSpace();
space.settings = {
...(space.settings || {}),
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
}
if (scenario.extraOpenings?.length) {
const space = requireSpace();
const known = new Set((space.openings || []).map((opening) => opening.id));
for (const opening of scenario.extraOpenings) {
if (!opening?.id || known.has(opening.id))
throw new Error(`golden extraOpening has missing/duplicate id: ${opening?.id || '<empty>'}`);
if (!['door', 'window', 'gate'].includes(opening.type))
throw new Error(`golden extraOpening has unknown type: ${opening.type}`);
known.add(opening.id);
}
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
}
if (scenario.wallReplacements?.length) {
const space = requireSpace();
const samePoint = (a, b) => Array.isArray(a) && Array.isArray(b)
&& Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
for (const replacement of scenario.wallReplacements) {
const index = (space.walls || []).findIndex((wall) => (
samePoint(wall.a, replacement.match?.a) && samePoint(wall.b, replacement.match?.b)
) || (
samePoint(wall.a, replacement.match?.b) && samePoint(wall.b, replacement.match?.a)
));
if (index < 0 || !replacement.segments?.length)
throw new Error(`golden wallReplacement cannot find a valid wall in ${space.id}`);
space.walls.splice(index, 1, ...structuredClone(replacement.segments));
}
}
if (scenario.hideOpenings) {
const space = requireSpace();
space.settings = { ...(space.settings || {}), hide_openings: true };
}
if (scenario.roomGlow) {
const space = requireSpace();
const unknown = new Set(Object.keys(scenario.roomGlow));
for (const room of space.rooms) {
if (!(room.id in scenario.roomGlow)) continue;
unknown.delete(room.id);
room.settings = { ...(room.settings || {}), glow: scenario.roomGlow[room.id] };
}
if (unknown.size) throw new Error(`golden roomGlow references missing room(s): ${[...unknown].join(', ')}`);
}
if (scenario.roomCustomFill) {
const space = requireSpace();
const unknown = new Set(Object.keys(scenario.roomCustomFill));
for (const room of space.rooms) {
if (!(room.id in scenario.roomCustomFill)) continue;
unknown.delete(room.id);
room.settings = { ...(room.settings || {}), custom_fill: scenario.roomCustomFill[room.id] };
}
if (unknown.size)
throw new Error(`golden roomCustomFill references missing room(s): ${[...unknown].join(', ')}`);
}
if (scenario.allLightsOff) {
for (const [entityId, state] of Object.entries(fixture.states || {})) {
if (!entityId.startsWith('light.')) continue;
fixture.states[entityId] = { ...state, state: 'off' };
}
}
if (scenario.stateOverrides) {
for (const [entityId, override] of Object.entries(scenario.stateOverrides)) {
const current = fixture.states?.[entityId];
if (!current) throw new Error(`golden stateOverride references missing entity: ${entityId}`);
fixture.states[entityId] = {
...current,
...structuredClone(override),
attributes: { ...(current.attributes || {}), ...(override.attributes || {}) },
};
}
}
if (scenario.markerOverrides) {
const ids = new Set(scenario.markerOverrides.map((marker) => marker.id));
const known = new Set((fixture.config.markers || []).map((marker) => marker.id));
const missing = [...ids].filter((id) => !known.has(id));
if (missing.length) throw new Error(`golden markerOverrides reference missing marker(s): ${missing.join(', ')}`);
fixture.config.markers = [
...(fixture.config.markers || []).filter((marker) => !ids.has(marker.id)),
...structuredClone(scenario.markerOverrides),
];
}
if (scenario.layoutOverrides) {
const missing = Object.keys(scenario.layoutOverrides).filter((id) => !(id in (fixture.layout || {})));
if (missing.length) throw new Error(`golden layoutOverrides reference missing item(s): ${missing.join(', ')}`);
fixture.layout = { ...(fixture.layout || {}), ...structuredClone(scenario.layoutOverrides) };
}
return fixture;
}
export async function prepareGoldenScenario(page, scenario) {
await stableEnvironment(page, scenario);
const fixture = prepareGoldenFixture(scenario);
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);
}
};
const settleMode = async (card) => {
await until(() => !card._modeTransitionBusy);
await card.updateComplete;
await frame();
};
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;
await settleMode(card);
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
card.requestUpdate();
await card.updateComplete;
}
if (scenario.editorTray) {
let expectedKind = '';
if (scenario.editorTray === 'plan-selection') {
card._physicalSel = { kind: 'partition', id: 'geo-partition-h' };
expectedKind = 'selection';
} else if (scenario.editorTray === 'plan-tool') {
card._physicalSel = null;
card._tool = 'draw';
expectedKind = 'tool';
} else if (scenario.editorTray === 'decor-selection') {
card._decorTool = 'select';
card._decorSel = 'geo-axis-h';
expectedKind = 'selection';
} else if (scenario.editorTray === 'decor-tool') {
card._decorSel = null;
card._decorTool = 'line';
expectedKind = 'tool';
} else if (scenario.editorTray === 'furniture-palette') {
card._decorSel = null;
card._furnPalette = null;
card._editorSecondary.openPalette();
card._decorTool = 'furniture';
expectedKind = 'palette';
} else if (scenario.editorTray === 'group') {
const group = {
id: 'golden-group', label: 'Arrange', icon: 'mdi:shape-outline', items: [
{ id: 'align', label: 'Align', icon: 'mdi:format-align-center', role: 'command', invoke: () => undefined },
{ id: 'distribute', label: 'Distribute', icon: 'mdi:format-horizontal-align-center', role: 'command', invoke: () => undefined },
],
};
Object.defineProperty(card, '_editorToolbarGroups', {
configurable: true,
get: () => [group],
});
card.requestUpdate();
await card.updateComplete;
card._editorSecondary.toggleGroup(card._editorToolbarGroups, group.id);
expectedKind = 'group';
} else {
throw new Error(`unknown golden editor tray: ${scenario.editorTray}`);
}
card.requestUpdate();
await card.updateComplete;
await frame();
const tray = card.renderRoot.querySelector(
`.editor-secondary-host.open .editor-secondary.kind-${expectedKind}`,
);
if (!tray) throw new Error(`golden editor tray did not open: ${scenario.editorTray}`);
}
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;
await settleMode(card);
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.deviceLightControls) {
card._setMarkerLightRole('always');
await card.updateComplete;
card._setMarkerGlowMode('fixed');
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const roleGroup = dialog?.querySelector('input[name="marker-light-role"]')?.closest('fieldset');
const glowGroup = dialog?.querySelector('input[name="marker-glow-mode"]')?.closest('fieldset');
const roleInputs = roleGroup?.querySelectorAll('input[name="marker-light-role"]');
const glowInputs = glowGroup?.querySelectorAll('input[name="marker-glow-mode"]');
const color = glowGroup?.querySelector('hp-color-opacity');
const brightness = glowGroup?.querySelector('input[type="range"]');
const radius = dialog?.querySelector('#marker-glow-radius');
if (!body || !roleGroup || !glowGroup || roleInputs?.length !== 3 || glowInputs?.length !== 3
|| !roleInputs[1]?.checked || !glowInputs[2]?.checked
|| !color || color.disabled || !brightness || brightness.disabled || !radius || radius.disabled)
throw new Error('golden device light-source controls are incomplete');
const bodyRect = body.getBoundingClientRect();
const roleRect = roleGroup.getBoundingClientRect();
body.scrollTop += roleRect.top - bodyRect.top - 8;
await frame();
const visibleBody = body.getBoundingClientRect();
const visibleRole = roleGroup.getBoundingClientRect();
const visibleRadius = radius.getBoundingClientRect();
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
throw new Error('golden viewport does not show the complete device light-source controls');
}
if (scenario.openHelp) {
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
await help?.updateComplete;
const trigger = help?.renderRoot?.querySelector('.trigger');
if (!trigger) throw new Error(`golden help trigger missing: ${scenario.openHelp}`);
trigger.click();
await help.updateComplete;
await frame();
const surface = help.renderRoot?.querySelector('.tooltip:popover-open')
|| card.renderRoot.querySelector('hp-dialog')?.renderRoot
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
if (trigger.getAttribute('aria-expanded') !== 'true' || !surface?.getBoundingClientRect().width)
throw new Error(`golden help surface did not open: ${scenario.openHelp}`);
}
if (scenario.focusDialogClose) {
const dialog = card.renderRoot.querySelector('hp-dialog');
await dialog?.updateComplete;
dialog?.renderRoot?.querySelector('.close')?.focus();
}
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
const full = scenario.dialog === 'backup-full';
card._backupImportDialog = {
filename: full ? 'houseplan-full-2026-08-11.json' : 'houseplan-space-ground.json',
size: 12345,
token: 'golden-token',
preview: {
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
duplicates: full ? 0 : 2,
confirmation_required: full,
content: full
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
: [{ url: 'https://example.test/ground.svg', state: 'external' }],
},
expectedConfigRev: 1, expectedLayoutRev: 1,
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
};
card.requestUpdate();
await card.updateComplete;
} else if (scenario.dialog === 'decor-color') {
card._setMode('decor');
card._decorTool = 'select';
await card.updateComplete;
await settleMode(card);
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'),
helpOpen: [...card.renderRoot.querySelectorAll('hp-help')]
.some((help) => help.renderRoot?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true'),
editorTray: card.renderRoot.querySelector('.editor-secondary-host.open .editor-secondary')
?.className || '',
};
}, { 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) };
});
}
+138
View File
@@ -0,0 +1,138 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 12;
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 },
// HP-UX-11 visual contract: every adaptive width is captured in both
// languages while the six materially different tray contents are open.
{ id: 'tray-wide-selection-en', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
editorTray: 'plan-selection', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, ...page },
{ id: 'tray-wide-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
editorTray: 'plan-tool', language: 'ru', theme: 'dark',
viewport: { width: 1180, height: 900 }, ...page },
{ id: 'tray-medium-group-en', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
editorTray: 'group', language: 'en', theme: 'dark',
viewport: { width: 760, height: 820 }, ...page },
{ id: 'tray-medium-selection-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'decor-selection', language: 'ru', theme: 'dark',
viewport: { width: 760, height: 820 }, ...page },
{ id: 'tray-narrow-palette-en', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'furniture-palette', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, ...page },
{ id: 'tray-narrow-tool-ru', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
editorTray: 'decor-tool', language: 'ru', theme: 'dark',
viewport: { width: 390, height: 760 }, ...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', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-filled-tunnel-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#66717c', a: 0.55 }, glowEnabled: false,
hideOpenings: true,
wallReplacements: [{
match: { a: [0.5, 0.1], b: [0.5, 0.88] },
segments: [
{ key: fixtureWallKey([0.5, 0.1], [0.5, 0.5]), a: [0.5, 0.1], b: [0.5, 0.5], cm: 18 },
{ key: fixtureWallKey([0.5, 0.5], [0.5, 0.88]), a: [0.5, 0.5], b: [0.5, 0.88], cm: 32 },
],
}],
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', glowEnabled: false, 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', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-fill-lqi-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'lqi', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-temp-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', glowEnabled: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-temp-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', glowEnabled: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-custom-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-opaque-glow-two-doorways-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#3f4854', a: 1 }, glowEnabled: true, sunRays: false,
allLightsOff: true,
stateOverrides: {
'light.golden_light_one': { state: 'on', attributes: { rgb_color: [255, 196, 112], brightness: 255 } },
},
extraOpenings: [
{ id: 'light-door-second', type: 'door', x: 0.50, y: 0.32, angle: 90, length: 0.13 },
],
layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },
// The golden is protection only if the receiving half actually contains
// rendered light. A data-only check once let a visually empty baseline pass.
warmPixelRegion: { x: 0.5, y: 0, w: 0.5, h: 1, minPixels: 2500, minRedBlueDelta: 25 },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-custom-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-temp-glow-no-sources-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', glowEnabled: true, allLightsOff: true,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-temp-glow-room-override-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', glowEnabled: true, roomGlow: { 'light-left': true, 'light-right': false },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-manual-auto-spill-overlap-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'temp', glowEnabled: true,
markerOverrides: [{
id: 'golden-light-two', binding: 'device:golden-light-two',
glow_color: { c: '#3a8fff', bri: 0.35 }, glow_radius_cm: 420,
}],
layoutOverrides: { 'golden-light-two': { s: 'golden-lighting', x: 0.39, y: 0.57 } },
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-light-two', deviceLightControls: true,
deviceName: 'Living room light controller with an intentionally long title',
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 1200 }, ...page },
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
language: 'ru', theme: 'light', viewport: { width: 760, height: 900 }, ...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 },
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-full', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, ...page },
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-space', language: 'ru', theme: 'light',
viewport: { width: 390, height: 820 }, ...page },
]);
+25
View File
@@ -0,0 +1,25 @@
// The name must NOT end with `manifest.json`: the HACS submission check globs
// `*manifest.json` over the whole clone of the default branch and refuses a
// repository with more than one match (test/repo-hygiene.test.mjs).
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.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');
};
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
* entry may survive after a scenario is removed or renamed. */
export const goldenScenarioSetsMatch = (expected, indexed, baselineFiles) => {
const normalized = (values) => [...new Set(values)].sort();
const wanted = normalized(expected);
return JSON.stringify(normalized(indexed)) === JSON.stringify(wanted)
&& JSON.stringify(normalized(baselineFiles)) === JSON.stringify(wanted);
};
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'));
};
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, 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,
goldenScenarioSetsMatch,
} 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,
});
}
/** Assert scenario semantics against the actual capture, not only its data.
* This protects a reviewed-but-empty baseline from becoming the reference. */
async function countWarmPixels(page, png, region) {
return page.evaluate(async ({ png64, region }) => {
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const left = Math.max(0, Math.min(image.width, Math.floor(region.x * image.width)));
const top = Math.max(0, Math.min(image.height, Math.floor(region.y * image.height)));
const right = Math.max(left, Math.min(image.width, Math.ceil((region.x + region.w) * image.width)));
const bottom = Math.max(top, Math.min(image.height, Math.ceil((region.y + region.h) * image.height)));
const pixels = context.getImageData(left, top, right - left, bottom - top).data;
let warm = 0;
for (let offset = 0; offset < pixels.length; offset += 4) {
if (pixels[offset] - pixels[offset + 2] > region.minRedBlueDelta) warm++;
}
return { warm, bounds: [left, top, right, bottom] };
}, { png64: png.toString('base64'), region });
}
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
* glyph pixels, not just an empty surface or a stale open-state flag. */
async function countHelpTextPixels(page, png, clip, spec) {
return page.evaluate(async ({ png64, clip, spec }) => {
const card = window.__goldenCard;
const help = card?.renderRoot?.querySelector(`hp-help[data-help-key="${spec.key}"]`);
const surface = help?.renderRoot?.querySelector('.tooltip:popover-open')
|| card?.renderRoot?.querySelector('hp-dialog')?.renderRoot
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
if (!surface) throw new Error(`semantic golden help missing: ${spec.key}`);
const bounds = surface.getBoundingClientRect();
const match = getComputedStyle(surface).color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
if (!match || match.length !== 3) throw new Error(`semantic golden help has invalid text color: ${spec.key}`);
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, image.width, image.height).data;
const originX = clip?.x || 0, originY = clip?.y || 0;
const left = Math.max(0, Math.ceil(bounds.left - originX) + 5);
const top = Math.max(0, Math.ceil(bounds.top - originY) + 5);
const right = Math.min(image.width - 1, Math.floor(bounds.right - originX) - 5);
const bottom = Math.min(image.height - 1, Math.floor(bounds.bottom - originY) - 5);
let textPixels = 0;
for (let y = top; y <= bottom; y++) {
for (let x = left; x <= right; x++) {
const offset = (y * image.width + x) * 4;
const distance = Math.abs(pixels[offset] - match[0])
+ Math.abs(pixels[offset + 1] - match[1])
+ Math.abs(pixels[offset + 2] - match[2]);
if (pixels[offset + 3] > 240 && distance <= 90) textPixels++;
}
}
return { textPixels, bounds: [left, top, right, bottom] };
}, { png64: png.toString('base64'), clip, spec });
}
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. The
* opening symbol is hidden by the scenario, so both centre lines should be a
* locally constant translucent surface. Sampling both axes catches the wall
* centre split as well as boundaries between atomic wall-profile pieces. */
async function inspectTunnelContinuity(page, png, clip, spec) {
return page.evaluate(async ({ png64, clip, spec }) => {
const card = window.__goldenCard;
const tunnel = card?.renderRoot?.querySelector(
`.opening-tunnels[data-layer="data"] [data-hp="opening-tunnel"][data-id="${spec.openingId}"]`,
);
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
const rect = tunnel.getBoundingClientRect();
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const originX = clip?.x || 0, originY = clip?.y || 0;
const left = Math.max(0, Math.ceil(rect.left - originX) + spec.insetPx);
const top = Math.max(0, Math.ceil(rect.top - originY) + spec.insetPx);
const right = Math.min(image.width - 1, Math.floor(rect.right - originX) - spec.insetPx);
const bottom = Math.min(image.height - 1, Math.floor(rect.bottom - originY) - spec.insetPx);
if (right - left < 3 || bottom - top < 3)
throw new Error(`semantic golden tunnel is too small: ${left},${top},${right},${bottom}`);
const pixels = context.getImageData(0, 0, image.width, image.height).data;
const rgb = (x, y) => {
const offset = (y * image.width + x) * 4;
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
};
let maxJump = 0, samplePairs = 0;
const compare = (a, b) => {
maxJump = Math.max(maxJump,
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
samplePairs++;
};
const centreX = Math.round((left + right) / 2);
const centreY = Math.round((top + bottom) / 2);
for (let x = left + 1; x <= right; x++) compare(rgb(x - 1, centreY), rgb(x, centreY));
for (let y = top + 1; y <= bottom; y++) compare(rgb(centreX, y - 1), rgb(centreX, y));
return { maxJump, samplePairs, bounds: [left, top, right, bottom] };
}, { png64: png.toString('base64'), clip, spec });
}
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;
if (scenario.warmPixelRegion) {
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
result.warmPixels = sample.warm;
result.warmPixelBounds = sample.bounds;
if (sample.warm < scenario.warmPixelRegion.minPixels) {
throw new Error(
`semantic golden assertion failed: ${sample.warm} warm pixels, expected at least `
+ `${scenario.warmPixelRegion.minPixels}`,
);
}
}
if (scenario.helpTextRegion) {
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
result.helpTextPixels = sample.textPixels;
result.helpPixelBounds = sample.bounds;
if (sample.textPixels < scenario.helpTextRegion.minPixels) {
throw new Error(
`semantic golden assertion failed: help ${scenario.helpTextRegion.key} contains `
+ `${sample.textPixels} text pixels, expected at least ${scenario.helpTextRegion.minPixels}`,
);
}
}
if (scenario.tunnelContinuity) {
const sample = await inspectTunnelContinuity(
page, actual, clip, scenario.tunnelContinuity,
);
result.tunnelMaxChannelJump = sample.maxJump;
result.tunnelSamplePairs = sample.samplePairs;
result.tunnelPixelBounds = sample.bounds;
if (sample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
throw new Error(
`semantic golden assertion failed: opening ${scenario.tunnelContinuity.openingId} `
+ `has a ${sample.maxJump}-channel local jump, expected at most `
+ `${scenario.tunnelContinuity.maxChannelJump}`,
);
}
}
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 expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
const indexedScenarioIds = Object.keys(baselineManifest?.scenarios || {});
const baselineScenarioIds = readdirSync(baselineRoot)
.filter((name) => name.endsWith('.png'))
.map((name) => name.slice(0, -'.png'.length));
const manifestValid = !!baselineManifest
&& !baselineManifest.invalid
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
&& baselineManifest.chromium === chromium
&& expectedScenarioIds.every((id) => typeof baselineManifest.scenarios?.[id] === 'string')
&& goldenScenarioSetsMatch(expectedScenarioIds, indexedScenarioIds, baselineScenarioIds);
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;
+159
View File
@@ -0,0 +1,159 @@
# Large-house performance gate
`benchmark_large_house.mjs` exercises the deterministic `large-house-v1`
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
40 columns and 500 decor objects on three floors.
The runner records seven measured samples after one discarded warm-up. With
this intentionally small CI sample, the nearest-rank `p95` is the observed
maximum; reports keep the conventional field name but should be read as a
high-tail guard rather than a population estimate:
- model readiness and first stable render;
- space switch, HA state update, pan/zoom and opening the settings dialog;
- a shared-wall room-resize preview which is cancelled before persistence;
- a twelve-switch navigation cycle;
- Long Tasks for every measured window;
- heap growth after four additional navigation rounds with forced GC;
- hot-cache size and growth after the same warmed cycles.
Every report is tied to the source fingerprint embedded by Rollup. A stale
bundle is a hard failure.
## CI contracts
Ordinary pushes, pull requests and prereleases use the blocking
`performance_smoke` job in `validate.yml`. It builds only the candidate and
measures the heaviest 60-source `large-house-glow-overlay-v1` state after one
warm-up, with three recorded samples. `compare.mjs --absolute-only` enforces the
reviewed hard timing, Long Task, heap, cache and rendered-device ceilings from
`budgets-glow-smoke.json`; it deliberately makes no noisy base-relative claim.
This is a catastrophic-regression guard, not a performance trend detector.
The dedicated `performance.yml` workflow is the full comparison. It runs on
every `main` promotion, weekly and on manual dispatch for an important beta or
performance-sensitive change. It checks out the candidate and its base SHA,
builds both, and runs them sequentially with the same Node.js 22 process
family, pinned Playwright Chromium and hosted runner. `compare.mjs` then
applies two limits:
1. a relative regression allowance against the base-SHA report;
2. an absolute safety ceiling from `budgets.json`.
The tighter limit wins. The absolute values are catastrophic safety ceilings,
not normal-performance targets; the base-relative comparison catches smaller
regressions. Small fast operations receive an absolute noise
allowance so normal scheduler jitter does not become a false regression. Heap,
Long Tasks, warmed-cache growth and the expected rendered-device count are
gated separately. Long-Task maximum/count/total checks use the same
relative-plus-absolute policy as timings. Both raw reports and the comparison
are always uploaded as the `full-performance` artifact, and the table is
written to the GitHub job summary. Stable release assets require both exact-SHA
`Validate` and exact-SHA `Full Performance`; prereleases require only
`Validate`.
This base-vs-candidate design intentionally does not compare timings captured
on different machines or different Chromium builds. A runtime/profile mismatch
fails closed.
Before the base checkout, CI fetches the complete commit graph and verifies the
requested comparison revision. A `main` push uses `github.event.before`, which
must both exist and remain an ancestor of the candidate; this catches the
unreachable SHA left by a force-push. A manual run may name an explicit tag,
branch or SHA, while an empty manual input and the weekly run use the candidate
parent. An unusable requested revision falls back with a warning to the direct
parent, then to the newest reachable semver release. If no safe comparison
exists, the job fails closed instead of comparing against an arbitrary commit.
## Private card contract
The candidate benchmark runner is also executed against the base bundle, so
every private `houseplan-card` field or method it reads is an explicit API of
the performance harness. `card-contract.mjs` lists that surface for the
large-house and Glow profiles. Each runner verifies it immediately after card
creation and fails with the exact missing names or invalid runtime types before
waiting for readiness or recording timings. Required caches must be real
`Map` instances and must never be converted from missing/invalid values to
plausible zeroes.
`fields` are required in every supported comparison base. `optionalFields` are
newer members whose absence has an explicit safe fallback in the runner; if an
optional member exists, its declared `fieldTypes` contract still applies. Add a
new safely degradable field to `optionalFields` until every supported base has
it, then promote it to `fields`. A member without a truthful fallback must be
introduced through a compatibility revision before the benchmark consumes it.
Rename a consumed private member in two revisions:
1. teach the contract and every reader to understand both the old and proposed
name while production still exposes the old name; land that compatibility
revision so it can become a comparison base;
2. rename the production member and prefer the new name while retaining the
old reader fallback. Remove the fallback only after all supported comparison
bases expose the new member.
This sequencing keeps the current harness capable of profiling both source
trees. A one-step rename that merely edits the candidate reader is forbidden:
it would make the same runner incompatible with its base bundle.
## Local diagnostics
Build and copy a fresh demo bundle first, then run:
```bash
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
```
A local report is diagnostic only; it cannot replace the CI comparison.
To reproduce the comparison against another checkout using one harness and one
browser installation:
```bash
npm run benchmark:large-house -- --target-root=../base --samples=7 --output=artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --output=artifacts/performance/candidate.json
npm run benchmark:compare
```
## Changing budgets
Budget changes require an explicit review of recent CI artifacts and a written
rationale in the change. Do not loosen a threshold merely to make a single red
run pass. A new fixture profile gets a new profile id instead of silently
changing the meaning of `large-house-v1`.
The `cleanFloor` entry ceiling is 160: the reviewed fixture currently warms
120 deterministic room/physical-body entries, and the extra 40 slots allow a
legitimate fixture extension without weakening the separate zero-growth gate.
## Glow profiles
Both Glow profiles run deterministic 1/10/30/60-pool variants at DPR 1 and
Chromium CPU throttling x4, but deliberately exercise different fixtures:
- `large-light-blend-v1` compares the isolated screen group with the previous
normal-layer implementation on the shared frontend/backend schema fixture
`test/fixtures/glow/additive-pools.json`;
- `large-house-glow-overlay-v1` measures simultaneous temperature fill and
independent Glow on the existing 60-room/200-device large-house fixture,
without changing `large-house-v1`.
```bash
npm run benchmark:glow -- --profile=large-light-blend-v1 --output=artifacts/performance/glow.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --output=artifacts/performance/overlay.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
```
Reports include per-variant state-update timings, render/pool counts, Long
Tasks, screenshot time, heap and cache growth. The first CI comparison against
a base SHA that predates `glow_enabled` bootstraps only the overlay profile's
relative baseline from the candidate; its absolute ceilings still gate that
introduction. Every subsequent revision compares both profiles to the real
base SHA.
The initial absolute ceilings are intentionally conservative bootstrap limits;
they must be reviewed against the first paired Ubuntu artifacts before the
feature is promoted from beta. Same-runner relative checks in the full workflow
remain the primary regression signal; the candidate-only smoke only guards
against catastrophic failures.
+39
View File
@@ -0,0 +1,39 @@
{
"schema": 1,
"profile": "large-house-glow-overlay-v1",
"minimumSamples": 3,
"timings": {
"stateUpdate60Ms": {
"stat": "median",
"hardMaxMs": 2200
},
"screenshotCaptureMs": {
"stat": "median",
"hardMaxMs": 3200
}
},
"longTasks": {
"maxSingleMs": 2700,
"maxCountP95": 12,
"maxTotalP95Ms": 5500
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864
},
"cacheEntries": {
"cleanFloor": 80,
"glowClip": 128,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0
},
"renderedDevices": 200
}
@@ -0,0 +1,27 @@
{
"schema": 1,
"profile": "large-house-glow-overlay-v1",
"minimumSamples": 7,
"timings": {
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
},
"longTasks": {
"maxSingleMs": 2700,
"maxSingleRegressionRatio": 0.5,
"maxSingleNoiseAllowanceMs": 200,
"maxCountP95": 12,
"maxCountRegressionRatio": 0.5,
"countNoiseAllowance": 2,
"maxTotalP95Ms": 5500,
"maxTotalRegressionRatio": 0.5,
"noiseAllowanceMs": 250
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
"cacheEntries": { "cleanFloor": 80, "glowClip": 128, "wallUnion": 1, "openingTunnel": 1, "openingWallIndex": 1 },
"cacheGrowth": { "cleanFloor": 0, "glowClip": 0, "wallUnion": 0, "openingTunnel": 0, "openingWallIndex": 0 },
"renderedDevices": 200
}
@@ -0,0 +1,27 @@
{
"schema": 1,
"profile": "large-light-blend-v1",
"minimumSamples": 7,
"timings": {
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 700 },
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1400 },
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2000 },
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3000 }
},
"longTasks": {
"maxSingleMs": 2500,
"maxSingleRegressionRatio": 0.5,
"maxSingleNoiseAllowanceMs": 200,
"maxCountP95": 12,
"maxCountRegressionRatio": 0.5,
"countNoiseAllowance": 2,
"maxTotalP95Ms": 5000,
"maxTotalRegressionRatio": 0.5,
"noiseAllowanceMs": 250
},
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
"cacheEntries": { "cleanFloor": 8, "glowClip": 200, "wallUnion": 1, "openingTunnel": 1, "openingWallIndex": 1 },
"cacheGrowth": { "cleanFloor": 0, "glowClip": 0, "wallUnion": 0, "openingTunnel": 0, "openingWallIndex": 0 },
"renderedDevices": 60
}
+87
View File
@@ -0,0 +1,87 @@
{
"schema": 1,
"profile": "large-house-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 200,
"hardMaxMs": 2500
},
"firstStableRenderMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 250,
"hardMaxMs": 3000
},
"spaceSwitchMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1500
},
"stateUpdateMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1000
},
"resizePreviewMs": {
"stat": "median",
"maxRegressionRatio": 0.4,
"noiseAllowanceMs": 150,
"hardMaxMs": 2000
},
"panZoomMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"settingsDialogMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 100,
"hardMaxMs": 1000
},
"switchCycleMs": {
"stat": "median",
"maxRegressionRatio": 0.35,
"noiseAllowanceMs": 250,
"hardMaxMs": 7000
}
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.3,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.35,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.3,
"noiseAllowanceMs": 150
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.75,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 160,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0
},
"renderedDevices": 200
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Private houseplan-card surface consumed by the performance runners.
*
* The candidate runner profiles both the candidate bundle and a bundle built
* from the comparison SHA. Keep these lists explicit so a private rename in
* either tree fails before measurements instead of silently reporting zeroes.
*/
const CACHE_FIELDS = Object.freeze([
'_cleanFloorCache',
'_glowClipCache',
'_wallUnionCache',
'_openingTunnelCache',
'_openingWallIndexCache',
]);
export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
label: 'large-house-v1',
methods: Object.freeze([
'_baseVb',
'_openSettingsDialog',
'_pickSpace',
'_rszCancelDrag',
'_rszEdgeDown',
'_rszMove',
'_rszRooms',
'_setMode',
'_viewOr',
]),
fields: Object.freeze([
'_booting',
...CACHE_FIELDS,
'_devices',
'_gridPitch',
'_loadOk',
'_model',
'_rszDrag',
'_settingsDialog',
'_tool',
]),
optionalFields: Object.freeze([]),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
_devices: 'array',
_glowClipCache: 'map',
_gridPitch: 'number',
_loadOk: 'boolean',
_model: 'array',
_tool: 'string',
}),
});
export const GLOW_CARD_CONTRACT = Object.freeze({
label: 'Glow performance profiles',
methods: Object.freeze([]),
fields: Object.freeze([
...CACHE_FIELDS,
'_devices',
'_loadOk',
]),
// Additive blending was introduced after the first supported performance
// bases. Its absence is safe: the runner keeps the historical normal blend.
optionalFields: Object.freeze(['_glowScreenBlend']),
fieldTypes: Object.freeze({
_cleanFloorCache: 'map',
_devices: 'array',
_glowClipCache: 'map',
_glowScreenBlend: 'boolean',
_loadOk: 'boolean',
}),
});
/** Single fail-fast implementation injected into both browser runners. Keep
* this function self-contained: runners serialize it with `toString()`. */
export function assertCardContract(card, contract) {
const matches = (value, expected) => {
if (expected === 'array') return Array.isArray(value);
if (expected === 'map') return value instanceof Map;
return typeof value === expected;
};
const missingMethods = contract.methods
.filter((name) => typeof card[name] !== 'function')
.map((name) => `${name}()`);
const missingFields = contract.fields
.filter((name) => !(name in card) || card[name] === undefined);
const invalidFields = [...contract.fields, ...(contract.optionalFields || [])]
.filter((name) => name in card && contract.fieldTypes?.[name]
&& !matches(card[name], contract.fieldTypes[name]))
.map((name) => `${name}:${contract.fieldTypes[name]}`);
const missing = [...missingMethods, ...missingFields];
if (missing.length || invalidFields.length) {
const details = [
missing.length ? `missing private API: ${missing.join(', ')}` : '',
invalidFields.length ? `invalid private API types: ${invalidFields.join(', ')}` : '',
].filter(Boolean).join('; ');
throw new Error(
`${contract.label} harness is incompatible with this houseplan-card bundle; ${details}. `
+ 'Update the explicit candidate/base compatibility contract before profiling.',
);
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { evaluatePerformanceBudget, performanceSummaryMarkdown } from './evaluate.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const absoluteOnly = process.argv.includes('--absolute-only');
const candidatePath = resolve(valueArg('candidate') ?? 'artifacts/performance/candidate.json');
const baselinePath = resolve(valueArg('baseline') ?? 'artifacts/performance/baseline.json');
const budgetsPath = resolve(valueArg('budgets') ?? 'demo/performance/budgets.json');
const outputPath = resolve(valueArg('output') ?? 'artifacts/performance/comparison.json');
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
const evaluation = evaluatePerformanceBudget({
candidate: readJson(candidatePath),
baseline: absoluteOnly ? null : readJson(baselinePath),
budgets: readJson(budgetsPath),
absoluteOnly,
});
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(evaluation, null, 2)}\n`, 'utf8');
const markdown = performanceSummaryMarkdown(evaluation);
process.stdout.write(markdown);
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown, 'utf8');
if (!evaluation.pass) {
console.error(`Performance budget failed: ${evaluation.failures.map((item) => item.id).join(', ')}`);
process.exitCode = 1;
}
+220
View File
@@ -0,0 +1,220 @@
const percentile = (values, p) => {
if (!values.length) return null;
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 finite = (value) => typeof value === 'number' && Number.isFinite(value);
const round = (value, digits = 2) => finite(value) ? Number(value.toFixed(digits)) : value;
export const summarizeTimings = (rows, metricNames) => Object.fromEntries(
metricNames.map((metric) => {
const values = rows.map((row) => row[metric]).filter(finite);
return [metric, {
median: percentile(values, 0.5),
p95: percentile(values, 0.95),
min: values.length ? Math.min(...values) : null,
max: values.length ? Math.max(...values) : null,
}];
}),
);
export const summarizeLongTasks = (rows) => {
const totals = [];
const counts = [];
const singles = [];
for (const row of rows) {
const windows = Object.values(row.longTasks ?? {});
totals.push(windows.reduce((sum, item) => sum + (item?.totalMs ?? 0), 0));
counts.push(windows.reduce((sum, item) => sum + (item?.count ?? 0), 0));
singles.push(...windows.map((item) => item?.maxMs ?? 0));
}
return {
maxSingleMs: round(singles.length ? Math.max(...singles) : 0),
countP95: percentile(counts, 0.95) ?? 0,
totalP95Ms: round(percentile(totals, 0.95) ?? 0),
};
};
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const requireReport = (report, budgets, label) => {
if (!report || report.schema !== 2) throw new Error(`${label}: unsupported report schema`);
if (report.profile !== budgets.profile) throw new Error(`${label}: unexpected benchmark profile`);
if (!Array.isArray(report.rows) || report.rows.length < budgets.minimumSamples) {
throw new Error(`${label}: expected at least ${budgets.minimumSamples} measured samples`);
}
};
const relativeLimit = (baseline, ratio, allowance) => Math.max(
baseline * (1 + ratio),
baseline + allowance,
);
const makeCheck = (id, actual, limit, details = {}) => ({
id,
actual: round(actual),
limit: round(limit),
pass: finite(actual) && actual <= limit,
...details,
});
/**
* Evaluate a candidate report against stable absolute ceilings. Full captures
* additionally compare a base-SHA report from the same runner; fast smoke
* captures deliberately enforce only the hard candidate limits.
*/
export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absoluteOnly = false }) => {
requireReport(candidate, budgets, 'candidate');
if (!absoluteOnly) {
requireReport(baseline, budgets, 'baseline');
if (!sameJson(candidate.fixture, baseline.fixture)) throw new Error('fixture mismatch');
for (const key of ['node', 'chromium', 'platform', 'arch']) {
if (candidate.runtime?.[key] !== baseline.runtime?.[key]) {
throw new Error(`runtime mismatch for ${key}`);
}
}
}
const checks = [];
for (const [metric, budget] of Object.entries(budgets.timings)) {
const stat = budget.stat ?? 'median';
const actual = candidate.summary?.[metric]?.[stat];
const base = absoluteOnly ? null : baseline.summary?.[metric]?.[stat];
if (!finite(actual) || (!absoluteOnly && !finite(base))) throw new Error(`missing ${stat} for ${metric}`);
const regressionLimit = absoluteOnly
? Number.POSITIVE_INFINITY
: relativeLimit(base, budget.maxRegressionRatio, budget.noiseAllowanceMs);
checks.push(makeCheck(
`timing.${metric}.${stat}`,
actual,
Math.min(budget.hardMaxMs, regressionLimit),
{
...(absoluteOnly ? {} : { baseline: round(base), regressionLimit: round(regressionLimit) }),
hardLimit: budget.hardMaxMs,
},
));
}
const candidateLong = candidate.longTasks ?? summarizeLongTasks(candidate.rows);
const baselineLong = absoluteOnly ? null : (baseline.longTasks ?? summarizeLongTasks(baseline.rows));
const longTasksAvailable = candidate.rows.every((row) => {
const windows = Object.values(row.longTasks ?? {});
return windows.length > 0 && windows.every((item) => item?.supported === true);
});
checks.push({ id: 'longTask.available', actual: longTasksAvailable ? 1 : 0, limit: 1, pass: longTasksAvailable });
const singleRegressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
baselineLong.maxSingleMs, budgets.longTasks.maxSingleRegressionRatio,
budgets.longTasks.maxSingleNoiseAllowanceMs,
);
checks.push(makeCheck(
'longTask.maxSingleMs',
candidateLong.maxSingleMs,
Math.min(budgets.longTasks.maxSingleMs, singleRegressionLimit),
{
...(absoluteOnly ? {} : { baseline: baselineLong.maxSingleMs }),
hardLimit: budgets.longTasks.maxSingleMs,
},
));
const countRegressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
baselineLong.countP95, budgets.longTasks.maxCountRegressionRatio,
budgets.longTasks.countNoiseAllowance,
);
checks.push(makeCheck(
'longTask.countP95',
candidateLong.countP95,
Math.min(budgets.longTasks.maxCountP95, countRegressionLimit),
{
...(absoluteOnly ? {} : { baseline: baselineLong.countP95 }),
hardLimit: budgets.longTasks.maxCountP95,
},
));
const longRegressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
baselineLong.totalP95Ms, budgets.longTasks.maxTotalRegressionRatio,
budgets.longTasks.noiseAllowanceMs,
);
checks.push(makeCheck(
'longTask.totalP95Ms',
candidateLong.totalP95Ms,
Math.min(budgets.longTasks.maxTotalP95Ms, longRegressionLimit),
{
...(absoluteOnly ? {} : { baseline: baselineLong.totalP95Ms }),
hardLimit: budgets.longTasks.maxTotalP95Ms,
},
));
const candidateHeap = candidate.rows
.map((row) => row.heapGrowthBytes)
.filter(finite)
.map((value) => Math.max(0, value));
const baselineHeap = absoluteOnly ? [] : baseline.rows
.map((row) => row.heapGrowthBytes)
.filter(finite)
.map((value) => Math.max(0, value));
const preciseGc = candidate.rows.every((row) => row.preciseGc === true);
checks.push({
id: 'heap.preciseGc', actual: preciseGc ? 1 : 0, limit: budgets.heap.required ? 1 : 0,
pass: !budgets.heap.required || preciseGc,
});
if (budgets.heap.required && (!candidateHeap.length || (!absoluteOnly && !baselineHeap.length))) {
checks.push({ id: 'heap.available', actual: candidateHeap.length, limit: 1, pass: false });
} else if (candidateHeap.length && (absoluteOnly || baselineHeap.length)) {
const actual = percentile(candidateHeap, 0.95);
const base = absoluteOnly ? null : percentile(baselineHeap, 0.95);
const regressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
base, budgets.heap.maxRegressionRatio, budgets.heap.noiseAllowanceBytes,
);
checks.push(makeCheck(
'heap.growthP95Bytes',
actual,
Math.min(budgets.heap.hardMaxGrowthBytes, regressionLimit),
{
...(absoluteOnly ? {} : { baseline: base }),
hardLimit: budgets.heap.hardMaxGrowthBytes,
},
));
}
for (const [cache, limit] of Object.entries(budgets.cacheEntries)) {
const actual = Math.max(...candidate.rows.map((row) => row.cacheEntries?.[cache] ?? Number.POSITIVE_INFINITY));
checks.push(makeCheck(`cache.entries.${cache}`, actual, limit));
}
for (const [cache, limit] of Object.entries(budgets.cacheGrowth)) {
const actual = Math.max(...candidate.rows.map((row) => row.cacheGrowth?.[cache] ?? Number.POSITIVE_INFINITY));
checks.push(makeCheck(`cache.growth.${cache}`, actual, limit));
}
const renderedDevices = Math.min(...candidate.rows.map((row) => row.renderedDevices ?? -1));
checks.push({
id: 'renderedDevices',
actual: renderedDevices,
limit: budgets.renderedDevices,
pass: renderedDevices === budgets.renderedDevices,
});
const failures = checks.filter((check) => !check.pass);
return {
schema: 1,
profile: budgets.profile,
pass: failures.length === 0,
mode: absoluteOnly ? 'absolute' : 'relative',
candidateFingerprint: candidate.buildFingerprint,
baselineFingerprint: baseline?.buildFingerprint ?? null,
checks,
failures,
};
};
export const performanceSummaryMarkdown = (evaluation) => {
const icon = evaluation.pass ? '✅' : '❌';
const lines = [
`### ${icon} House Plan large-house performance`,
'',
'| Check | Candidate | Limit | Base |',
'|---|---:|---:|---:|',
];
for (const check of evaluation.checks) {
lines.push(`| ${check.pass ? '✅' : '❌'} ${check.id} | ${check.actual} | ${check.limit} | ${check.baseline ?? '—'} |`);
}
return `${lines.join('\n')}\n`;
};
+126
View File
@@ -0,0 +1,126 @@
// #73 stable-release pixel gate. Unlike a loop of Playwright screenshots,
// CDP screencast observes compositor-presented frames and can therefore catch
// the single black/empty frame that motivated the visual-continuity contract.
import { mkdirSync, writeFileSync } from 'node:fs';
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const session = await page.context().newCDPSession(page);
const frames = [];
let stopped = false;
session.on('Page.screencastFrame', (event) => {
if (frames.length < 120) frames.push(event.data);
void session.send('Page.screencastFrameAck', { sessionId: event.sessionId });
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await page.waitForFunction(() => {
const card = window.__card;
const root = card?.shadowRoot || card?.renderRoot;
const host = root?.querySelector('ha-card');
return host?.dataset.continuityState === 'steady' && !!host?.dataset.frameFingerprint;
});
const stage = await page.locator('houseplan-card').evaluate((card) => {
const node = (card.shadowRoot || card.renderRoot).querySelector('.stage');
const rect = node.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
// The reference is an explicitly settled DOM screenshot, never the first
// compositor frame emitted by the screencast itself. A degraded first frame
// must fail against this baseline rather than widening its own tolerance.
const baselinePng = await page.screenshot({ clip: stage });
await session.send('Page.startScreencast', {
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
});
await wait(120);
await page.evaluate(() => window.__card._pageVisibility({
kind: 'visible', token: 73, at: Date.now(), hiddenFor: 20_000, long: true,
}));
// A harmless real paint gives the compositor enough damage to emit the
// candidate sequence even when the fresh server response equals the stale
// frame pixel-for-pixel.
await page.mouse.move(stage.x + stage.width * 0.42, stage.y + stage.height * 0.52);
await wait(850);
await page.mouse.move(2, 2);
await wait(180);
await session.send('Page.stopScreencast');
stopped = true;
const allMetrics = await page.evaluate(async ({ encoded, crop }) => {
const decode = (data) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = `data:image/png;base64,${data}`;
});
const out = [];
for (const data of encoded) {
const image = await decode(data);
const scaleX = image.width / innerWidth;
const scaleY = image.height / innerHeight;
const x = Math.max(0, Math.floor(crop.x * scaleX));
const y = Math.max(0, Math.floor(crop.y * scaleY));
const width = Math.max(1, Math.min(image.width - x, Math.floor(crop.width * scaleX)));
const height = Math.max(1, Math.min(image.height - y, Math.floor(crop.height * scaleY)));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, x, y, width, height, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
let count = 0;
let sum = 0;
let sum2 = 0;
let dark = 0;
for (let row = 0; row < height; row += 4) {
for (let column = 0; column < width; column += 4) {
const offset = (row * width + column) * 4;
const luma = pixels[offset] * 0.2126
+ pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722;
count++;
sum += luma;
sum2 += luma * luma;
if (luma < 18) dark++;
}
}
const mean = sum / Math.max(1, count);
out.push({
mean,
variance: sum2 / Math.max(1, count) - mean * mean,
darkRatio: dark / Math.max(1, count),
});
}
return out;
}, { encoded: [baselinePng.toString('base64'), ...frames], crop: stage });
const baseline = allMetrics[0] || { mean: 0, variance: 0, darkRatio: 1 };
const metrics = allMetrics.slice(1);
const forbidden = metrics.filter((frame) => (
frame.variance < Math.max(12, baseline.variance * 0.08)
|| frame.mean < Math.max(4, baseline.mean * 0.35)
|| (baseline.darkRatio < 0.85 && frame.darkRatio > 0.96)
));
mkdirSync('artifacts/continuity-screencast', { recursive: true });
frames.forEach((data, index) => writeFileSync(
`artifacts/continuity-screencast/frame-${String(index).padStart(3, '0')}.png`,
Buffer.from(data, 'base64'),
));
writeFileSync('artifacts/continuity-screencast/metrics.json', JSON.stringify({
stage, frames: metrics, forbidden: forbidden.length,
}, null, 2));
const result = {
capturedPresentedFrames: frames.length >= 2,
baselineContainsPlanDetail: baseline.variance >= 12 && baseline.mean >= 4,
noEmptyOrBlackPresentedFrame: forbidden.length === 0,
};
for (const [name, value] of Object.entries(result)) check(name, value);
await finish(browser, result);
} finally {
if (!stopped) await session.send('Page.stopScreencast').catch(() => undefined);
await browser.close().catch(() => undefined);
}
+12 -4
View File
@@ -40,16 +40,24 @@ 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 = {},
serveRoot = ROOT,
) {
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) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
if (p === '/') p = '/demo.html';
const f = ROOT + p;
const f = serveRoot + p;
existsSync(f)
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
: r.fulfill({ status: 404, body: 'nf' });
+7 -4
View File
@@ -1,11 +1,14 @@
// Capture: a curtain on the move — the breathing ring (.covermove) around a
// Capture: a curtain on the move — the semantic transition ring around a
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
// the shot is deterministic.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
await page.evaluate(async () => {
const c = window.__card;
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] };
c._serverCfg = { ...c._serverCfg, markers: [
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
] };
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
c.hass = { ...c.hass, states: { ...c.hass.states,
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(400);
const box = await page.evaluate(() => {
const c = window.__card;
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.covermove').getBoundingClientRect();
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-transition').getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const pad = 70;
+4 -3
View File
@@ -1,7 +1,7 @@
// Capture: one curtain in the four states an owner sees — closed, open,
// opening, closing (owner's contract 2026-08-04). The plate is the plain
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
// and the two travelling ones add the breathing .covermove ring (frozen at a
// and the two travelling ones add the breathing transition ring (frozen at a
// visible frame so the shot is deterministic).
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
@@ -22,7 +22,8 @@ await page.evaluate(async (STATES) => {
entities: { ...c.hass.entities, ...entities },
states: { ...c.hass.states, ...states } };
c._serverCfg = { ...c._serverCfg, markers: [
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i,
tap_action: 'cover', display: 'icon_ripple' })),
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
] };
@@ -37,7 +38,7 @@ await page.evaluate(async (STATES) => {
c.requestUpdate();
await c.updateComplete;
const st = document.createElement('style');
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
st.textContent = '.activity-ring.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
}, STATES);
await page.waitForTimeout(500);
+63
View File
@@ -0,0 +1,63 @@
// Скриншоты библиотеки мебели: палитра и расставленная мебель на плане.
import { launch } from './serve.mjs';
const OUT = process.env.HP_SHOT_DIR || '/tmp';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
sr.querySelectorAll('.modetab')[2].click();
await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'furniture';
c._cfgEpoch++; c.requestUpdate();
await c.updateComplete;
sr.querySelector('.furnitem[data-symbol="sofa"]').click();
await c.updateComplete;
});
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/furniture_palette.png` });
await page.evaluate(async () => {
const c = window.__card;
const P = 1000 / 240;
const cm = (v) => (v / 5) * P / 1000; // cm -> normalised
const put = (id, symbol, w, h, cx, cy, angle) => ({
id, kind: 'furniture', symbol,
x: cx / 1000 - cm(w) / 2, y: cy / 1000 - cm(h) / 2,
w: cm(w), h: cm(h), color: '#8d6e63', width: 3,
...(angle ? { angle } : {}),
});
// r1 living 40..550 x 140..580 | r2 kitchen 550..960 x 140..460
// r3 bedroom 550..960 x 460..860 | r4 hall 40..550 x 580..860
c._curSpaceCfg.decor = [
put('f1', 'sofa', 220, 90, 250, 140 + 37.5),
put('f2', 'coffee_table', 110, 60, 250, 300),
put('f3', 'tv', 120, 30, 250, 570),
put('f4', 'armchair', 90, 85, 90, 340, 90),
put('f5', 'table_dining', 140, 80, 700, 300),
put('f6', 'chair', 45, 45, 700, 240),
put('f7', 'chair', 45, 45, 700, 360, 180),
put('f8', 'fridge', 60, 65, 590, 140 + 27, 0),
put('f9', 'stove', 60, 60, 670, 140 + 25),
put('f10', 'kitchen_sink', 80, 60, 760, 140 + 25),
put('f11', 'dishwasher', 60, 60, 840, 140 + 25),
put('f12', 'bed_double', 160, 200, 700, 460 + 42),
put('f13', 'nightstand', 45, 40, 620, 470),
put('f14', 'nightstand', 45, 40, 780, 470),
put('f15', 'wardrobe', 100, 60, 910, 700, 90),
put('f16', 'washer', 60, 60, 120, 860 - 25, 180),
put('f17', 'toilet', 40, 70, 220, 860 - 29, 180),
put('f18', 'bathtub', 170, 75, 400, 860 - 31, 180),
put('f19', 'plant', 40, 40, 500, 620),
put('f20', 'shower', 90, 90, 490, 700),
];
c._decorTool = 'select';
c._decorSel = 'f1';
c._cfgEpoch++; c.requestUpdate();
await c.updateComplete;
});
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/furniture_plan.png` });
await browser.close();
console.log('shots written to', OUT);
+5 -2
View File
@@ -2,6 +2,9 @@ import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
await page.evaluate(async () => {
const c = window.__card;
c._serverCfg.markers = (c._serverCfg.markers || []).filter((m) => m.binding !== 'device:d_motion');
c._serverCfg.markers.push({ id: 'd_motion', binding: 'device:d_motion', display: 'icon_ripple' });
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
// the flash is keyed to a WITNESSED off→on transition (owner's rule
// 2026-08-01: разовая вспышка в момент обнаружения) — cycle through off
const set = async (state) => {
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
await set('on');
// freeze the pulse at a visible frame for a deterministic capture
const st = document.createElement('style');
st.textContent = '.dev.senseflash::after{animation-delay:-0.4s;animation-play-state:paused;}';
st.textContent = '.activity-ring.event i{animation-delay:-0.4s!important;animation-play-state:paused!important;}';
(c.shadowRoot || c.renderRoot).appendChild(st);
});
await page.waitForTimeout(300);
const box = await page.evaluate(() => {
const c = window.__card;
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.senseflash');
const el = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-event');
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
+2 -1
View File
@@ -46,7 +46,8 @@ await page.evaluate(async () => {
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(200);
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot
.querySelector('hp-dialog').shadowRoot.querySelector('.surface'));
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
await browser.close();
console.log('shots written to ' + outDir);
+64
View File
@@ -0,0 +1,64 @@
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
// the value can be picked by looking rather than by arguing. The alphas other
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
// DOM, which is why a rebuild is not needed to compare them.
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
// `0` is the "before" frame: the wedge alone, with no rim at all.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
const setup = async (scene) => {
await page.evaluate(async (sc) => {
const c = window.__card;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain paper, nothing to hide behind
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
c._cfgEpoch++;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 250, elevation: 35 } } } };
c.requestUpdate();
await c.updateComplete;
}, scene);
await page.waitForTimeout(300);
};
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
// scale relative to the current values would collapse to zero the moment the
// 0 frame is taken and never come back.
const setAlpha = async (peak) => {
await page.evaluate((a) => {
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
const stops = [...g.querySelectorAll('stop')];
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
const p0 = g.__base[0] || 1;
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
}
}, peak);
await page.waitForTimeout(120);
};
for (const scene of ['light', 'dark']) {
await setup(scene);
for (const a of ALPHAS) {
await setAlpha(a);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
}
}
await browser.close();
console.log('shots written to ' + outDir);
+77 -26
View File
@@ -7,7 +7,7 @@
* • dragging the body moves the picture and writes plan_x/plan_y;
* • a corner handle scales UNIFORMLY about the opposite corner;
* • live badges state the real size in metres, through cell_cm;
* • position and size land on the grid, Shift steps off it;
* • position and size always land on the grid, including with Shift;
* • the paper follows the ROOMS even here, and the picture is drawn above
* the paper and below the walls;
* • «Вписать всё» still finds a picture that has been dragged away;
@@ -25,16 +25,23 @@ 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(() => {
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
return {
x: sp.plan_x ?? null, y: sp.plan_y ?? null,
sx: sp.plan_scale_x ?? null, sy: sp.plan_scale_y ?? null,
legacy: sp.plan_scale ?? null, angle: sp.plan_angle ?? null,
};
});
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
const imageRect = () => page.evaluate(() => {
@@ -84,20 +91,24 @@ const tool = (t) => page.evaluate((t) => {
await mode('decor');
await settle();
// the frame is on screen the moment the backdrop editor opens…
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
check('frame_hugs_the_picture', await frameRect(), BASE);
// Every Background session starts predictably on Select. The image frame is
// exclusive to its own tool and therefore cannot steal a Select/pan gesture.
check('no_frame_until_picture_tool', await q('.bdframe'), 0);
check('no_handles_until_picture_tool', await q('.bdframe .bdhandle'), 0);
check('no_picture_frame_until_picture_tool', await frameRect(), null);
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
// …and the picture has a tool of its own, offered because f1 HAS a picture
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 7);
check('no_grab_cursor_until_that_tool', await page.evaluate(() =>
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 8);
check('select_tool_armed_on_open', await page.evaluate(() => window.__card._decorTool), 'select');
check('no_grab_cursor_under_select', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
await tool('backdrop');
await settle();
check('grab_cursor', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
check('four_corners_plus_rotate_under_picture_tool', await q('.bdframe .bdhandle'), 5);
check('frame_hugs_the_picture_under_picture_tool', await frameRect(), BASE);
check('grab_cursor_under_picture_tool', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
// a drawing tool owns the drag instead — no frame competing with it
await tool('rect');
await settle();
@@ -137,7 +148,8 @@ check('badge_gone_after_release', (await badges()).length, 0);
// corner lands on a lattice node within one step of where the finger let go
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
check('scale_untouched_by_a_move', cfg.k, 1);
check('scale_untouched_by_a_move', near(cfg.sx, 1) && near(cfg.sy, 1), true);
check('legacy_scale_not_written', cfg.legacy, null);
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
@@ -150,7 +162,7 @@ check('badge_gone_after_release', (await badges()).length, 0);
check('the_plan_geometry_never_moved', await page.evaluate(() =>
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
// ---------- 3) Shift steps off the grid ----------------------------------
// ---------- 3) Shift cannot step off the grid -----------------------------
await restore();
await settle();
{
@@ -165,12 +177,8 @@ await settle();
await page.keyboard.up('Shift');
await settle();
const im = await imageRect();
// Shift is not "a different snap", it is NO snap: the corner is wherever the
// finger left it, to the pixel — and a pixel is a fraction of a grid step,
// so at least one axis is bound to sit between the nodes.
check('shift_lands_where_the_finger_did',
Math.abs(im.x - 102) <= 1 && Math.abs(im.y - 152) <= 1, true);
check('shift_is_off_the_lattice', onGrid(im.x) && onGrid(im.y), false);
check('shift_still_lands_on_the_nearest_node', [im.x, im.y], [100, 150]);
check('shift_stays_on_the_lattice', onGrid(im.x) && onGrid(im.y), true);
}
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
@@ -197,9 +205,10 @@ await settle();
check('scaled_down', im.w < BASE.w, true);
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
const cfg = await spaceCfg();
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
check('scaled_width_is_on_the_grid',
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
check('axis_scales_saved', near(cfg.sx, im.w / BASE.w, 1e-5)
&& near(cfg.sy, im.h / BASE.h, 1e-5), true);
check('proportional_scale_keeps_axes_equal', near(cfg.sx, cfg.sy, 1e-6), true);
check('scaled_width_is_on_the_grid', onGrid(im.x + im.w), true);
// …and the badge states the picture's REAL size through cell_cm
check('badge_matches_the_new_size', await page.evaluate(() => {
const c = window.__card;
@@ -213,12 +222,15 @@ await settle();
}
// ---------- 5) the reset button ------------------------------------------
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
check('reset_button_offered_once_moved', await q('.editor-secondary .bdreset'), 1);
check('reset_button_absent_from_primary_bar', await q('.decorbar .bdreset'), 0);
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
await settle();
check('reset_puts_the_picture_back', await imageRect(), BASE);
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
check('reset_clears_the_fields', await spaceCfg(), {
x: null, y: null, sx: null, sy: null, legacy: null, angle: null,
});
check('reset_button_gone_again', await q('.editor-secondary .bdreset'), 0);
// ---------- 5b) the SELECT tool still pans right over the picture ---------
// The body of the picture is most of the screen; claiming it outside its own
@@ -263,6 +275,45 @@ check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
// ---------- 6b) editors keep a WHITE sheet under the grid with a backdrop --
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 {
mode,
stageWhite: getComputedStyle(stage).backgroundColor === 'rgb(255, 255, 255)',
paperWhite: !paper || getComputedStyle(paper).fill === 'rgb(255, 255, 255)',
hasBg: !!c._spaceModel().bg,
noNoplan: !stage.classList.contains('noplan'),
};
};
const plan = await probe('plan');
const devices = await probe('devices');
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)'
&& !viewStage.classList.contains('noplan');
return { plan, devices, decor, viewForcedWhite, hasBg: plan.hasBg };
});
check('editors_have_backdrop', editorWhite.hasBg, true);
check('plan_editor_stage_white_with_backdrop', editorWhite.plan.stageWhite && editorWhite.plan.noNoplan, true);
check('plan_editor_paper_white_with_backdrop', editorWhite.plan.paperWhite, true);
check('devices_editor_stage_white_with_backdrop', editorWhite.devices.stageWhite, true);
check('decor_editor_stage_white_with_backdrop', editorWhite.decor.stageWhite, true);
check('view_with_backdrop_is_not_forced_white', editorWhite.viewForcedWhite, false);
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
await page.evaluate(() => {
const c = window.__card;

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