Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5814cfe0c2 | ||
|
|
d839eb88eb | ||
|
|
bf5a040508 | ||
|
|
faa1f4ea9a | ||
|
|
caf3c44ad9 | ||
|
|
08b19363fd | ||
|
|
d5e6c5cff0 | ||
|
|
c85dbaf4cb | ||
|
|
b1deb0beab | ||
|
|
9590011ddb | ||
|
|
ddbd3288fe | ||
|
|
e177c14603 | ||
|
|
164f7cf76a | ||
|
|
2219700d63 | ||
|
|
854a6944a0 | ||
|
|
2a8302f4d6 | ||
|
|
f1537b2108 | ||
|
|
5e1315f61d | ||
|
|
4e29db1afb | ||
|
|
953063b984 | ||
|
|
d48d220a8c | ||
|
|
1c949ae49d | ||
|
|
9956a6cfe6 | ||
|
|
fe5f5b6a24 | ||
|
|
6db9eb0a66 | ||
|
|
3028122016 | ||
|
|
29fb9deb43 | ||
|
|
6a9122f41f | ||
|
|
25f43da1bd | ||
|
|
e7aca9c678 | ||
|
|
5ca4c7e5c5 | ||
|
|
e0f6746d7f | ||
|
|
d2bec266ed | ||
|
|
4868cc0786 | ||
|
|
e188f9d609 | ||
|
|
3ad4e9d803 | ||
|
|
7333223a55 | ||
|
|
de5e8129a1 | ||
|
|
b44d2fb958 | ||
|
|
ee87d3d00e | ||
|
|
a7d956a072 | ||
|
|
309bd59358 | ||
|
|
31cd4142ac | ||
|
|
cb2b064c6a | ||
|
|
e6579f1e55 | ||
|
|
1b37427f1d | ||
|
|
4c260f0ea0 | ||
|
|
159f4f43c0 | ||
|
|
99f7c3a4a9 | ||
|
|
f4e5ce6822 | ||
|
|
0ee80a6a52 | ||
|
|
1397a71f84 | ||
|
|
46f20fe4e1 | ||
|
|
1108b2bc14 | ||
|
|
2fd46493db | ||
|
|
df233905c5 | ||
|
|
85263d520f | ||
|
|
02ae7f9588 | ||
|
|
1dc03e1fb1 | ||
|
|
d21d532f10 | ||
|
|
50ba443492 | ||
|
|
aa01eaef01 | ||
|
|
93b60c5b8e | ||
|
|
eb17396006 | ||
|
|
0af50a74ae | ||
|
|
232c4807fd | ||
|
|
285d569102 | ||
|
|
22b588e116 | ||
|
|
1da1aba625 | ||
|
|
de53d530fa | ||
|
|
ade8daab16 | ||
|
|
05a2a838d6 | ||
|
|
f4ad843619 | ||
|
|
a7d58f0552 | ||
|
|
fd72330549 | ||
|
|
2c947f4f7a | ||
|
|
693601a8e0 | ||
|
|
c7fa9542ba | ||
|
|
478d2042b2 | ||
|
|
47ab60cddd | ||
|
|
79142d9334 | ||
|
|
bb4d4e1f6e | ||
|
|
b6675dc3a4 | ||
|
|
53e1c7163d | ||
|
|
ca66791440 | ||
|
|
db19753bbf | ||
|
|
9be44dab1e | ||
|
|
ba88782ce1 | ||
|
|
a20b73621f | ||
|
|
b70153769a | ||
|
|
5e5c06f126 | ||
|
|
c4a80bcb9f | ||
|
|
9ccc3831d1 | ||
|
|
33a960031e | ||
|
|
3a6a819dec | ||
|
|
c024c6d75a | ||
|
|
a8bb145ffb | ||
|
|
1e0295a370 | ||
|
|
c74ce39eb3 | ||
|
|
ee0ee9a1d3 | ||
|
|
a8d40e4d99 | ||
|
|
63a8274623 | ||
|
|
1a75ee1ddf | ||
|
|
e81e841ed6 | ||
|
|
3e23ff58c3 | ||
|
|
21786ed5a1 | ||
|
|
6414873fb0 | ||
|
|
941d2709fd | ||
|
|
9bcfebe8f4 | ||
|
|
8b439ea0f3 | ||
|
|
b7d5cb8a8f | ||
|
|
84f8bcf0e6 | ||
|
|
10927ffaed | ||
|
|
6ff79106b8 | ||
|
|
f49ac613b5 | ||
|
|
ec58b0602a | ||
|
|
65070c0520 | ||
|
|
fa32afa9f3 | ||
|
|
adaed9be7c | ||
|
|
b508c20ad3 | ||
|
|
ca07579b63 | ||
|
|
29dae45e2a | ||
|
|
88008961e3 | ||
|
|
6d16f69f38 | ||
|
|
92ea8fa03c | ||
|
|
6743b6efc4 | ||
|
|
c9775141c9 | ||
|
|
9c224e7606 | ||
|
|
93d97e1a83 | ||
|
|
fad2e87ab5 | ||
|
|
909bb6fbc7 | ||
|
|
4e3d8f1d53 | ||
|
|
a1f7fb4161 | ||
|
|
257a71123a | ||
|
|
69c5a4c41d | ||
|
|
77327c07d4 | ||
|
|
18b8589438 | ||
|
|
9870b2fb98 | ||
|
|
7de7cfb8c4 | ||
|
|
18ac9b459f | ||
|
|
4e355dcbd2 | ||
|
|
f9b612a389 | ||
|
|
d27864e90e | ||
|
|
f3bc3278e5 | ||
|
|
75524d9d85 | ||
|
|
089c5ef462 | ||
|
|
8dbc7db0b2 | ||
|
|
23daa28cf4 | ||
|
|
d063453670 | ||
|
|
f83577afa7 | ||
|
|
eddc8b41db | ||
|
|
1eabfeeee8 | ||
|
|
11186371a9 | ||
|
|
692bb91e57 | ||
|
|
893ccff94f | ||
|
|
d2faa070b5 | ||
|
|
f5c56d2f8e | ||
|
|
0142d37bec | ||
|
|
40abbccbf0 | ||
|
|
831e694f83 | ||
|
|
b350893448 | ||
|
|
fc8b6f85a4 | ||
|
|
6fc60e3a50 | ||
|
|
dcc0b156af | ||
|
|
9d56708f2d | ||
|
|
6d8ec7b92a | ||
|
|
a9b999b3e0 | ||
|
|
f36d2cddad | ||
|
|
1770ca2960 | ||
|
|
764e023996 | ||
|
|
ce6a11f53f | ||
|
|
3e976562ff | ||
|
|
f56bceef27 | ||
|
|
96a01e1380 | ||
|
|
08a9cc7d27 | ||
|
|
1be79a1427 | ||
|
|
d7c20ff6a5 | ||
|
|
fa59767c69 | ||
|
|
60d6167ecd | ||
|
|
110fabd038 | ||
|
|
3456706ef6 | ||
|
|
1310c84a32 | ||
|
|
94563d2a0c | ||
|
|
5615afa33b | ||
|
|
4083e14247 | ||
|
|
518d72fb74 | ||
|
|
fc95a1f09b | ||
|
|
4e736d49a3 | ||
|
|
aa3c379540 | ||
|
|
fa15598e67 | ||
|
|
9eec856d50 | ||
|
|
0bb9282edd | ||
|
|
09f4dc4115 | ||
|
|
2551b4ea0e | ||
|
|
694e1e9a3b | ||
|
|
996a7442ec | ||
|
|
098a147f87 | ||
|
|
14f7c06bf4 | ||
|
|
9f36b39379 | ||
|
|
9da96abb05 | ||
|
|
df65d25348 | ||
|
|
8db6b2673f | ||
|
|
c9030af900 | ||
|
|
5392dadeaa | ||
|
|
aa53b33dd6 | ||
|
|
a8ce6020f4 | ||
|
|
9282c28830 | ||
|
|
8c5d5ba5c5 | ||
|
|
6c90e03427 | ||
|
|
9b180c5917 | ||
|
|
3084472c75 | ||
|
|
c00048611e | ||
|
|
f5e6c0318d | ||
|
|
e1e730560d | ||
|
|
94b298962a | ||
|
|
f7fe63776a | ||
|
|
01bc4f9711 | ||
|
|
85491d0fea | ||
|
|
d37a67c29f | ||
|
|
a66272c6f4 | ||
|
|
f4af2fe508 | ||
|
|
8e07e3c958 | ||
|
|
9868f1035f | ||
|
|
f2c9b07cc1 | ||
|
|
2c7a2f849d | ||
|
|
33e71ca96c | ||
|
|
7128ab504d | ||
|
|
f953a3c286 | ||
|
|
ef270d11b7 | ||
|
|
dc24390222 | ||
|
|
254354bf56 | ||
|
|
c9a60a110d | ||
|
|
75279308c1 | ||
|
|
b7ae3e7adf | ||
|
|
379fb68db2 | ||
|
|
96a70495d3 | ||
|
|
d3db9e30e6 | ||
|
|
2e731debd9 | ||
|
|
a49b5e6d2e | ||
|
|
4418312b0b | ||
|
|
3f719cc32a | ||
|
|
260615a63f | ||
|
|
e4e300adaa | ||
|
|
96d387ff1d | ||
|
|
8b531db3f5 | ||
|
|
68aa1f04ba | ||
|
|
3d41fe16b8 | ||
|
|
ac734688d4 | ||
|
|
2e2d353b04 | ||
|
|
f8c8cb4eeb | ||
|
|
c749b52a0d | ||
|
|
15e5dd7392 | ||
|
|
f1b501a956 | ||
|
|
5d2dbb1009 | ||
|
|
40cb0302e3 | ||
|
|
14cc4df4bd | ||
|
|
ead56dd9b6 | ||
|
|
018b37940f | ||
|
|
ebeaa5c0c6 | ||
|
|
02ba18dc7b | ||
|
|
715a93ec61 | ||
|
|
09b0ba41a5 | ||
|
|
0467cee98a | ||
|
|
c0653dfc73 | ||
|
|
946e7543ad | ||
|
|
641c61dc19 | ||
|
|
ae9168f6ec | ||
|
|
45c863138a | ||
|
|
e04ef2f2e6 | ||
|
|
a841d17543 | ||
|
|
e63b7882a6 | ||
|
|
c1e3cdb768 | ||
|
|
41b20e1901 | ||
|
|
49b0cb4e05 | ||
|
|
0fd0ba408d | ||
|
|
5c7d1ca8bb | ||
|
|
b4bb732736 | ||
|
|
10d4084d17 | ||
|
|
19e19b5c5f | ||
|
|
88dc0d1de7 | ||
|
|
ad7e946e75 | ||
|
|
9191a94701 | ||
|
|
eea669fa12 | ||
|
|
bd9fabfe99 | ||
|
|
b03ef70794 | ||
|
|
1d6ca968e8 | ||
|
|
82eed100b2 | ||
|
|
f27c91ade1 | ||
|
|
4b4df4b3a2 | ||
|
|
df9e158efb | ||
|
|
0522413c48 | ||
|
|
9bfef453db | ||
|
|
9c92bcdf2f | ||
|
|
8adb262410 | ||
|
|
3811a1ca73 | ||
|
|
8895354c4e | ||
|
|
ea41bec86b | ||
|
|
05f162434b | ||
|
|
0e14139fe2 | ||
|
|
6b9909768f | ||
|
|
20c7b55a87 | ||
|
|
3a89966e4b | ||
|
|
8330b48cf3 | ||
|
|
81ea5c0f3c | ||
|
|
df2ed0c3e1 | ||
|
|
d7a1b344e4 | ||
|
|
7eaf513c9e | ||
|
|
1e20279adf | ||
|
|
031e5439eb | ||
|
|
2bf9e44178 | ||
|
|
c2eaf50118 | ||
|
|
ac082569d7 | ||
|
|
a90316c9f3 | ||
|
|
912613bbf8 | ||
|
|
935a519c32 | ||
|
|
dd930b64f7 | ||
|
|
f235c8afc5 | ||
|
|
5e6f9c407c | ||
|
|
849117eb27 | ||
|
|
37b39e7a1e | ||
|
|
d4f2d81a2f | ||
|
|
bcb546dbc7 | ||
|
|
ccf36e283e | ||
|
|
821bdfbd9a | ||
|
|
22992b256f | ||
|
|
d273a90db8 | ||
|
|
48d47a46af | ||
|
|
0d712b9069 | ||
|
|
dfa2a8badc | ||
|
|
2dfab3565b | ||
|
|
6ee09d3750 | ||
|
|
29c5a89aad | ||
|
|
71f44fd528 | ||
|
|
c8722642b7 | ||
|
|
0a7b9a627f | ||
|
|
961d6afe67 | ||
|
|
b9fecbdd95 | ||
|
|
9fd36bd710 | ||
|
|
a9346c9c14 | ||
|
|
794b02b84f |
@@ -0,0 +1,14 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.zip binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Telegram chat (@ha_houseplan)
|
||||
url: https://t.me/ha_houseplan
|
||||
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
|
||||
- name: 💡 GitHub discussions
|
||||
url: https://github.com/Matysh/houseplan-card/discussions
|
||||
about: Longer-form ideas and show-and-tell.
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Announce release
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Fires on every published release, prereleases included: the workflow file
|
||||
# lives at the TAGGED commit, so it works for beta tags cut from dev as soon
|
||||
# as this file is on dev, and for stable tags once it reaches main.
|
||||
# workflow_dispatch exists purely as a connectivity test button.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
permissions: {}
|
||||
jobs:
|
||||
telegram:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send to Telegram
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
NAME: ${{ github.event.release.name }}
|
||||
URL: ${{ github.event.release.html_url }}
|
||||
PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
KIND=$([ "$PRE" = "true" ] && echo "🧪 Пре-релиз" || echo "🏠 Релиз")
|
||||
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
|
||||
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
|
||||
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
|
||||
fi
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Attach HACS zip to release
|
||||
# hacs.json declares zip_release + filename=houseplan.zip, so every release
|
||||
# (prereleases included) must carry the asset — HACS installs from it and
|
||||
# GitHub's public download counter becomes a free per-version install metric
|
||||
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
|
||||
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
|
||||
# dev, stable tags once it reaches main.
|
||||
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
|
||||
# once for the latest stable after the hacs.json change reaches main).
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing release tag to attach the zip to"
|
||||
required: true
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
zip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
env:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
|
||||
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
|
||||
- name: Sanity check
|
||||
run: unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
- name: Upload asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
|
||||
@@ -4,11 +4,43 @@ 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"
|
||||
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
|
||||
@@ -17,3 +49,29 @@ jobs:
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Validate
|
||||
on:
|
||||
push:
|
||||
# The branch commit is the release-gate authority. An annotated tag points
|
||||
# to the same SHA and must not duplicate every expensive browser/perf job.
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
@@ -32,12 +36,165 @@ 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 }
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a FRESH bundle for the smokes
|
||||
# Never depend on a previous job's artefact; build the exact source
|
||||
# under test again before the browser suite.
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
run: |
|
||||
fail=0
|
||||
mkdir -p /tmp/smoke-logs
|
||||
for f in demo/smoke_*.mjs; do
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
tail -20 "/tmp/smoke-logs/$name.log"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
golden:
|
||||
# Separate deterministic pixel layer. Before the first reviewed baseline
|
||||
# set it captures candidates; once baselines exist the same job becomes a
|
||||
# blocking comparison and uploads actual/diff/report on failure.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
|
||||
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:verify
|
||||
else
|
||||
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:capture
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
|
||||
performance:
|
||||
# Candidate and base SHA run sequentially on the same hosted runner with
|
||||
# one Playwright/Chromium installation. This avoids turning a developer
|
||||
# laptop or cross-runner variance into a timing budget.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_BASE_SHA" ]; then
|
||||
sha="$PR_BASE_SHA"
|
||||
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
|
||||
sha="$PUSH_BEFORE_SHA"
|
||||
else
|
||||
sha="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
# The first main promotion after HP-PERF-01 can legitimately point
|
||||
# at an older stable commit which predates the benchmark's embedded
|
||||
# bundle fingerprint. Comparing against that legacy tree would fail
|
||||
# the freshness guard before any measurements are taken. Fall back
|
||||
# to the candidate's direct parent, which is the last dev revision
|
||||
# included in the promotion and already has the current harness.
|
||||
git fetch --no-tags --depth=1 origin "$sha"
|
||||
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
|
||||
echo "comparison $sha predates HP-PERF-01; using HEAD^" >&2
|
||||
sha="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- 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
|
||||
- name: Enforce 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
|
||||
- name: Upload performance reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: large-house-performance
|
||||
path: artifacts/performance
|
||||
|
||||
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
|
||||
|
||||
@@ -4,3 +4,5 @@ test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# AGENTS.md
|
||||
|
||||
House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
|
||||
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.
|
||||
|
||||
## 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, 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.
|
||||
@@ -3,6 +3,29 @@
|
||||
Thanks for your interest! The project is one HACS package: a storage **integration**
|
||||
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
|
||||
|
||||
## Changelog
|
||||
|
||||
User-visible changes go into **both** changelogs in the same commit:
|
||||
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
|
||||
older than v1.42.0 exist only in the English file — no need to backfill them.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
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
|
||||
|
||||
@@ -1,10 +1,71 @@
|
||||
# 🏠 House Plan — an interactive house plan for Home Assistant
|
||||
# 🏠 House Plan — interactive floor plan card for Home Assistant
|
||||
|
||||
**A live map of your home right inside Home Assistant: floors, rooms and devices on a real floor plan — with live states, temperature and signal strength. Everything is configured with the mouse, without a single line of YAML.**
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||

|
||||
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
|
||||
a floor plan, outline the rooms with your mouse — and every smart device appears
|
||||
in its real place: live states, tap-to-toggle lights, temperature and humidity per
|
||||
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
|
||||
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
|
||||
right on your Lovelace dashboard.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md)
|
||||
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
|
||||
> on phones and tablets. The editors are designed primarily for a desktop
|
||||
> browser with a mouse and keyboard; individual editing operations on touch
|
||||
> devices may be awkward, limited, or unavailable.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Try it live — no install needed
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
|
||||
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
|
||||
> toggle lights, open the editors, break things. The stand resets itself to a
|
||||
> pristine state every hour.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Feature highlights**
|
||||
|
||||
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
|
||||
past: draw and place devices anywhere, pan at any zoom, zoom out to see
|
||||
everything, and let one tap fit the whole plan back on screen.
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
|
||||
place and pull a corner to size it, with its real size in metres shown as
|
||||
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.
|
||||
- ☀️ **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
|
||||
from a weather entity.
|
||||
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
|
||||
stops a cover, and the icon itself morphs between open and closed while a
|
||||
soft ring pulses as it travels.
|
||||
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
|
||||
comfort-range temperature fills, per-room signal heatmap.
|
||||
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
|
||||
explicit button, never an accidental tap.
|
||||
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
|
||||
auto-carousel, per-screen icon sizes.
|
||||
- 🤖 **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.
|
||||
- 🔔 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -28,16 +89,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:
|
||||
|
||||
@@ -45,10 +117,39 @@ Key advantages in short:
|
||||
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
|
||||
- **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, 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.
|
||||
|
||||
---
|
||||
|
||||
## Wall tablet / TV (kiosk mode)
|
||||
|
||||
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
|
||||
(or tick "Wall device (kiosk) mode" in the card editor):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
|
||||
```
|
||||
|
||||
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
|
||||
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
|
||||
tune icon and text sizes for THIS screen (saved per device). To hide Home
|
||||
Assistant's own header use the companion app's kiosk settings or the
|
||||
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run HACS:
|
||||
@@ -109,7 +210,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and **upload a background** — a floor-plan image in SVG, PNG or JPG format. Both fields are required: without a plan the "Save" button stays disabled.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -133,15 +234,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
|
||||
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||
|
||||
|
||||
### Doors, windows and locks
|
||||
### Doors, windows, gates and locks
|
||||
|
||||
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
|
||||
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
|
||||
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
|
||||
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
|
||||
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
|
||||
|
||||
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
|
||||
draws itself in as the real door opens; a window opens its two casements. While open, the moving
|
||||
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
|
||||
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
|
||||
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
|
||||
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
|
||||
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
|
||||
on the opening shows a status card with both states instead.
|
||||
|
||||
@@ -152,7 +255,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan — service records, bridges and duplicates are filtered out. If you need to see **absolutely all** devices of the area, enable the **👁 "Show all devices"** button in the header.
|
||||
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.
|
||||
|
||||
@@ -201,6 +304,10 @@ turned the way it is mounted.
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
@@ -212,11 +319,29 @@ turned the way it is mounted.
|
||||
|
||||
---
|
||||
|
||||
## Getting help & sharing your plan
|
||||
|
||||
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
|
||||
setup help, feature ideas, and screenshots of your plans. The fastest way to
|
||||
reach the author and other users.
|
||||
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
|
||||
reports and feature requests (please attach your House Plan version).
|
||||
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
|
||||
longer-form ideas.
|
||||
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
|
||||
([на русском](docs/CHANGELOG.ru.md)).
|
||||
|
||||
When reporting a problem, the version number helps a lot: it is shown in the
|
||||
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
|
||||
Services → House Plan**.
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**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 by curation (bridges, service records, duplicates) — enable the **👁 "Show all devices"** button in the header.
|
||||
**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.
|
||||
|
||||
|
||||
@@ -1,13 +1,73 @@
|
||||
# 🏠 House Plan — интерактивный план дома для Home Assistant
|
||||
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
|
||||
|
||||
**Живая карта вашего дома прямо в Home Assistant: этажи, комнаты и устройства на настоящем плане — с реальными состояниями, температурой и уровнем сигнала. Всё настраивается мышкой, без единой строчки YAML.**
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||

|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
|
||||
|
||||
🇬🇧 [English documentation](README.md)
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
**Главное**
|
||||
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Что это и зачем
|
||||
|
||||
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
|
||||
@@ -28,16 +88,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
|
||||
на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
@@ -45,10 +115,36 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
|
||||
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
|
||||
Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
|
||||
## Настенный планшет / ТВ (киоск-режим)
|
||||
|
||||
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
|
||||
галочка «Режим настенного устройства» в редакторе карточки):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
|
||||
```
|
||||
|
||||
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
|
||||
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
|
||||
настройка размеров значков и текста для ЭТОГО экрана (хранится на
|
||||
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
|
||||
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть HACS:
|
||||
@@ -111,7 +207,7 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
В диалоге задайте **название** (например, «1 этаж») и **загрузите подложку** — картинку плана этажа в формате SVG, PNG или JPG. Оба поля обязательны: без плана кнопка «Сохранить» неактивна.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
|
||||
|
||||

|
||||
|
||||
@@ -135,15 +231,15 @@ title: План дома
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Двери, окна и замки
|
||||
### Двери, окна, ворота и замки
|
||||
|
||||
В режиме разметки инструмент **«Проём»** ставит двери и окна: кликните рядом со стеной — проём
|
||||
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
|
||||
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
|
||||
окно 120 см), датчик открытия и — для двери — **замок**.
|
||||
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
|
||||
|
||||
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
|
||||
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
|
||||
подвижные части подсвечены акцентным цветом. У двери с замком рядом отображается замочек —
|
||||
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
|
||||
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
|
||||
переключить — клик по проёму показывает карточку с обоими статусами.
|
||||
|
||||
@@ -154,7 +250,7 @@ title: План дома
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства — служебные записи, мосты и дубликаты отфильтрованы. Если нужно видеть **вообще все** устройства зоны, включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||
@@ -205,6 +301,10 @@ title: План дома
|
||||
|
||||

|
||||
|
||||
### Свои стили через 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)**.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
@@ -216,11 +316,28 @@ title: План дома
|
||||
|
||||
---
|
||||
|
||||
## Помощь и обмен опытом
|
||||
|
||||
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
|
||||
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
|
||||
связаться с автором и другими пользователями.
|
||||
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
быстрее.
|
||||
|
||||
---
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто курированием (мосты, служебные, дубликаты) — включите в шапке кнопку **👁 «Показать все устройства»**.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -18,6 +20,8 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -28,9 +32,10 @@ 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 HouseplanUploadView
|
||||
from .http_api import HouseplanContentView, HouseplanUploadView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
return True
|
||||
|
||||
|
||||
@@ -45,6 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
|
||||
entry.runtime_data = data
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
from .trails import TrailRecorder
|
||||
recorder = TrailRecorder(hass, data)
|
||||
await recorder.async_setup()
|
||||
# setdefault: the CI harness sets entries up without async_setup, so
|
||||
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
|
||||
# downstream WS test with unknown_error
|
||||
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
|
||||
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
@@ -61,14 +75,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
static_paths.append(StaticPathConfig(PLANS_URL, str(plans_path), cache_headers=True))
|
||||
static_paths.append(StaticPathConfig(FILES_URL, str(files_path), cache_headers=True))
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the card bundle stays public — Lovelace resources must be.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
hass.http.register_static_path(PLANS_URL, str(plans_path), cache_headers=True)
|
||||
hass.http.register_static_path(FILES_URL, str(files_path), cache_headers=True)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
@@ -95,11 +109,146 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
module_url, module_url,
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
# — the box is padded and the numbers re-expressed against it.
|
||||
# The two stores are written independently, and the lock is no transaction:
|
||||
# a crash between the writes used to leave the config in square coordinates
|
||||
# with the layout still in the old ones — permanently, because the config
|
||||
# write had already deleted the `aspect` fields the layout half needed
|
||||
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
|
||||
# and each half carries its own trigger with its own write: the config half
|
||||
# removes `aspect`, the layout half removes the saved intent. Whatever
|
||||
# half is missing after a crash, the next start finishes exactly it.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config")
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
layout = lay_stored.get("layout") or {}
|
||||
lay_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "geom_pending")
|
||||
}
|
||||
pending = {
|
||||
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
|
||||
}
|
||||
merged = {**pending, **pending_from_config(cfg)}
|
||||
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(
|
||||
{**lay_meta, "layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
)
|
||||
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({**lay_meta, "layout": layout, "rev": lay_rev + 1})
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
# only once both halves are durable — a client refetching on this
|
||||
# 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
|
||||
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))
|
||||
if stored.get("config") != target_config:
|
||||
config_rev += 1
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_rev,
|
||||
})
|
||||
if lay_stored.get("layout", {}) != target_layout:
|
||||
layout_rev += 1
|
||||
layout_meta = {
|
||||
k: v for k, v in lay_stored.items()
|
||||
if k not in ("layout", "rev", "optimize_pending", "optimize_backup")
|
||||
}
|
||||
if not pending.get("clear_backup") and "optimize_backup" in lay_stored:
|
||||
layout_meta["optimize_backup"] = lay_stored["optimize_backup"]
|
||||
await data.store.async_save({
|
||||
**layout_meta,
|
||||
"layout": target_layout,
|
||||
"rev": layout_rev,
|
||||
})
|
||||
optimize_revs = (config_rev, layout_rev)
|
||||
_LOGGER.warning("House Plan: completed an interrupted plan optimization")
|
||||
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]})
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
#
|
||||
# A commit collects what that commit superseded, which is the right rule for
|
||||
# a commit — but it only ever runs when somebody saves. Cancel a dialog
|
||||
# after the file has already uploaded, lose the connection after the upload
|
||||
# succeeded, or call the API directly, and the file is unreferenced with no
|
||||
# future write to notice it (HP-1461-01). The earlier version of this sweep
|
||||
# only removed streaming temporaries, which are a different, narrower case.
|
||||
#
|
||||
# Passing the CURRENT configuration as both sides means "nothing was
|
||||
# superseded": every referenced file is preserved and only unreferenced ones
|
||||
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
|
||||
# so it cannot decide from a snapshot that a commit is about to replace.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
# `data` from the closure, NOT get_data(hass): during
|
||||
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
|
||||
# async_loaded_entries() does not list it and the lookup returned
|
||||
# None. The startup pass then silently degraded to removing
|
||||
# streaming temporaries only, and the real collection waited a full
|
||||
# day — restarting more often than that meant it never ran at all
|
||||
# (HP-1462-01). The callback is unregistered with the entry, so
|
||||
# closing over its runtime data matches the lifecycle exactly.
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
cfg = stored.get("config") or {}
|
||||
|
||||
def _collect() -> int:
|
||||
n = sweep_upload_temps(files_dir)
|
||||
# same config on both sides: nothing is superseded, so this
|
||||
# only ever collects what the shared rules call abandoned
|
||||
n += collect_attachments(files_dir, cfg, cfg)
|
||||
n += collect_plans(plans_dir, cfg, cfg)
|
||||
return n
|
||||
|
||||
n = await hass.async_add_executor_job(_collect)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
|
||||
|
||||
data.sweep = _sweep
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
"""Unload the entry.
|
||||
|
||||
WS commands and the HTTP view are global (async_setup) and stay registered —
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Single source of truth for the write-authorization policy.
|
||||
|
||||
The WS and HTTP paths used to duplicate this decision and drifted apart: the
|
||||
WS copy was fixed to fail closed while the upload view still failed OPEN when
|
||||
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
|
||||
one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
from .store import get_entry
|
||||
|
||||
|
||||
def may_write(hass: HomeAssistant, user) -> bool:
|
||||
"""True when `user` may modify House Plan data.
|
||||
|
||||
Fails CLOSED: when the entry cannot be read — during a reload, or while the
|
||||
integration is disabled — the policy is unknown, and "unknown" is not the
|
||||
same as "permissive": only admins are allowed through.
|
||||
"""
|
||||
is_admin = bool(getattr(user, "is_admin", False))
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
# 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
|
||||
@@ -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}),
|
||||
|
||||
@@ -9,9 +9,43 @@ FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
|
||||
CONTENT_URL = "/api/houseplan/content"
|
||||
|
||||
# How many paths one houseplan/content/sign call may carry. The card batches to
|
||||
# the same number; a client that sends more used to get a partial answer with no
|
||||
# way to tell which paths were dropped (review R2-2).
|
||||
MAX_SIGN_PATHS = 200
|
||||
|
||||
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
|
||||
# stopped at the door instead. These bound the whole store, not one request: by
|
||||
# default any authenticated user may upload, and a per-request cap of 8/50 MB
|
||||
# says nothing about how many requests there are (HP-1470-01).
|
||||
MAX_PLANS_BYTES = 256 * 1024 * 1024
|
||||
MAX_PLANS_FILES = 200
|
||||
# How many the picker asks for at once — newest first.
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# An uploaded plan that no accepted configuration references is collected only
|
||||
# once it is this old. Age is a race guard, not a policy: a plan uploaded
|
||||
# seconds ago may belong to another client's transaction that has not written
|
||||
# its configuration yet (review R3-1).
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
|
||||
# Kept for compatibility with anything reading it; the collectors no longer use
|
||||
# a long grace at all. Every attempt to age files out ended badly — first by
|
||||
# deleting detached plans, then by racing the save that was about to reference a
|
||||
# retried upload. What is left is deliberately simple: files go when the user's
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.26.0"
|
||||
VERSION = "1.60.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", [])
|
||||
],
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""One-time migration to a square canvas — pure, so it can be tested alone.
|
||||
|
||||
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
|
||||
it: x by the width, y by the HEIGHT. Making every canvas square without touching
|
||||
the numbers would stretch every plan vertically.
|
||||
|
||||
Nothing about the drawing changes here. The canvas is padded to a square —
|
||||
top and bottom for a wide plan, left and right for a tall one — and the
|
||||
coordinates are re-expressed against that larger box. In render units it is a
|
||||
uniform scale plus an offset, so angles, room proportions and relative positions
|
||||
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
|
||||
tall plan the width grew, so the same wall would otherwise measure less.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transform_for(aspect: float) -> tuple[float, float, float, float]:
|
||||
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
|
||||
|
||||
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
|
||||
axis's factor; both are the same uniform scale in RENDER units, which is
|
||||
why angles are preserved.
|
||||
"""
|
||||
a = float(aspect)
|
||||
if not a or a <= 0:
|
||||
a = 1.0
|
||||
k = min(1.0, a) # how much the old box shrinks inside the square
|
||||
kx = k # x was normalised by the width
|
||||
ky = k / a # y was normalised by the height (= width / aspect)
|
||||
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
|
||||
|
||||
|
||||
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
|
||||
if isinstance(p, (list, tuple)) and len(p) >= 2:
|
||||
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
|
||||
return p
|
||||
|
||||
|
||||
def migrate_space(space: dict[str, Any]) -> bool:
|
||||
"""Rewrite one space in place. Returns True when anything was changed."""
|
||||
if "aspect" not in space:
|
||||
return False
|
||||
try:
|
||||
aspect = float(space.get("aspect") or 1)
|
||||
except (TypeError, ValueError):
|
||||
aspect = 1.0
|
||||
dx, dy, kx, ky = transform_for(aspect)
|
||||
space.pop("aspect", None)
|
||||
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("x") is not None:
|
||||
room["x"] = dx + float(room["x"]) * kx
|
||||
if room.get("y") is not None:
|
||||
room["y"] = dy + float(room["y"]) * ky
|
||||
if room.get("w") is not None:
|
||||
room["w"] = float(room["w"]) * kx
|
||||
if room.get("h") is not None:
|
||||
room["h"] = float(room["h"]) * ky
|
||||
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
|
||||
# a length is measured along the wall, and the render scale is uniform
|
||||
if op.get("length") is not None:
|
||||
op["length"] = float(op["length"]) * kx
|
||||
|
||||
for shape in space.get("decor") or []:
|
||||
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
|
||||
if shape.get(a) is not None:
|
||||
shape[a] = dx + float(shape[a]) * fx
|
||||
if shape.get(b) is not None:
|
||||
shape[b] = dy + float(shape[b]) * fy
|
||||
if shape.get("w") is not None:
|
||||
shape["w"] = float(shape["w"]) * kx
|
||||
if shape.get("h") is not None:
|
||||
shape["h"] = float(shape["h"]) * ky
|
||||
|
||||
# The viewport becomes the whole square rather than the transformed old
|
||||
# rectangle. It is what the grid is drawn over and what "fit to screen"
|
||||
# fits, so keeping the old box would leave the new margins outside the
|
||||
# canvas — no dots, nothing to draw on — which is exactly the room this
|
||||
# change was meant to give.
|
||||
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
|
||||
|
||||
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
|
||||
# canvas, so a wall now covers fewer cells; without this every measurement
|
||||
# in the plan would silently shrink.
|
||||
if kx != 1:
|
||||
try:
|
||||
cell = float(space.get("cell_cm") or 5)
|
||||
except (TypeError, ValueError):
|
||||
cell = 5.0
|
||||
space["cell_cm"] = round(cell / kx, 4)
|
||||
|
||||
# The image keeps its own proportions and is centred; the space no longer
|
||||
# has any of its own.
|
||||
if space.get("plan_url") and not space.get("plan_aspect"):
|
||||
space["plan_aspect"] = round(aspect, 6)
|
||||
return True
|
||||
|
||||
|
||||
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""{space_id: old aspect} for every space still carrying one.
|
||||
|
||||
This is the migration INTENT. The two stores are written independently and
|
||||
either write can fail, so the intent has to survive on its own: it is saved
|
||||
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
|
||||
the same write that stores the migrated layout. A crash between the writes
|
||||
leaves the intent behind, and the next start finishes the missing half —
|
||||
each half is idempotent because its trigger (`aspect` in the config, the
|
||||
saved intent for the layout) travels with that half's own write.
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if "aspect" not in space:
|
||||
continue
|
||||
try:
|
||||
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
|
||||
except (TypeError, ValueError):
|
||||
out[str(space.get("id"))] = 1.0
|
||||
return out
|
||||
|
||||
|
||||
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
||||
"""The config half: migrate every space still carrying an `aspect`.
|
||||
|
||||
`layout` is accepted for backward compatibility and migrated with the
|
||||
factors found in the config — callers that can crash between store writes
|
||||
should use `pending_from_config()` + `migrate_layout()` instead, so the
|
||||
layout half does not depend on state the config half just deleted.
|
||||
"""
|
||||
factors = pending_from_config(config)
|
||||
if not factors:
|
||||
return False
|
||||
for space in config.get("spaces") or []:
|
||||
migrate_space(space)
|
||||
if layout:
|
||||
migrate_layout(layout, factors)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
|
||||
"""The layout half: marker and label positions of the spaces in `pending`."""
|
||||
changed = False
|
||||
for pos in (layout or {}).values():
|
||||
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
|
||||
continue
|
||||
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
|
||||
if pos.get("x") is not None:
|
||||
pos["x"] = dx + float(pos["x"]) * kx
|
||||
if pos.get("y") is not None:
|
||||
pos["y"] = dy + float(pos["y"]) * ky
|
||||
changed = True
|
||||
return changed
|
||||
@@ -6,6 +6,8 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -18,8 +20,12 @@ except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, FILES_DIR, FILES_URL
|
||||
from .store import get_entry
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -31,6 +37,79 @@ from .validation import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 1024
|
||||
# batch disk writes: one executor job per megabyte instead of per chunk
|
||||
_FLUSH_AT = 1024 * 1024
|
||||
|
||||
_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
class HouseplanContentView(HomeAssistantView):
|
||||
"""Authenticated read access to plans and marker files (audit B1).
|
||||
|
||||
The directories used to be exposed as unauthenticated static paths, so
|
||||
anyone who could reach the HA endpoint could pull floor plans and uploaded
|
||||
manuals without logging in. This view keeps the same URLs but requires a
|
||||
Home Assistant session (or a signed path, which the frontend uses for
|
||||
<image href> inside the SVG).
|
||||
"""
|
||||
|
||||
url = "/api/houseplan/content/{kind}/{sub}/{name}"
|
||||
name = "api:houseplan:content"
|
||||
requires_auth = True
|
||||
|
||||
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if kind not in ("plans", "files"):
|
||||
return web.Response(status=404)
|
||||
safe_sub = sanitize_marker_id(sub)
|
||||
safe_name = sanitize_filename(name)
|
||||
if not safe_sub or not safe_name:
|
||||
return web.Response(status=404)
|
||||
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
# defence in depth: the sanitizers already strip separators
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
suffix = path.suffix.lower()
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
# origin. Inside the card it is referenced by <image>, where scripts
|
||||
# never run — but the same url opened as a top-level document is a
|
||||
# live document of this origin, and a <script> in it reaches the
|
||||
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
|
||||
# needs write access, which by default every authenticated user has,
|
||||
# and the signed url is easy to hand to an admin).
|
||||
#
|
||||
# `sandbox` with no allow-* tokens drops the document into an opaque
|
||||
# origin: no scripts, no same-origin access, no forms. The explicit
|
||||
# directives below are belt and braces for older engines. Only SVG
|
||||
# gets this — a CSP on a PDF response can break the browser's built-in
|
||||
# viewer, and a raster image cannot execute anything in the first place.
|
||||
headers["Content-Security-Policy"] = (
|
||||
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
|
||||
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
|
||||
)
|
||||
# FileResponse streams from disk: a 50 MB manual used to be read whole
|
||||
# into memory and copied into the response body, so a couple of parallel
|
||||
# downloads could push a small Home Assistant host into swap (HP-1454-06).
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
@@ -42,62 +121,135 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
entry = get_entry(hass)
|
||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
if admin_only:
|
||||
user = request.get("hass_user")
|
||||
if user is None or not user.is_admin:
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
blob: bytes | None = None
|
||||
too_large = False
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
# `finally` removes whatever is left: a dropped connection, a second
|
||||
# `file` part or a failure while promoting used to leave a `.upload-*`
|
||||
# behind for good, and the collector only ever walks marker folders, so
|
||||
# nothing would have picked it up (HP-1460-02).
|
||||
temps: list[Path] = []
|
||||
error: tuple[dict, int] | None = None
|
||||
|
||||
def _new_tmp() -> Path:
|
||||
files_root.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
def _flush(target: Path, blocks: list[bytes]) -> None:
|
||||
with open(target, "ab") as fh:
|
||||
for block in blocks:
|
||||
fh.write(block)
|
||||
|
||||
def _cleanup(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
filename = part.filename or "file"
|
||||
# read in chunks, aborting at the limit, instead of loading the whole file into memory
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
too_large = True
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
if filename is not None:
|
||||
# one upload per request: a second part would strand
|
||||
# the first temporary file and make the response
|
||||
# ambiguous about which url was returned
|
||||
error = ({"error": "one_file_only"}, 400)
|
||||
break
|
||||
chunks.append(chunk)
|
||||
if too_large:
|
||||
break
|
||||
blob = b"".join(chunks)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the
|
||||
# whole upload in memory and copying it again into one
|
||||
# buffer: a 50 MB manual used to cost ~100 MB of RSS
|
||||
# mid-request (HP-1454-06). Blocks are batched so this
|
||||
# is one executor job per megabyte, not per 64 KB.
|
||||
tmp = await hass.async_add_executor_job(_new_tmp)
|
||||
temps.append(tmp)
|
||||
size = 0
|
||||
pending: list[bytes] = []
|
||||
buffered = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
error = (
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
413,
|
||||
)
|
||||
break
|
||||
pending.append(chunk)
|
||||
buffered += len(chunk)
|
||||
if buffered >= _FLUSH_AT:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
pending, buffered = [], 0
|
||||
if error:
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
if too_large:
|
||||
if error:
|
||||
return web.json_response(error[0], status=error[1])
|
||||
if not temps or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
def _promote() -> str:
|
||||
"""Claim a free name, then move the finished upload onto it.
|
||||
|
||||
Never overwrite an existing attachment: its bytes may be
|
||||
referenced by the stored configuration, and this upload is not
|
||||
part of that transaction — a cancelled dialog or a rejected save
|
||||
would leave the old url serving the new content (HP-1454-02).
|
||||
The name is reserved atomically, so two uploads racing on the
|
||||
same filename cannot agree on it (HP-1460-01).
|
||||
"""
|
||||
name = reserve_filename(target_dir, safe_name)
|
||||
try:
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
except OSError:
|
||||
(target_dir / name).unlink(missing_ok=True)
|
||||
raise
|
||||
return name
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
temps.remove(tmp_path) # it is the attachment now, not a temporary
|
||||
return web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
if blob is None or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
ext = file_ext(filename)
|
||||
if ext not in FILE_EXTENSIONS:
|
||||
return web.json_response(
|
||||
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||
)
|
||||
|
||||
safe_name = sanitize_filename(filename)
|
||||
target_dir = Path(hass.config.path(FILES_DIR)) / marker_id
|
||||
path = target_dir / safe_name
|
||||
|
||||
def _write() -> int:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(blob)
|
||||
return int(path.stat().st_mtime)
|
||||
|
||||
mtime = await hass.async_add_executor_job(_write)
|
||||
return web.json_response(
|
||||
{"ok": True, "url": f"{FILES_URL}/{marker_id}/{safe_name}?v={mtime}", "name": filename}
|
||||
)
|
||||
finally:
|
||||
# BaseException too: cancelling the request task raises
|
||||
# asyncio.CancelledError, which an `except Exception` never saw —
|
||||
# an aborted large upload leaked its temporary file every time
|
||||
if temps:
|
||||
await hass.async_add_executor_job(_cleanup, list(temps))
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.26.0"
|
||||
"version": "1.60.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
|
||||
|
||||
The file system is not part of the configuration store's transaction, so who
|
||||
may write or delete a plan or an attachment, and when, is a correctness
|
||||
question rather than housekeeping. It lives here, apart from the WebSocket and
|
||||
HTTP plumbing, precisely because it is the part that has to be reasoned about
|
||||
and tested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
""""<marker>/<file>" for every attachment a configuration references."""
|
||||
out: set[str] = set()
|
||||
for m in (cfg or {}).get("markers") or []:
|
||||
for pdf in m.get("pdfs") or []:
|
||||
url = pdf.get("url") if isinstance(pdf, dict) else None
|
||||
if not isinstance(url, str) or "/files/" not in url:
|
||||
continue
|
||||
rel = url.split("?", 1)[0].split("/files/", 1)[1]
|
||||
if rel.count("/") == 1:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
|
||||
|
||||
A file the old revision referenced and the new one does not, whose marker
|
||||
still exists, was removed on purpose — the dialog has a trash button and
|
||||
promises nothing. It goes. Everything else is kept, except a staging folder
|
||||
(`up_*`), which by construction only ever holds an upload from a dialog that
|
||||
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
|
||||
behind a durable write.
|
||||
"""
|
||||
new_refs = attachment_refs(new_cfg)
|
||||
old_refs = attachment_refs(old_cfg)
|
||||
# Removing an attachment from a device that still exists is the user saying
|
||||
# "drop this one" — a trash button, no promise that anything is kept. A
|
||||
# device that is GONE is a different transition, and its files follow the
|
||||
# same rule as a deleted space's plan: kept.
|
||||
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
|
||||
# Same distinction as for plans. A staging folder (`up_*`) is different: it
|
||||
# only ever holds an upload from a dialog that was never saved, so the short
|
||||
# rule is exactly right there even on the timer.
|
||||
now_s = time.time() if now is None else now
|
||||
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
# A staging folder only ever holds an upload from a dialog that was never
|
||||
# saved — unambiguous, so an hour is right, and no device owns it.
|
||||
staging = folder.name.startswith("up_")
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
except OSError:
|
||||
continue
|
||||
for item in items:
|
||||
rel = f"{folder.name}/{item.name}"
|
||||
if rel in new_refs:
|
||||
continue
|
||||
dropped = rel in old_refs and folder.name in live_markers
|
||||
if not dropped:
|
||||
if not staging:
|
||||
# Same rule as for plans: not asked for, so kept. A file in
|
||||
# a device's folder that the device does not list is an
|
||||
# upload whose save was rejected — and ageing those out
|
||||
# raced the retry that was about to reference them.
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= staging_cutoff:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
|
||||
try:
|
||||
next(folder.iterdir())
|
||||
except StopIteration:
|
||||
try:
|
||||
folder.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
class QuotaError(Exception):
|
||||
"""A store limit would be exceeded. Carries what to tell the user."""
|
||||
|
||||
def __init__(self, reason: str, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
anyway: at the moment somebody asks to store something new.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
raise QuotaError(
|
||||
"quota_exceeded",
|
||||
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
|
||||
f"{max_bytes // 1024 // 1024} MB",
|
||||
)
|
||||
try:
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
return ""
|
||||
return url.split("?", 1)[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
"""Plan file names a configuration references."""
|
||||
out: set[str] = set()
|
||||
for sp in (cfg or {}).get("spaces") or []:
|
||||
name = plan_basename(sp.get("plan_url"))
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""space id -> the plan file it references ('' when it has none)."""
|
||||
return {
|
||||
str(sp.get("id")): plan_basename(sp.get("plan_url"))
|
||||
for sp in (cfg or {}).get("spaces") or []
|
||||
}
|
||||
|
||||
|
||||
def is_plan_file(name: str) -> bool:
|
||||
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
|
||||
parts = name.split(".")
|
||||
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
|
||||
|
||||
|
||||
def collect_plans(
|
||||
plans_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
new_cfg: dict[str, Any],
|
||||
now: float | None = None,
|
||||
) -> int:
|
||||
"""Drop plan files the accepted configuration made obsolete (review R3-1).
|
||||
|
||||
Called inside the config write lock, right after the new revision is
|
||||
stored, so it decides from the two configurations that actually bracket the
|
||||
commit instead of trusting a client to say what may be deleted. The earlier
|
||||
design — a `plan/cleanup` command carrying `keep` — could not be ordered
|
||||
against another client's commit: a delayed call removed the file that
|
||||
client had just saved, leaving the accepted configuration pointing at
|
||||
nothing, which is the damage copy-on-write was introduced to prevent.
|
||||
|
||||
Two rules, both conservative:
|
||||
* a file the OLD configuration referenced and the new one does not was
|
||||
authoritative and has been superseded — remove it;
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
# A commit knows what it superseded. The timer only knows what nothing
|
||||
# points at *right now*, and for a plan that is a reversible state: the
|
||||
# editor detaches the image when a space switches to "draw" and says the
|
||||
# file stays on disk. So the scheduled pass keeps anything belonging to a
|
||||
# space that still exists, and waits a month for the rest.
|
||||
# A space with NO plan_url has had its image detached — reversible, and the
|
||||
# editor promises the file stays. A space that HAS one is different: any
|
||||
# other file of its own is a superseded or rejected upload, so the short
|
||||
# rule is right for those. Getting this distinction wrong (protecting
|
||||
# nothing) destroyed two detached plans on 2026-07-28.
|
||||
# The short rule fits exactly one case: a space that HAS a plan, where any
|
||||
# other file of its own can only be a superseded or rejected upload.
|
||||
old_by_space = plan_by_space(old_cfg)
|
||||
new_by_space = plan_by_space(new_cfg)
|
||||
# A file that left the configuration tells us nothing on its own: replacing a
|
||||
# plan, detaching one and deleting a space all look identical from
|
||||
# `old_refs - new_refs`. Only the first is a deletion the user asked for
|
||||
# (HP-1465-01 — the guards below were written and then never reached,
|
||||
# because the code decided "superseded" before asking why).
|
||||
replaced = {
|
||||
name for space, name in old_by_space.items()
|
||||
if new_by_space.get(space) and new_by_space[space] != name
|
||||
}
|
||||
removed = 0
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
# The directory can vanish or turn unreadable between the check and the
|
||||
# walk. This is housekeeping running behind a commit that is already
|
||||
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
if item.name not in replaced:
|
||||
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
|
||||
# not told to delete is kept, however long it sits there. Detaching
|
||||
# is one click to undo and the editor says the image stays; deleting
|
||||
# a space is deliberate but the image was imported and may be
|
||||
# nowhere else. The errors are not symmetrical — unnecessary
|
||||
# megabytes can be removed by hand, a deleted file cannot be
|
||||
# brought back.
|
||||
#
|
||||
# There is deliberately no age rule here. An earlier version aged
|
||||
# out "rejected uploads" — a file of a space that has a plan, which
|
||||
# was never the plan — and that raced a save: the sweep deleted the
|
||||
# upload from the failed attempt while a retry was committing a
|
||||
# reference to it. A rule that can delete a file somebody is about
|
||||
# to point at is not worth the disk it reclaims.
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
|
||||
return removed
|
||||
@@ -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.
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
|
||||
@@ -25,9 +25,15 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
res = []
|
||||
for sp in spaces:
|
||||
url = sp.get("plan_url") or ""
|
||||
if not url.startswith(PLANS_URL + "/"):
|
||||
# both the legacy static URL and the authenticated content URL
|
||||
prefix = None
|
||||
if url.startswith(PLANS_URL + "/"):
|
||||
prefix = PLANS_URL + "/"
|
||||
elif url.startswith(CONTENT_URL + "/plans/_/"):
|
||||
prefix = CONTENT_URL + "/plans/_/"
|
||||
if prefix is None:
|
||||
continue # external/legacy URL — not ours to verify
|
||||
fname = url[len(PLANS_URL) + 1 :].split("?", 1)[0]
|
||||
fname = url[len(prefix) :].split("?", 1)[0]
|
||||
if not (plans_dir / fname).is_file():
|
||||
res.append((sp.get("id", "?"), fname))
|
||||
return res
|
||||
@@ -45,8 +51,17 @@ async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntr
|
||||
translation_key="broken_plan",
|
||||
translation_placeholders={"space": space_id, "file": fname},
|
||||
)
|
||||
# clear stale issues for spaces that are fine again (or gone)
|
||||
for sp in spaces:
|
||||
sid = sp.get("id", "?")
|
||||
if sid not in broken:
|
||||
ir.async_delete_issue(hass, DOMAIN, f"broken_plan_{sid}")
|
||||
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
|
||||
# issues for spaces that still exist, so deleting or renaming a space with a
|
||||
# missing plan left its warning in Repairs forever, with nothing left to fix
|
||||
# it (HP-1454-09). Enumerate what we actually published instead.
|
||||
registry = ir.async_get(hass)
|
||||
stale = [
|
||||
issue_id
|
||||
for (domain, issue_id) in list(registry.issues)
|
||||
if domain == DOMAIN
|
||||
and issue_id.startswith("broken_plan_")
|
||||
and issue_id[len("broken_plan_") :] not in broken
|
||||
]
|
||||
for issue_id in stale:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -42,6 +43,17 @@ class HouseplanData:
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# A separate, narrower lock for the check-quota→write-file pair of an
|
||||
# upload. Without it N parallel uploads all measure the store BEFORE any
|
||||
# of them writes, and all pass a quota only one of them fits under
|
||||
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
|
||||
# stall config/layout commits.
|
||||
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Collect files nothing references any more. Set during setup, which also
|
||||
# runs it once and schedules it daily. Exposed so it can be invoked
|
||||
# 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
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
@@ -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", {})),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Server-side vacuum trails.
|
||||
|
||||
The integration records the robot's path ITSELF by watching the source
|
||||
entity's state changes — no card involvement. This removes every client-side
|
||||
race (N open tabs would fight over writes), survives page reloads by
|
||||
construction, and keeps recording while no card is open at all. Stored: the
|
||||
current run and one previous run per marker (owner call 2026-07-31 — users
|
||||
want to see where the cleanup has already been).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
|
||||
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
|
||||
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
|
||||
value that is not None wins — truthiness is wrong here: a zero-based
|
||||
`map_index: 0` is a valid first map and an empty string is still an id.
|
||||
The old `or`-chain dropped the zero, so the server stored trails under a
|
||||
key the renderer never looked up (HP-1540-02).
|
||||
"""
|
||||
for v in (
|
||||
src_attrs.get("map_name"),
|
||||
src_attrs.get("current_map"),
|
||||
src_attrs.get("map_index"),
|
||||
src_attrs.get("selected_map"),
|
||||
vac_attrs.get("selected_map"),
|
||||
):
|
||||
if v is not None:
|
||||
return str(v)
|
||||
return "default"
|
||||
|
||||
|
||||
class TrailBook:
|
||||
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
|
||||
|
||||
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
|
||||
robot coordinates — recalibration never invalidates a stored trail.
|
||||
"""
|
||||
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
return False
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
half = pts[0::2]
|
||||
if half[-1] != pts[-1]:
|
||||
half.append(pts[-1])
|
||||
cur["points"] = half
|
||||
return True
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and not cur.get("ended"):
|
||||
cur["ended"] = now
|
||||
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."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
self.book = TrailBook()
|
||||
# HP-1540-03: one source may feed SEVERAL markers — the same robot
|
||||
# placed on two floors is the documented multi-floor case, and a plain
|
||||
# source → (marker, vacuum) dict silently kept only the last one
|
||||
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
|
||||
self._unsub_track = None
|
||||
self._unsub_save = None
|
||||
self._last_fire = 0.0
|
||||
# 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
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
self.book = TrailBook(await self.store.async_load() or {})
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""(Re)subscribe after any config change — markers may come and go.
|
||||
|
||||
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
|
||||
atomic across the awaited config load, so overlapping refresh tasks can
|
||||
no longer both subscribe and strand one callback forever. The _closed
|
||||
check covers teardown() racing a refresh that is parked on its await.
|
||||
"""
|
||||
async with self._refresh_lock:
|
||||
stored = await self.rt.config_store.async_load() or {}
|
||||
if self._closed:
|
||||
return
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
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
|
||||
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))
|
||||
self.pairs = pairs
|
||||
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
|
||||
# lost.
|
||||
for src in self.pairs:
|
||||
self._sample(src, time.time())
|
||||
|
||||
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
|
||||
self._closed = True
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
|
||||
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
|
||||
b = str(m.get("binding") or "")
|
||||
if b.startswith("entity:vacuum."):
|
||||
return b[len("entity:"):]
|
||||
if b.startswith("device:"):
|
||||
reg = er.async_get(self.hass)
|
||||
for e in er.async_entries_for_device(reg, b[len("device:"):]):
|
||||
if e.entity_id.startswith("vacuum."):
|
||||
return e.entity_id
|
||||
return None
|
||||
|
||||
def _sample(self, src: str, now: float) -> bool:
|
||||
"""Record one point (or end the run) for EVERY marker fed by src.
|
||||
|
||||
HP-1540-03: the same source serves one marker per floor — all of them
|
||||
must receive the point, not just whichever survived the dict.
|
||||
"""
|
||||
changed = False
|
||||
for marker, vac in self.pairs.get(src) or ():
|
||||
st_vac = self.hass.states.get(vac)
|
||||
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
|
||||
# unavailable and ending the run here would split one cleanup into
|
||||
# current+previous on every restart (observed live: 21 points
|
||||
# became previous, the same run restarted at 5)
|
||||
if not st_vac or st_vac.state in ("unavailable", "unknown"):
|
||||
continue
|
||||
if st_vac.state not in MOVING_STATES:
|
||||
changed |= self.book.end_run(marker, now)
|
||||
continue
|
||||
st_src = self.hass.states.get(src)
|
||||
attrs = st_src.attributes if st_src else {}
|
||||
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
|
||||
# Server-side these attributes are often OBJECTS (Tasshack keeps a
|
||||
# Point dataclass in memory — it only becomes a dict when
|
||||
# serialised to the frontend). Caught live on the owner's X50: the
|
||||
# recorder saw every state change and rejected every single one.
|
||||
if isinstance(raw, dict):
|
||||
px, py = raw.get("x"), raw.get("y")
|
||||
else:
|
||||
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
|
||||
try:
|
||||
x, y = float(px), float(py) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
map_id = resolve_map_id(attrs, st_vac.attributes)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
return changed
|
||||
|
||||
@callback
|
||||
def _on_state(self, event: Any) -> None:
|
||||
eid = event.data.get("entity_id")
|
||||
now = time.time()
|
||||
changed = False
|
||||
for src, pair_list in self.pairs.items():
|
||||
if eid == src or any(eid == vac for _, vac in pair_list):
|
||||
changed |= self._sample(src, now)
|
||||
if changed:
|
||||
self._schedule_save()
|
||||
if now - self._last_fire >= FIRE_THROTTLE_S:
|
||||
self._last_fire = now
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if self._unsub_save:
|
||||
return
|
||||
|
||||
async def _save(_now: Any) -> None:
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
|
||||
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
|
||||
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# 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
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
@@ -47,13 +50,147 @@ def valid_space_id(value: str) -> bool:
|
||||
|
||||
|
||||
# ---------- voluptuous schemas ----------
|
||||
def _finite(value):
|
||||
"""Coerce to float and reject NaN/Infinity (audit B5).
|
||||
|
||||
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
|
||||
silently corrupting a stored position forever.
|
||||
"""
|
||||
f = float(value)
|
||||
if f != f or f in (float("inf"), float("-inf")):
|
||||
raise vol.Invalid("coordinate must be a finite number")
|
||||
return f
|
||||
|
||||
|
||||
# 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
|
||||
# list passed validation, then made the card build gigantic SVG attributes and
|
||||
# walk them on every render. Any authenticated writer could store one, and with
|
||||
# `admin_only` off that is every user. These are product limits, not guesses: a
|
||||
# hand-drawn room does not need 500 vertices, and no home has 200 lights behind
|
||||
# one switch.
|
||||
MAX_POLY_POINTS = 500
|
||||
MAX_OPEN_TO = 50
|
||||
MAX_CONTROLS = 200
|
||||
MAX_PDFS = 50
|
||||
MAX_KNOWN_DEVICES = 20000
|
||||
MAX_TEXT = 500 # names, models, ids
|
||||
MAX_DESCRIPTION = 4000
|
||||
MAX_URL = 2000
|
||||
# Comfortably below the WebSocket frame limit (aiohttp's default is 4 MB): a
|
||||
# payload larger than the frame never reaches the handler at all — the socket
|
||||
# closes with 1009 and the user sees a dropped connection instead of an error
|
||||
# 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)
|
||||
_URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
|
||||
# The canvas is UNBOUNDED (docs/CANVAS.md). Coordinates are still normalised —
|
||||
# 1.0 is still one canvas width — but there is no frame any more, so a plan may
|
||||
# legitimately live at 2.7 or -1.4. The range below is GARBAGE INSURANCE, not a
|
||||
# boundary: at the product's own scale (cell_cm=5, 240 cells across the unit
|
||||
# width) 5000 is about 60 km of plan, unreachable in a home, while a stored
|
||||
# 1e100 still cannot stretch every client's view until the plan is invisible
|
||||
# (HP-1500-03 / HP-1501-01). Widened from +/-4 on 2026-08-03.
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
# corruption (HP-1501-01, the room-geometry twin of HP-1500-03).
|
||||
_GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
# A SIZE is not a coordinate (HP-1502-01): SVG requires positive width/height,
|
||||
# and the clients divide by these. `view_box: [0,0,0,0]` passed the shared
|
||||
# validator and serialised into viewBox="0 0 0 0" — a blank plan on every
|
||||
# client. The floor is one thousandth of the canvas (1 render unit): far below
|
||||
# any real room, but keeps the maths finite. The CEILING follows the canvas
|
||||
# (docs/CANVAS.md) — a room on an unbounded plane may legitimately be wider
|
||||
# than the old unit square — while staying strictly positive.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
|
||||
|
||||
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
|
||||
# coordinate: strictly positive, and bounded by what a person could mean —
|
||||
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
|
||||
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
|
||||
PLAN_SCALE_MIN = 0.01
|
||||
PLAN_SCALE_MAX = 100.0
|
||||
|
||||
|
||||
def _view_box(value):
|
||||
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 4:
|
||||
raise vol.Invalid("view_box must be [x, y, w, h]")
|
||||
return [_GEOM(value[0]), _GEOM(value[1]), _EXTENT(value[2]), _EXTENT(value[3])]
|
||||
|
||||
|
||||
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:
|
||||
@@ -65,51 +202,344 @@ def _require_geometry(room: dict) -> dict:
|
||||
ROOM_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("name"): str,
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("x"): vol.Coerce(float),
|
||||
vol.Optional("y"): vol.Coerce(float),
|
||||
vol.Optional("w"): vol.Coerce(float),
|
||||
vol.Optional("h"): vol.Coerce(float),
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3)),
|
||||
vol.Required("id"): _TEXT,
|
||||
vol.Required("name"): _TEXT,
|
||||
vol.Optional("area"): _TEXT_OR_NONE,
|
||||
vol.Optional("open_to"): vol.All([_TEXT], vol.Length(max=MAX_OPEN_TO)),
|
||||
vol.Optional("settings"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
|
||||
vol.Optional("temp_source"): vol.Any(str, None),
|
||||
vol.Optional("hum_source"): vol.Any(str, None),
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
vol.Optional("x"): _GEOM,
|
||||
vol.Optional("y"): _GEOM,
|
||||
vol.Optional("w"): _EXTENT,
|
||||
vol.Optional("h"): _EXTENT,
|
||||
vol.Optional("poly"): vol.All([POINT], vol.Length(min=3, max=MAX_POLY_POINTS)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_require_geometry,
|
||||
)
|
||||
|
||||
def _north_deg(value):
|
||||
"""Compass (docs/SUN.md): strict integer degrees, 0..359.
|
||||
|
||||
Strict on purpose: Coerce(int) would take "90" and 1.5, and a bool is an
|
||||
int in Python — none of those is a compass reading a client stored.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
if not 0 <= value <= 359:
|
||||
raise vol.Invalid("north_deg must be an integer in 0..359")
|
||||
return value
|
||||
|
||||
|
||||
_BG_MODE = vol.In(["static", "daynight"])
|
||||
|
||||
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}$"),
|
||||
# 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("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp"]),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
# "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,
|
||||
vol.Optional("label_light"): bool,
|
||||
vol.Optional("card_font_scale"): vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3)),
|
||||
# sun on the plan (docs/SUN.md): per-space overrides, absent = inherit
|
||||
vol.Optional("north_deg"): vol.Any(None, _north_deg),
|
||||
vol.Optional("bg_mode"): vol.Any(None, _BG_MODE),
|
||||
vol.Optional("sun_rays"): vol.Any(None, bool),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
# 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("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):
|
||||
# it used to be pinned to -1..2, i.e. "one canvas of slack around the square".
|
||||
_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.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,
|
||||
# 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"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
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,
|
||||
# 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),
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
||||
vol.Optional("openings"): [
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
# `aspect` is gone; the background image keeps its own proportions and
|
||||
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
|
||||
# send the old field — it is dropped rather than trusted, because the
|
||||
# coordinates it comes with were normalised against a different box.
|
||||
vol.Remove("aspect"): object,
|
||||
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,
|
||||
# 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),
|
||||
vol.Optional("plan_y"): vol.Any(None, _COORD),
|
||||
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)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("x"): vol.Coerce(float),
|
||||
vol.Required("y"): vol.Coerce(float),
|
||||
vol.Required("angle"): vol.Coerce(float),
|
||||
vol.Required("length"): vol.All(vol.Coerce(float), vol.Range(min=0.001, max=1)),
|
||||
vol.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)),
|
||||
# a SIZE: strictly positive, capped by the canvas insurance
|
||||
# limit rather than by the old unit square (docs/CANVAS.md)
|
||||
vol.Required("length"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("contact"): vol.Any(str, None),
|
||||
vol.Optional("lock"): vol.Any(str, None),
|
||||
vol.Optional("invert"): bool,
|
||||
@@ -118,45 +548,123 @@ 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.
|
||||
vol.Optional("segments"): [vol.All([vol.Coerce(float)], vol.Length(min=4, max=4))],
|
||||
# Accepted so a stale browser tab cannot fail a save, then DROPPED here
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
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,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("model"): vol.Any(str, None),
|
||||
vol.Optional("link"): vol.Any(str, None),
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
# 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,
|
||||
vol.Optional("link"): vol.Any(None, _URL),
|
||||
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
|
||||
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
|
||||
# the 'run' target: only the runnable domains, nothing else is callable
|
||||
vol.Optional("tap_target"): vol.Any(
|
||||
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
|
||||
),
|
||||
vol.Optional("tap_confirm"): vol.Any(bool, None),
|
||||
# live robot vacuums (docs/VACUUM.md): everything optional so configs
|
||||
# from older versions stay valid untouched
|
||||
vol.Optional("vacuum"): vol.Any(
|
||||
None,
|
||||
vol.Schema({
|
||||
vol.Optional("live"): vol.Any(bool, None),
|
||||
vol.Optional("trail"): vol.Any(bool, None),
|
||||
vol.Optional("trail_mode"): vol.Any(
|
||||
None, vol.In(["never", "cleaning", "always"])
|
||||
),
|
||||
vol.Optional("room_highlight"): vol.Any(bool, None),
|
||||
vol.Optional("source"): vol.Any(str, None),
|
||||
# one 6-number affine per robot map; numbers must be finite
|
||||
vol.Optional("calibration"): vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
}),
|
||||
),
|
||||
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("is_light"): vol.Any(bool, None),
|
||||
# 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. `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, vol.Match(r"^#[0-9a-fA-F]{6}$")
|
||||
),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
vol.Optional("pdfs"): [
|
||||
vol.Schema({vol.Required("name"): str, vol.Required("url"): str}, extra=vol.ALLOW_EXTRA)
|
||||
],
|
||||
vol.Optional("pdfs"): vol.All(
|
||||
[vol.Schema({vol.Required("name"): _TEXT, vol.Required("url"): _URL}, extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(max=MAX_PDFS),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): 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}$"),
|
||||
# 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)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/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';
|
||||
|
||||
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}',
|
||||
});
|
||||
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 }) => {
|
||||
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);
|
||||
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 });
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
|
||||
|
||||
/** Refuse measurements/screenshots made by a committed bundle from old source. */
|
||||
export async function assertFreshDemoBundle(page, root = process.cwd()) {
|
||||
const expected = sourceFingerprint(root);
|
||||
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
|
||||
if (loaded !== expected) {
|
||||
throw new Error(
|
||||
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
|
||||
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
|
||||
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Deterministic, fictional high-load fixture shared by performance and future
|
||||
* visual-regression tooling. Nothing here depends on a real HA installation.
|
||||
*/
|
||||
|
||||
const FLOOR_COUNT = 3;
|
||||
const ROOMS_PER_FLOOR = 20;
|
||||
const DEVICE_COUNT = 200;
|
||||
const OPENING_COUNT = 100;
|
||||
const PARTITION_COUNT = 60;
|
||||
const COLUMN_COUNT = 40;
|
||||
const DECOR_COUNT = 500;
|
||||
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
const roomGrid = (floor) => {
|
||||
const rooms = [];
|
||||
const left = 0.04;
|
||||
const top = 0.04;
|
||||
const width = 0.92 / 5;
|
||||
const height = 0.92 / 4;
|
||||
for (let row = 0; row < 4; row++) {
|
||||
for (let column = 0; column < 5; column++) {
|
||||
const index = row * 5 + column;
|
||||
const x1 = round(left + column * width);
|
||||
const y1 = round(top + row * height);
|
||||
const x2 = round(x1 + width);
|
||||
const y2 = round(y1 + height);
|
||||
rooms.push({
|
||||
id: `perf-room-${floor}-${index}`,
|
||||
name: `Room ${floor + 1}.${index + 1}`,
|
||||
area: `perf_area_${floor}_${index}`,
|
||||
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
|
||||
});
|
||||
}
|
||||
}
|
||||
return rooms;
|
||||
};
|
||||
|
||||
const wallSegments = (rooms) => {
|
||||
const unique = new Map();
|
||||
for (const room of rooms) {
|
||||
room.poly.forEach((a, index) => {
|
||||
const b = room.poly[(index + 1) % room.poly.length];
|
||||
const forward = `${a.join(',')}/${b.join(',')}`;
|
||||
const reverse = `${b.join(',')}/${a.join(',')}`;
|
||||
if (!unique.has(reverse)) unique.set(forward, { a, b });
|
||||
});
|
||||
}
|
||||
return [...unique.values()];
|
||||
};
|
||||
|
||||
const makeOpenings = (floor, walls, count) => walls.slice(0, count).map((wall, index) => {
|
||||
const horizontal = Math.abs(wall.b[0] - wall.a[0]) >= Math.abs(wall.b[1] - wall.a[1]);
|
||||
return {
|
||||
id: `perf-opening-${floor}-${index}`,
|
||||
type: index % 7 === 0 ? 'window' : index % 11 === 0 ? 'gate' : 'door',
|
||||
x: round((wall.a[0] + wall.b[0]) / 2),
|
||||
y: round((wall.a[1] + wall.b[1]) / 2),
|
||||
angle: horizontal ? 0 : 90,
|
||||
length: horizontal ? 0.045 : 0.055,
|
||||
};
|
||||
});
|
||||
|
||||
const makePartitions = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
|
||||
const room = rooms[index % rooms.length];
|
||||
const [a, , c] = room.poly;
|
||||
const y = round(a[1] + (c[1] - a[1]) * (0.35 + (index % 3) * 0.12));
|
||||
return {
|
||||
id: `perf-partition-${floor}-${index}`,
|
||||
a: [round(a[0] + 0.035), y],
|
||||
b: [round(c[0] - 0.035), y],
|
||||
cm: 10 + (index % 3) * 5,
|
||||
};
|
||||
});
|
||||
|
||||
const makeColumns = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
|
||||
const room = rooms[(index * 3) % rooms.length];
|
||||
const [a, , c] = room.poly;
|
||||
return {
|
||||
id: `perf-column-${floor}-${index}`,
|
||||
shape: index % 3 === 0 ? 'circle' : 'square',
|
||||
center: [
|
||||
round(a[0] + (c[0] - a[0]) * (0.28 + (index % 2) * 0.44)),
|
||||
round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 1) % 2) * 0.44)),
|
||||
],
|
||||
cm: 25 + (index % 4) * 5,
|
||||
...(index % 3 === 0 ? {} : { angle: (index % 6) * 15 }),
|
||||
};
|
||||
});
|
||||
|
||||
const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) => {
|
||||
const column = index % 25;
|
||||
const row = Math.floor(index / 25);
|
||||
const x = round(0.02 + column * 0.039);
|
||||
const y = round(0.018 + (row % 20) * 0.048);
|
||||
if (index % 10 === 0) {
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'text', x, y, text: `F${floor + 1}-${index}`, size_cm: 14,
|
||||
color: '#59636e', opacity: 0.75,
|
||||
};
|
||||
}
|
||||
if (index % 3 === 0) {
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'rect', x, y, w: 0.022, h: 0.018, angle: (index % 12) * 5,
|
||||
color: '#687681', opacity: 0.55, width_cm: 1.5,
|
||||
fill: index % 2 === 0, fill_color: '#75838e', fill_opacity: 0.12,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'line', x1: x, y1: y, x2: round(x + 0.025), y2: round(y + (index % 2 ? 0.012 : 0)),
|
||||
color: '#687681', opacity: 0.6, width_cm: 1.2,
|
||||
...(index % 9 === 0 ? { line_style: 'dashed' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const entityKinds = [
|
||||
['light', 'on'],
|
||||
['switch', 'off'],
|
||||
['sensor', '21.5'],
|
||||
['binary_sensor', 'off'],
|
||||
['climate', 'heat'],
|
||||
['media_player', 'playing'],
|
||||
['cover', 'closed'],
|
||||
['fan', 'on'],
|
||||
['lock', 'locked'],
|
||||
['vacuum', 'docked'],
|
||||
];
|
||||
|
||||
const makeRuntime = (spaces) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {};
|
||||
const areas = {};
|
||||
const layout = {};
|
||||
const roomRefs = spaces.flatMap((space) => space.rooms.map((room) => ({ space, room })));
|
||||
for (const { room } of roomRefs) areas[room.area] = { area_id: room.area, name: room.name };
|
||||
for (let index = 0; index < DEVICE_COUNT; index++) {
|
||||
const { space, room } = roomRefs[index % roomRefs.length];
|
||||
const [domain, baseState] = entityKinds[index % entityKinds.length];
|
||||
const deviceId = `perf-device-${index}`;
|
||||
const entityId = `${domain}.perf_${index}`;
|
||||
devices[deviceId] = {
|
||||
id: deviceId,
|
||||
name: `Synthetic ${domain} ${index + 1}`,
|
||||
model: `PERF-${String(index + 1).padStart(3, '0')}`,
|
||||
area_id: room.area,
|
||||
identifiers: [['houseplan_perf', deviceId]],
|
||||
config_entries: ['perf_entry'],
|
||||
entry_type: null,
|
||||
via_device_id: null,
|
||||
disabled_by: null,
|
||||
};
|
||||
entities[entityId] = {
|
||||
entity_id: entityId,
|
||||
device_id: deviceId,
|
||||
platform: 'houseplan_perf',
|
||||
config_entry_id: 'perf_entry',
|
||||
disabled_by: null,
|
||||
};
|
||||
const attributes = { friendly_name: devices[deviceId].name };
|
||||
if (domain === 'sensor') Object.assign(attributes, {
|
||||
device_class: 'temperature', unit_of_measurement: '°C', state_class: 'measurement',
|
||||
});
|
||||
if (domain === 'binary_sensor') attributes.device_class = index % 2 ? 'motion' : 'occupancy';
|
||||
if (domain === 'climate') Object.assign(attributes, { current_temperature: 21.5, temperature: 22 });
|
||||
states[entityId] = { entity_id: entityId, state: index % 4 === 0 && domain === 'light' ? 'off' : baseState, attributes };
|
||||
const [a, , c] = room.poly;
|
||||
layout[deviceId] = {
|
||||
s: space.id,
|
||||
x: round(a[0] + (c[0] - a[0]) * (0.2 + (index % 4) * 0.2)),
|
||||
y: round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 2) % 3) * 0.22)),
|
||||
};
|
||||
}
|
||||
return { devices, entities, states, areas, layout };
|
||||
};
|
||||
|
||||
export const LARGE_HOUSE_COUNTS = Object.freeze({
|
||||
floors: FLOOR_COUNT,
|
||||
rooms: FLOOR_COUNT * ROOMS_PER_FLOOR,
|
||||
devices: DEVICE_COUNT,
|
||||
openings: OPENING_COUNT,
|
||||
partitions: PARTITION_COUNT,
|
||||
columns: COLUMN_COUNT,
|
||||
decor: DECOR_COUNT,
|
||||
});
|
||||
|
||||
export const makeLargeHouseFixture = () => {
|
||||
let openingsLeft = OPENING_COUNT;
|
||||
let partitionsLeft = PARTITION_COUNT;
|
||||
let columnsLeft = COLUMN_COUNT;
|
||||
let decorLeft = DECOR_COUNT;
|
||||
const spaces = Array.from({ length: FLOOR_COUNT }, (_, floor) => {
|
||||
const rooms = roomGrid(floor);
|
||||
const segments = wallSegments(rooms);
|
||||
const floorsRemaining = FLOOR_COUNT - floor;
|
||||
const openingCount = Math.ceil(openingsLeft / floorsRemaining);
|
||||
const partitionCount = Math.ceil(partitionsLeft / floorsRemaining);
|
||||
const columnCount = Math.ceil(columnsLeft / floorsRemaining);
|
||||
const decorCount = Math.ceil(decorLeft / floorsRemaining);
|
||||
openingsLeft -= openingCount;
|
||||
partitionsLeft -= partitionCount;
|
||||
columnsLeft -= columnCount;
|
||||
decorLeft -= decorCount;
|
||||
return {
|
||||
id: `perf-floor-${floor + 1}`,
|
||||
title: `Performance floor ${floor + 1}`,
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
|
||||
rooms,
|
||||
walls: segments.map((wall, index) => ({
|
||||
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
|
||||
})),
|
||||
openings: makeOpenings(floor, segments, openingCount),
|
||||
partitions: makePartitions(floor, rooms, partitionCount),
|
||||
wall_columns: makeColumns(floor, rooms, columnCount),
|
||||
decor: makeDecor(floor, decorCount),
|
||||
};
|
||||
});
|
||||
const runtime = makeRuntime(spaces);
|
||||
return {
|
||||
config: { spaces, markers: [], settings: { glow_radius_cm: 300 } },
|
||||
...runtime,
|
||||
counts: LARGE_HOUSE_COUNTS,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
|
||||
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
const uniqueEdges = (rooms) => {
|
||||
const edges = new Map();
|
||||
for (const room of rooms) {
|
||||
room.poly.forEach((a, index) => {
|
||||
const b = room.poly[(index + 1) % room.poly.length];
|
||||
const forward = `${a.join(',')}/${b.join(',')}`;
|
||||
const reverse = `${b.join(',')}/${a.join(',')}`;
|
||||
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
|
||||
});
|
||||
}
|
||||
return [...edges.values()];
|
||||
};
|
||||
|
||||
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
|
||||
key: `${prefix}-wall-${index}`,
|
||||
a: edge.a,
|
||||
b: edge.b,
|
||||
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
|
||||
}));
|
||||
|
||||
const geometryRooms = [
|
||||
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
|
||||
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
|
||||
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
|
||||
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
|
||||
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
|
||||
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
|
||||
];
|
||||
|
||||
const lightingRooms = [
|
||||
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
|
||||
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
|
||||
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
|
||||
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
|
||||
];
|
||||
|
||||
const geometrySpace = {
|
||||
id: 'golden-geometry',
|
||||
title: 'Geometry matrix',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', show_borders: true, show_names: true,
|
||||
room_color: '#2d8fce', room_opacity: 0.16,
|
||||
},
|
||||
rooms: geometryRooms,
|
||||
walls: wallsFor('geo', geometryRooms, (edge, index) => {
|
||||
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
|
||||
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
|
||||
return index % 4 === 0 ? 10 : 15;
|
||||
}),
|
||||
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
|
||||
openings: [
|
||||
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
|
||||
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
|
||||
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
|
||||
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
|
||||
],
|
||||
partitions: [
|
||||
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
|
||||
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
|
||||
],
|
||||
wall_columns: [
|
||||
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
|
||||
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
|
||||
],
|
||||
decor: [
|
||||
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
|
||||
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
|
||||
],
|
||||
};
|
||||
|
||||
const lightingSpace = {
|
||||
id: 'golden-lighting',
|
||||
title: 'Lighting matrix',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'glow', show_borders: true, show_names: true,
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'static',
|
||||
},
|
||||
rooms: lightingRooms,
|
||||
walls: wallsFor('light', lightingRooms, (edge) => (
|
||||
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
|
||||
)),
|
||||
openings: [
|
||||
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
|
||||
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
|
||||
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
|
||||
],
|
||||
partitions: [
|
||||
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
|
||||
],
|
||||
wall_columns: [
|
||||
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
|
||||
],
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = () => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 24 },
|
||||
},
|
||||
};
|
||||
const layout = {};
|
||||
const areas = Object.fromEntries(
|
||||
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
);
|
||||
const add = (id, domain, area, x, y, state, attributes = {}) => {
|
||||
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
|
||||
devices[id] = {
|
||||
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
|
||||
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
|
||||
entry_type: null, via_device_id: null, disabled_by: null,
|
||||
};
|
||||
entities[entityId] = {
|
||||
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
|
||||
config_entry_id: 'golden_entry', disabled_by: null,
|
||||
};
|
||||
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
|
||||
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
|
||||
};
|
||||
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
|
||||
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
|
||||
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
|
||||
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
|
||||
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
|
||||
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
|
||||
});
|
||||
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
});
|
||||
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
});
|
||||
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
return { devices, entities, states, layout, areas };
|
||||
};
|
||||
|
||||
export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
spaces: 2,
|
||||
rooms: geometryRooms.length + lightingRooms.length,
|
||||
openings: geometrySpace.openings.length + lightingSpace.openings.length,
|
||||
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
config: {
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
markers: [],
|
||||
settings: {
|
||||
glow_radius_cm: 360,
|
||||
north_deg: 0,
|
||||
sun_rays: true,
|
||||
bg_mode: 'static',
|
||||
fill_colors: {
|
||||
glow_base: { c: '#1b2530', a: 0.78 },
|
||||
glow_light: { c: '#ffd27b', a: 0.70 },
|
||||
wall_fill: { c: '#d7d9dc', a: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(),
|
||||
counts: VISUAL_MATRIX_COUNTS,
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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.
|
||||
|
||||
## Safety contract
|
||||
|
||||
- A build fingerprint embedded by Rollup must match `src/`, Rollup/TypeScript
|
||||
configuration and locked package inputs; stale committed
|
||||
demo bundles fail before the first screenshot.
|
||||
- Chromium, viewport, locale, timezone, colour profile, font rendering,
|
||||
animations and caret are controlled by the runner.
|
||||
- `capture` writes only to ignored `artifacts/golden/`; it never changes a
|
||||
baseline and never claims a missing baseline passed. Any scenario runtime
|
||||
error makes capture fail, including the initial no-baseline CI run.
|
||||
- `verify` requires every image plus a matching matrix manifest and fails on
|
||||
missing/different/error scenarios, browser mismatch or a baseline whose hash
|
||||
no longer matches the reviewed manifest.
|
||||
- `accept` requires `--reviewed`, a complete candidate report and current
|
||||
source fingerprint. It validates the whole set before copying anything and
|
||||
is the only command allowed to update baselines.
|
||||
|
||||
## Workflow
|
||||
|
||||
Build and copy the exact current source first:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run golden:capture
|
||||
```
|
||||
|
||||
Review `artifacts/golden/actual/` and, when existing references are present,
|
||||
`artifacts/golden/diff/`. If every image is intentional:
|
||||
|
||||
```bash
|
||||
npm run golden:accept -- --reviewed
|
||||
npm run golden:verify
|
||||
```
|
||||
|
||||
Never accept images merely to make CI green. A matrix/framing change increments
|
||||
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
|
||||
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
|
||||
Future updates must still use the `golden-images` artifact produced by the Linux
|
||||
CI job as the review set: desktop font rasterisation can differ from the CI
|
||||
environment even with the same pinned Chromium. Pass its unpacked root via
|
||||
`--from=...` when accepting it locally.
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||||
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
|
||||
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
|
||||
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
|
||||
|
||||
const reportPath = resolve(from, 'golden-report.json');
|
||||
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
|
||||
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
||||
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
|
||||
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
|
||||
if (report.buildFingerprint !== sourceFingerprint(ROOT))
|
||||
throw new Error('candidate screenshots were not captured from the current frontend source');
|
||||
if (typeof report.chromium !== 'string' || !report.chromium)
|
||||
throw new Error('candidate report does not identify its Chromium build');
|
||||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||||
|
||||
const byId = new Map(report.results.map((result) => [result.id, result]));
|
||||
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
||||
mkdirSync(baselineRoot, { recursive: true });
|
||||
const hashes = {};
|
||||
const candidates = [];
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
const result = byId.get(scenario.id);
|
||||
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
|
||||
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
|
||||
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
|
||||
if (!result?.actualSha256 || !existsSync(candidate))
|
||||
throw new Error(`review candidate missing: ${scenario.id}`);
|
||||
const bytes = readFileSync(candidate);
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
|
||||
candidates.push({ scenario, candidate });
|
||||
hashes[scenario.id] = digest;
|
||||
}
|
||||
// Validate the complete set first: a broken report must never leave a half-
|
||||
// updated baseline directory behind.
|
||||
for (const { scenario, candidate } of candidates)
|
||||
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
|
||||
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
|
||||
schema: 1,
|
||||
matrixVersion: GOLDEN_MATRIX_VERSION,
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
scenarios: hashes,
|
||||
}, null, 2)}\n`, 'utf8');
|
||||
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 3,
|
||||
"acceptedAt": "2026-08-09T12:30:01.505Z",
|
||||
"sourceFingerprint": "f1969cf05ab483dd1bf24d32d967f931f1e3c9ce37cd3f61d4b851f1eae417ee",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"geometry-view-dark-fit": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
|
||||
"geometry-view-light-fit": "a425fa6d77081bb35d2fb81e469962402c6a3c86a8113ff957f28548908d0816",
|
||||
"geometry-plan-editor-dark": "86d64305e4da13436aef14fe96b2c9eb5978d1d32a0bba2318005421a2fda161",
|
||||
"geometry-devices-editor-dark": "93417ab7363ca50e65c9d6f0b876f4d5a6a367a8fabdf42306b244e6511693f1",
|
||||
"geometry-decor-editor-dark": "25d341052e30e7acea13c17897e8bd0f1f08235342b949c02afd3db5b9936c64",
|
||||
"tray-wide-selection-en": "c7350c3661f17f46d22b2c977524fde9fba1ebf9d6e10f021482f0d1caeffa8a",
|
||||
"tray-wide-tool-ru": "5472a302ad7e7b70235089164a5c4558183504519d95280e8ff30883e304cb71",
|
||||
"tray-medium-group-en": "59480e04ff5ea0f2cfb31b88c1c753ce8eeac785596ca21eac48dd99ec5e0eb6",
|
||||
"tray-medium-selection-ru": "805c23560bc340582f9b951e910f39245ab62b6cd980d175c8d9e3b7e1ef1519",
|
||||
"tray-narrow-palette-en": "e8dede0d90ebc32e1e294ba3c39621df918aaf7b3183f71aed9b60014eee51db",
|
||||
"tray-narrow-tool-ru": "8889d58aca90daadf0bedd6a7c08afb2604c086a453f9435f32d29607aa49e42",
|
||||
"geometry-diagonal-45-opening-dark": "fbce77f97180cd7d50a997c74d3fa1874ee3be2515a4c09b36dd4e792ebc66df",
|
||||
"openings-thick-wall-dark": "5d9ac6add9a5709318180f4554ac62c1c7e2862bd016be7901a2bd450e021f0c",
|
||||
"openings-hidden-view-dark": "799e791830ab63a1c445b8c77650a577d8e881daa1ff47d1dcf4e238eb1cff16",
|
||||
"lighting-glow-sun-dark": "638ab9fa6856f91581453c732e4059b2a7af47b20d6a58b45a47497876142ea1",
|
||||
"lighting-fill-light-axis-split-dark": "acdbebe845ede66dbbe9de979aad93ba78dc2779d1ec533b238a9bea0d047d1c",
|
||||
"lighting-fill-temp-axis-split-dark": "2348b7c6a8594468664755e2fea1b49126e604192599be0a3965f24a0c2d2765",
|
||||
"lighting-fill-lqi-axis-split-dark": "584adcfb5541b1dddc814c2674bfce8002566bbb667b10501b7ff49fedef6f9c",
|
||||
"hover-over-glow-dark": "3ca354fa94922a3e38ce15886250ae88630a1e9356e4ac07b74b425f063839d7",
|
||||
"hover-nested-room-dark": "472f630768f60c3168f64744af891323a64cf71f36f943fc5a56b4f0494efa70",
|
||||
"large-house-zoom-040-dark": "69593f75f1965daf0a12777fde923ae25c99b42089a73a04cf523b3ea5ce4f0d",
|
||||
"large-house-zoom-250-dark": "21a1a0408c913f477913ae9c0339417ae7aaad7baad8a890eb558f6e36a5a597",
|
||||
"large-house-warm-remount-dark": "f3eb077934701cf11bc828da6a68b55346d499a68c323988938b88aca5f5d9fe",
|
||||
"device-dialog-desktop-en": "c2e1c2864f13b9e27e0d56e3eb7a7b245feab0d543f5533a7b3c4aa34ab2f3eb",
|
||||
"device-dialog-mobile-ru": "5445eb576636362d402f66aa80c3816e01ca7ee75f4ffc86708ff3a43bbfb4a5",
|
||||
"decor-color-popover-mobile-ru": "eab3c5c76b27050dbdbc1627371a16916ea30473934de48842c6ab94a045f9db"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 304 KiB |
@@ -0,0 +1,257 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
|
||||
const themeVars = {
|
||||
dark: {
|
||||
'--primary-color': '#3ea6ff', '--primary-text-color': '#e6e7eb',
|
||||
'--secondary-text-color': '#9aa4ad', '--card-background-color': '#202126',
|
||||
'--ha-card-background': '#202126', '--divider-color': '#3a3d45',
|
||||
},
|
||||
light: {
|
||||
'--primary-color': '#0b73b8', '--primary-text-color': '#202124',
|
||||
'--secondary-text-color': '#5f6368', '--card-background-color': '#ffffff',
|
||||
'--ha-card-background': '#ffffff', '--divider-color': '#d7d9de',
|
||||
},
|
||||
};
|
||||
|
||||
async function stableEnvironment(page, scenario) {
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
|
||||
await page.evaluate(({ variables, theme }) => {
|
||||
let style = document.getElementById('hp-golden-stability');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'hp-golden-stability';
|
||||
style.textContent = `
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
caret-color: transparent !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
html, body { width: 100%; min-height: 100%; overflow: hidden; }
|
||||
body { background: var(--hp-golden-page-bg) !important;
|
||||
font-family: Arial, sans-serif !important; }
|
||||
#host { width: min(100%, 1120px) !important; margin: 0 auto !important;
|
||||
padding: 8px !important; box-sizing: border-box !important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
for (const [name, value] of Object.entries(variables))
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
document.documentElement.style.setProperty(
|
||||
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
|
||||
);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
|
||||
}
|
||||
|
||||
export async function prepareGoldenScenario(page, scenario) {
|
||||
await stableEnvironment(page, scenario);
|
||||
const fixture = fixtureFor(scenario.fixture);
|
||||
if (scenario.deviceName && fixture.devices?.[scenario.deviceId])
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
if (scenario.fillMode) {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
space.settings = { ...(space.settings || {}), fill_mode: scenario.fillMode };
|
||||
}
|
||||
if (scenario.hideOpenings) {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
space.settings = { ...(space.settings || {}), hide_openings: true };
|
||||
}
|
||||
|
||||
return page.evaluate(async ({ fixture, scenario }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error(`golden scenario timed out: ${scenario.id}`);
|
||||
await wait(15);
|
||||
}
|
||||
};
|
||||
window.__goldenCard?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
const cardConfig = {
|
||||
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
|
||||
language: scenario.language || 'en',
|
||||
};
|
||||
const hassFor = () => ({
|
||||
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
|
||||
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
|
||||
devices: fixture.devices || {}, entities: fixture.entities || {},
|
||||
areas: fixture.areas || {}, states: fixture.states || {},
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices || {});
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities || {});
|
||||
if (message.type === 'config_entries/get')
|
||||
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden fixture' }];
|
||||
if (message.type === 'manifest/list')
|
||||
return [{ domain: 'houseplan_golden', name: 'House Plan Golden' }];
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
const mount = async () => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor();
|
||||
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
|
||||
await card.updateComplete;
|
||||
const expectedDevices = Object.keys(fixture.devices || {}).length;
|
||||
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
|
||||
await until(() => card._booting === false);
|
||||
await frame();
|
||||
return card;
|
||||
};
|
||||
|
||||
let card = await mount();
|
||||
if (scenario.warmRemount) {
|
||||
card.remove();
|
||||
await wait(0);
|
||||
card = await mount();
|
||||
}
|
||||
window.__goldenCard = card;
|
||||
if (scenario.space && card._space !== scenario.space) {
|
||||
card._pickSpace(scenario.space);
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.mode) {
|
||||
card._setMode(scenario.mode);
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
card._applyView(scenario.zoom, 500, 500);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.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;
|
||||
const device = card._devices.find((item) => item.id === scenario.deviceId);
|
||||
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
|
||||
card._openMarkerDialog(device);
|
||||
await card.updateComplete;
|
||||
if (scenario.focusDialogClose) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
await dialog?.updateComplete;
|
||||
dialog?.renderRoot?.querySelector('.close')?.focus();
|
||||
}
|
||||
} else if (scenario.dialog === 'decor-color') {
|
||||
card._setMode('decor');
|
||||
card._decorTool = 'select';
|
||||
await card.updateComplete;
|
||||
const shape = card._decorList.find((item) => item.kind === 'line');
|
||||
if (!shape) throw new Error('golden decor line missing');
|
||||
card._decorShapeDbl(new MouseEvent('dblclick'), shape);
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const picker = dialog?.querySelector('hp-color-opacity');
|
||||
await picker?.updateComplete;
|
||||
const trigger = picker?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error('golden decor color trigger missing');
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
return {
|
||||
space: card._space,
|
||||
mode: card._mode,
|
||||
devices: card._devices.length,
|
||||
dialog: !!card.renderRoot.querySelector('hp-dialog'),
|
||||
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) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 3;
|
||||
|
||||
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', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'light', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-fill-lqi-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'lqi', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-over-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 2.5, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
warmRemount: true, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'device-dialog-desktop-en', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-climate',
|
||||
deviceName: 'Living room climate controller with an intentionally long title',
|
||||
focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-climate',
|
||||
deviceName: 'Контроллер климата гостиной с намеренно очень длинным названием',
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'decor-color', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
]);
|
||||
@@ -0,0 +1,13 @@
|
||||
export const GOLDEN_BASELINE_MANIFEST = 'baseline-manifest.json';
|
||||
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
|
||||
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
||||
if (mode === 'verify' && scenarioFilter)
|
||||
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
||||
};
|
||||
|
||||
export const goldenRunFailed = (mode, manifestValid, results) => {
|
||||
if (results.some((result) => result.status === 'error')) return true;
|
||||
return mode === 'verify'
|
||||
&& (!manifestValid || results.some((result) => result.status !== 'passed'));
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
|
||||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||||
import { assertGoldenInvocation, GOLDEN_BASELINE_MANIFEST, goldenRunFailed } from './policy.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
|
||||
const scenarioFilter = process.argv.find((arg) => arg.startsWith('--scenario='))?.slice(11) || '';
|
||||
assertGoldenInvocation(mode, scenarioFilter);
|
||||
const scenarios = scenarioFilter
|
||||
? GOLDEN_SCENARIOS.filter((scenario) => scenario.id === scenarioFilter)
|
||||
: GOLDEN_SCENARIOS;
|
||||
if (!scenarios.length) throw new Error(`unknown golden scenario: ${scenarioFilter}`);
|
||||
|
||||
const artifactRoot = resolve(ROOT, 'artifacts/golden');
|
||||
const actualRoot = resolve(artifactRoot, 'actual');
|
||||
const diffRoot = resolve(artifactRoot, 'diff');
|
||||
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
||||
mkdirSync(actualRoot, { recursive: true });
|
||||
mkdirSync(diffRoot, { recursive: true });
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
async function comparePng(page, actual, baseline, threshold) {
|
||||
return page.evaluate(async ({ actual64, baseline64, threshold }) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const [actualImage, baselineImage] = await Promise.all([decode(actual64), decode(baseline64)]);
|
||||
if (actualImage.width !== baselineImage.width || actualImage.height !== baselineImage.height) {
|
||||
return {
|
||||
dimensionsMatch: false,
|
||||
actualSize: [actualImage.width, actualImage.height],
|
||||
baselineSize: [baselineImage.width, baselineImage.height],
|
||||
differingPixels: null,
|
||||
diffRatio: 1,
|
||||
maxObservedDelta: 255,
|
||||
passed: false,
|
||||
diffPngBase64: null,
|
||||
};
|
||||
}
|
||||
const width = actualImage.width;
|
||||
const height = actualImage.height;
|
||||
const canvas = document.createElement('canvas');
|
||||
const baselineCanvas = document.createElement('canvas');
|
||||
const diffCanvas = document.createElement('canvas');
|
||||
for (const item of [canvas, baselineCanvas, diffCanvas]) {
|
||||
item.width = width;
|
||||
item.height = height;
|
||||
}
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const baselineContext = baselineCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const diffContext = diffCanvas.getContext('2d');
|
||||
context.drawImage(actualImage, 0, 0);
|
||||
baselineContext.drawImage(baselineImage, 0, 0);
|
||||
const actualData = context.getImageData(0, 0, width, height).data;
|
||||
const baselineData = baselineContext.getImageData(0, 0, width, height).data;
|
||||
const diff = diffContext.createImageData(width, height);
|
||||
let differingPixels = 0;
|
||||
let maxObservedDelta = 0;
|
||||
for (let offset = 0; offset < actualData.length; offset += 4) {
|
||||
let delta = 0;
|
||||
for (let channel = 0; channel < 4; channel++)
|
||||
delta = Math.max(delta, Math.abs(actualData[offset + channel] - baselineData[offset + channel]));
|
||||
maxObservedDelta = Math.max(maxObservedDelta, delta);
|
||||
if (delta > threshold.maxChannelDelta) {
|
||||
differingPixels++;
|
||||
diff.data[offset] = 255;
|
||||
diff.data[offset + 1] = 0;
|
||||
diff.data[offset + 2] = 180;
|
||||
diff.data[offset + 3] = 255;
|
||||
} else {
|
||||
const gray = Math.round((actualData[offset] + actualData[offset + 1] + actualData[offset + 2]) / 9);
|
||||
diff.data[offset] = gray;
|
||||
diff.data[offset + 1] = gray;
|
||||
diff.data[offset + 2] = gray;
|
||||
diff.data[offset + 3] = 90;
|
||||
}
|
||||
}
|
||||
diffContext.putImageData(diff, 0, 0);
|
||||
const diffRatio = differingPixels / (width * height);
|
||||
return {
|
||||
dimensionsMatch: true,
|
||||
actualSize: [width, height],
|
||||
baselineSize: [width, height],
|
||||
differingPixels,
|
||||
diffRatio,
|
||||
maxObservedDelta,
|
||||
passed: diffRatio <= threshold.maxDiffRatio,
|
||||
diffPngBase64: differingPixels
|
||||
? diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length)
|
||||
: null,
|
||||
};
|
||||
}, {
|
||||
actual64: actual.toString('base64'),
|
||||
baseline64: baseline.toString('base64'),
|
||||
threshold,
|
||||
});
|
||||
}
|
||||
|
||||
let baselineManifest = null;
|
||||
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
||||
if (existsSync(baselineManifestPath)) {
|
||||
try { baselineManifest = JSON.parse(readFileSync(baselineManifestPath, 'utf8')); }
|
||||
catch { baselineManifest = { invalid: true }; }
|
||||
}
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1000, height: 900 },
|
||||
1,
|
||||
['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'],
|
||||
{ locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' },
|
||||
);
|
||||
const chromium = await browser.version();
|
||||
const results = [];
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
let buildFingerprint = null;
|
||||
try {
|
||||
buildFingerprint = await assertFreshDemoBundle(page, ROOT);
|
||||
for (const scenario of scenarios) {
|
||||
const result = {
|
||||
id: scenario.id,
|
||||
threshold: scenario.threshold,
|
||||
status: 'error',
|
||||
actualSha256: null,
|
||||
};
|
||||
try {
|
||||
pageErrors.length = 0;
|
||||
result.runtime = await prepareGoldenScenario(page, scenario);
|
||||
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
|
||||
const clip = await goldenClip(page, scenario.capture);
|
||||
const actual = await page.screenshot({
|
||||
...(clip ? { clip } : {}),
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
scale: 'css',
|
||||
});
|
||||
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
|
||||
writeFileSync(actualPath, actual);
|
||||
result.actualSha256 = sha256(actual);
|
||||
result.actual = actualPath;
|
||||
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
|
||||
result.baseline = baselinePath;
|
||||
if (!existsSync(baselinePath)) {
|
||||
result.status = 'missing-baseline';
|
||||
} else {
|
||||
const baseline = readFileSync(baselinePath);
|
||||
const expectedBaselineSha256 = baselineManifest?.scenarios?.[scenario.id];
|
||||
result.baselineSha256 = sha256(baseline);
|
||||
if (!expectedBaselineSha256 || result.baselineSha256 !== expectedBaselineSha256) {
|
||||
result.status = 'invalid-baseline';
|
||||
result.error = expectedBaselineSha256
|
||||
? 'baseline PNG does not match its reviewed manifest hash'
|
||||
: 'baseline PNG is not listed in the reviewed manifest';
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
continue;
|
||||
}
|
||||
const comparison = await comparePng(page, actual, baseline, scenario.threshold);
|
||||
Object.assign(result, comparison);
|
||||
result.status = comparison.passed ? 'passed' : 'different';
|
||||
if (comparison.diffPngBase64) {
|
||||
const diffPath = resolve(diffRoot, `${scenario.id}.png`);
|
||||
writeFileSync(diffPath, Buffer.from(comparison.diffPngBase64, 'base64'));
|
||||
result.diff = diffPath;
|
||||
}
|
||||
delete result.diffPngBase64;
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const manifestValid = !!baselineManifest
|
||||
&& !baselineManifest.invalid
|
||||
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
|
||||
&& baselineManifest.chromium === chromium
|
||||
&& GOLDEN_SCENARIOS.every((scenario) => typeof baselineManifest.scenarios?.[scenario.id] === 'string');
|
||||
const report = {
|
||||
schema: 1,
|
||||
mode,
|
||||
generatedAt: new Date().toISOString(),
|
||||
matrixVersion: GOLDEN_MATRIX_VERSION,
|
||||
buildFingerprint,
|
||||
chromium,
|
||||
baselineManifest: baselineManifest ? {
|
||||
present: true,
|
||||
valid: manifestValid,
|
||||
matrixVersion: baselineManifest.matrixVersion ?? null,
|
||||
chromium: baselineManifest.chromium ?? null,
|
||||
} : { present: false, valid: false, matrixVersion: null, chromium: null },
|
||||
results,
|
||||
};
|
||||
writeFileSync(resolve(artifactRoot, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (goldenRunFailed(mode, manifestValid, results)) process.exitCode = 1;
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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 contract
|
||||
|
||||
The `performance` job 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 `large-house-performance` artifact, and the table is written to the GitHub
|
||||
job summary.
|
||||
|
||||
This base-vs-candidate design intentionally does not compare timings captured
|
||||
on different machines or different Chromium builds. A runtime/profile mismatch
|
||||
fails closed.
|
||||
|
||||
## 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.
|
||||
|
||||
The absolute switch-cycle/Long-Task ceilings include roughly 20–30% headroom
|
||||
over the paired 2026-08-09 Ubuntu run where the unchanged base and candidate
|
||||
both reached about 5.3 s / 2.45 s / 22 tasks / 9.9 s total under runner load.
|
||||
The same-runner relative checks remain tighter for an actual candidate-only
|
||||
regression; this prevents an overloaded but symmetric runner from turning an
|
||||
absolute safety ceiling into a flaky code-regression signal.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/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 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: readJson(baselinePath),
|
||||
budgets: readJson(budgetsPath),
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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 both stable absolute ceilings and a
|
||||
* report captured from the base SHA on the same runner.
|
||||
*/
|
||||
export const evaluatePerformanceBudget = ({ candidate, baseline, budgets }) => {
|
||||
requireReport(candidate, budgets, 'candidate');
|
||||
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 = baseline.summary?.[metric]?.[stat];
|
||||
if (!finite(actual) || !finite(base)) throw new Error(`missing ${stat} for ${metric}`);
|
||||
const regressionLimit = relativeLimit(base, budget.maxRegressionRatio, budget.noiseAllowanceMs);
|
||||
checks.push(makeCheck(
|
||||
`timing.${metric}.${stat}`,
|
||||
actual,
|
||||
Math.min(budget.hardMaxMs, regressionLimit),
|
||||
{ baseline: round(base), hardLimit: budget.hardMaxMs, regressionLimit: round(regressionLimit) },
|
||||
));
|
||||
}
|
||||
|
||||
const candidateLong = candidate.longTasks ?? summarizeLongTasks(candidate.rows);
|
||||
const baselineLong = 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 = relativeLimit(
|
||||
baselineLong.maxSingleMs,
|
||||
budgets.longTasks.maxSingleRegressionRatio,
|
||||
budgets.longTasks.maxSingleNoiseAllowanceMs,
|
||||
);
|
||||
checks.push(makeCheck(
|
||||
'longTask.maxSingleMs',
|
||||
candidateLong.maxSingleMs,
|
||||
Math.min(budgets.longTasks.maxSingleMs, singleRegressionLimit),
|
||||
{ baseline: baselineLong.maxSingleMs, hardLimit: budgets.longTasks.maxSingleMs },
|
||||
));
|
||||
const countRegressionLimit = relativeLimit(
|
||||
baselineLong.countP95,
|
||||
budgets.longTasks.maxCountRegressionRatio,
|
||||
budgets.longTasks.countNoiseAllowance,
|
||||
);
|
||||
checks.push(makeCheck(
|
||||
'longTask.countP95',
|
||||
candidateLong.countP95,
|
||||
Math.min(budgets.longTasks.maxCountP95, countRegressionLimit),
|
||||
{ baseline: baselineLong.countP95, hardLimit: budgets.longTasks.maxCountP95 },
|
||||
));
|
||||
const longRegressionLimit = relativeLimit(
|
||||
baselineLong.totalP95Ms,
|
||||
budgets.longTasks.maxTotalRegressionRatio,
|
||||
budgets.longTasks.noiseAllowanceMs,
|
||||
);
|
||||
checks.push(makeCheck(
|
||||
'longTask.totalP95Ms',
|
||||
candidateLong.totalP95Ms,
|
||||
Math.min(budgets.longTasks.maxTotalP95Ms, longRegressionLimit),
|
||||
{ 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 = 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 || !baselineHeap.length)) {
|
||||
checks.push({ id: 'heap.available', actual: candidateHeap.length, limit: 1, pass: false });
|
||||
} else if (candidateHeap.length && baselineHeap.length) {
|
||||
const actual = percentile(candidateHeap, 0.95);
|
||||
const base = percentile(baselineHeap, 0.95);
|
||||
const regressionLimit = relativeLimit(base, budgets.heap.maxRegressionRatio, budgets.heap.noiseAllowanceBytes);
|
||||
checks.push(makeCheck(
|
||||
'heap.growthP95Bytes',
|
||||
actual,
|
||||
Math.min(budgets.heap.hardMaxGrowthBytes, regressionLimit),
|
||||
{ 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,
|
||||
candidateFingerprint: candidate.buildFingerprint,
|
||||
baselineFingerprint: baseline.buildFingerprint,
|
||||
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`;
|
||||
};
|
||||
@@ -6,21 +6,68 @@ import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||||
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
||||
page.on('pageerror', (e) => console.log('EXC', e.message));
|
||||
// ---- assertion harness (audit T1) --------------------------------------
|
||||
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
|
||||
// build reported success. `check()` accumulates named failures, `finish()`
|
||||
// prints them and sets the exit code.
|
||||
const _failures = [];
|
||||
let _pageErrors = 0;
|
||||
|
||||
/** Assert one named fact. `expected` defaults to true. */
|
||||
export function check(name, actual, expected = true) {
|
||||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Assert a whole result object: every key must equal true unless listed. */
|
||||
export function checkAll(out, expected = {}) {
|
||||
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Print the result, report failures, close the browser, set the exit code. */
|
||||
export async function finish(browser, out) {
|
||||
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
|
||||
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
|
||||
await browser?.close?.();
|
||||
if (_failures.length) {
|
||||
console.error('\nFAILED (' + _failures.length + '):');
|
||||
for (const f of _failures) console.error(' - ' + f);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('OK');
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
});
|
||||
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||||
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
|
||||
// the stage height settles — real pointer interaction cannot hit a hidden
|
||||
// plan, so every smoke starts where the user does: with the plan revealed.
|
||||
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
|
||||
// hass flows continuously in production; the stub sets it once — nudge a rebuild
|
||||
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
|
||||
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
|
||||
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
|
||||
// finger-sized corner handles, and the live "W × H" badge
|
||||
// stating the picture's real size through cell_cm;
|
||||
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
|
||||
// the ROOM CONTOURS, so the scene colour is visible around
|
||||
// them and under the parts of the picture nobody has drawn
|
||||
// over. The picture is scaled down here so both are obvious.
|
||||
// Usage: node demo/shot_backdrop.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const settle = () => page.waitForTimeout(260);
|
||||
|
||||
// ---- 1) the frame, mid-drag ---------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('decor');
|
||||
c._decorTool = 'backdrop';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
{
|
||||
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
|
||||
// the frame AND the live badge at once
|
||||
const [ax, ay] = await screenPt(1000, 900);
|
||||
const [bx, by] = await screenPt(820, 760);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
|
||||
await page.mouse.up();
|
||||
}
|
||||
await settle();
|
||||
|
||||
// ---- 2) the paper is the rooms ------------------------------------------
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
// a picture pulled well inside the plan and an acid scene colour: what shows
|
||||
// through around the rooms is the SCENE, which is the whole new rule
|
||||
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
|
||||
c._cfgEpoch++; c._frame = null;
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._fitAll();
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
await settle();
|
||||
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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_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',
|
||||
attributes: { friendly_name: 'Curtain', device_class: 'curtain' } } } };
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
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.activity-transition').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_move.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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 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);
|
||||
const STATES = ['closed', 'open', 'opening', 'closing'];
|
||||
await page.evaluate(async (STATES) => {
|
||||
const c = window.__card;
|
||||
const devices = {}; const entities = {}; const states = {};
|
||||
STATES.forEach((s, i) => {
|
||||
const id = 'd_cur' + i;
|
||||
devices[id] = { id, name: 'Curtain ' + s, model: 'Roller shade driver E1',
|
||||
area_id: 'garden', identifiers: [['demo', id]], entry_type: null, via_device_id: null };
|
||||
entities['cover.cur' + i] = { entity_id: 'cover.cur' + i, device_id: id, platform: 'demo' };
|
||||
states['cover.cur' + i] = { entity_id: 'cover.cur' + i, state: s,
|
||||
attributes: { friendly_name: 'Curtain ' + s, device_class: 'curtain' } };
|
||||
});
|
||||
c.hass = { ...c.hass,
|
||||
devices: { ...c.hass.devices, ...devices },
|
||||
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', display: 'icon_ripple' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
const layout = { ...c._layout };
|
||||
STATES.forEach((s, i) => {
|
||||
const d = c._devices.find((x) => x.bindingRef === 'd_cur' + i);
|
||||
if (d) layout[d.id] = { s: 'garden', x: 0.18 + i * 0.215, y: 0.5 };
|
||||
});
|
||||
c._layout = layout;
|
||||
c._setMode('view'); c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
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);
|
||||
// captions under each badge, so the four states are readable in the file
|
||||
const box = await page.evaluate((STATES) => {
|
||||
const c = window.__card;
|
||||
const els = [...(c.shadowRoot || c.renderRoot).querySelectorAll('.devlayer .dev')]
|
||||
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
|
||||
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cap = document.createElement('div');
|
||||
cap.textContent = STATES[i];
|
||||
cap.style.cssText = `position:fixed;left:${r.x + r.width / 2}px;top:${r.y + r.height + 10}px;`
|
||||
+ 'transform:translateX(-50%);font:600 13px system-ui,sans-serif;color:#1c2530;'
|
||||
+ 'letter-spacing:.02em;z-index:9999;padding:2px 7px;border-radius:6px;'
|
||||
+ 'background:rgba(255,255,255,0.88);white-space:nowrap;';
|
||||
document.body.appendChild(cap);
|
||||
minX = Math.min(minX, r.x); maxX = Math.max(maxX, r.x + r.width);
|
||||
minY = Math.min(minY, r.y); maxY = Math.max(maxY, r.y + r.height);
|
||||
});
|
||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
||||
}, STATES);
|
||||
const pad = 55;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/cover_states.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad),
|
||||
width: box.w + pad * 2, height: box.h + pad * 2 + 22 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,36 @@
|
||||
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = ''; // hand-drawn plan: white paper — the risky case for a white day
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
});
|
||||
const shot = async (az, el, name) => {
|
||||
await page.evaluate(async ([az, el]) => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(300);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
|
||||
};
|
||||
await shot(180, 60, 'dn_noon');
|
||||
await shot(265, 3, 'dn_sunset');
|
||||
await shot(0, -20, 'dn_night');
|
||||
await browser.close();
|
||||
console.log('shots done');
|
||||
@@ -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);
|
||||
@@ -0,0 +1,34 @@
|
||||
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) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'binary_sensor.hall_motion': { entity_id: 'binary_sensor.hall_motion', state,
|
||||
attributes: { friendly_name: 'Motion', device_class: 'motion', linkquality: 64 } } } };
|
||||
await c.updateComplete;
|
||||
};
|
||||
await set('off');
|
||||
await set('on');
|
||||
// freeze the pulse at a visible frame for a deterministic capture
|
||||
const st = document.createElement('style');
|
||||
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.activity-event');
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/motion_sense.png',
|
||||
clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.w + pad * 2, height: box.h + pad * 2 } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,27 @@
|
||||
// Capture: the shoulder rulers + centre tick while PLACING a new opening
|
||||
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [];
|
||||
c._setMode('plan'); c._tool = 'opening';
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const pt = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const stage = c.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
|
||||
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
|
||||
});
|
||||
await page.mouse.move(pt.x, pt.y, { steps: 4 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
|
||||
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
|
||||
await browser.close();
|
||||
console.log('shot ok');
|
||||
@@ -0,0 +1,45 @@
|
||||
// Owner 2026-08-04: «углы границ комнат всё ещё с зубцами». A miter join on a
|
||||
// sharp corner shoots a spike far past the walls that meet there; past the
|
||||
// miter limit it flips to a flat bevel. Room borders now join round, like the
|
||||
// decor lines already did. Two rooms make the defect obvious: an L (a concave
|
||||
// 90° corner plus the convex ones) and a wedge with a ~45° apex — the angle
|
||||
// where the miter spike is longest before the miter limit clips it to a bevel.
|
||||
// Usage: node demo/shot_room_joins.mjs <outdir> <name>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const name = process.argv[3] || 'room_joins';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 6);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
delete sp.plan_url;
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: '', area: 'living_room',
|
||||
poly: [[0.06, 0.04], [0.52, 0.04], [0.52, 0.14], [0.94, 0.14], [0.94, 0.26], [0.06, 0.26]] },
|
||||
{ id: 'rW', name: '', area: 'kitchen',
|
||||
poly: [[0.20, 0.318], [0.90, 0.55], [0.20, 0.782]] },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, show_names: false,
|
||||
fill_mode: 'none', room_color: '#3ea6ff', room_opacity: 1 };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
// crop tight around the wedge's ~28° apex: at plan scale the whole stage hides
|
||||
// a 5-unit spike, and the spike is the entire point of the shot
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const svg = c.shadowRoot.querySelector('.stage svg');
|
||||
const poly = [...svg.querySelectorAll('polygon.room')].pop();
|
||||
const pts = poly.getAttribute('points').split(' ').map((s) => s.split(',').map(Number));
|
||||
const apex = pts.reduce((m, p) => (p[0] > m[0] ? p : m), pts[0]);
|
||||
const m = svg.getScreenCTM();
|
||||
return { x: m.a * apex[0] + m.c * apex[1] + m.e, y: m.b * apex[0] + m.d * apex[1] + m.f };
|
||||
});
|
||||
await page.screenshot({ path: `${outDir}/${name}.png`,
|
||||
clip: { x: box.x - 62, y: box.y - 30, width: 72, height: 60 } });
|
||||
await browser.close();
|
||||
console.log('shot written to ' + outDir + '/' + name + '.png');
|
||||
@@ -0,0 +1,53 @@
|
||||
// Screenshots for docs/SUN.md: evening wedges over the glow fill + the compass
|
||||
// dial in the general settings. Usage: node demo/shot_sun.mjs <outdir>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// instant colors for the still shot: the day/night transition is tens of seconds
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, 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 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = {
|
||||
...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight',
|
||||
};
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
// a warm evening: the sun low in the west; the living-room light off so
|
||||
// the glow pool does not swamp the west wedge
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: 265, elevation: 3 },
|
||||
},
|
||||
} };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: outDir + '/sun_rays.png' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._openSettingsDialog();
|
||||
c._settingsDialog = { ...c._settingsDialog, northDeg: 330 };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
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);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Brightness check for the wedges (owner 2026-08-03: «поярче, иногда плохо
|
||||
// видны»). Two hardest cases side by side: a DAYLIGHT sun on white paper (the
|
||||
// wedge colour is nearly neutral there) and a low sun over the dark glow
|
||||
// canvas. Usage: node demo/shot_sun_bright.mjs <outdir> [suffix]
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] ? '_' + process.argv[3] : '';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
// the fade is a 2 s animation; reduced motion pins the layer at full opacity
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
|
||||
const setup = (fill, azimuth, elevation, ceiling) => page.evaluate(async ([fill, azimuth, elevation, ceiling]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, 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 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: fill };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: ceiling },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [fill, azimuth, elevation, ceiling]);
|
||||
|
||||
const shoot = async (name) => {
|
||||
await page.waitForTimeout(400);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}${tag}.png` });
|
||||
};
|
||||
|
||||
// 1) plain daylight on white paper: neutral wedge colour, brightest canvas
|
||||
await setup('none', 265, 24, 'off');
|
||||
await shoot('sun_bright_paper');
|
||||
// 2) a low warm sun over the dark glow canvas
|
||||
await setup('glow', 265, 4, 'off');
|
||||
await shoot('sun_bright_glow');
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Stills for the 2026-08-04 ray report: 30 % shorter shafts with SHARP sides
|
||||
// that dissolve only along the ray (docs/SUN.md). The low-sun frame is the
|
||||
// interesting one — the north and south windows get a nearly wall-grazing sun,
|
||||
// the case that used to hang a bright kerb across the floor. Usage:
|
||||
// node demo/shot_sun_short.mjs <outdir> <prefix>
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const tag = process.argv[3] || 'sun_rays_short';
|
||||
const { page, browser } = await launch({ width: 900, height: 860 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no 45 s sky glide
|
||||
|
||||
const setup = async (az, el) => {
|
||||
await page.evaluate(async ([azimuth, elevation]) => {
|
||||
const c = window.__card;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, 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 },
|
||||
];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, fill_mode: 'glow' };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}),
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth, elevation } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, [az, el]);
|
||||
await page.waitForTimeout(350);
|
||||
};
|
||||
|
||||
// low sun in the west — the long shafts, the case that hit the far walls
|
||||
await setup(265, 5);
|
||||
let stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_low.png` });
|
||||
|
||||
// high southern sun — the short noon pools
|
||||
await setup(180, 55);
|
||||
stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${tag}_high.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const g = c._gridPitch;
|
||||
const guides = () => sr().querySelectorAll('.alignline').length;
|
||||
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
const v = poly[0];
|
||||
c._path = [[v[0] + g * 10, v[1] + g * 10]];
|
||||
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.drawGuide = guides() >= 1;
|
||||
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
|
||||
// нет выравнивания → нет гидов
|
||||
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGuideOffAxis = guides() === 0;
|
||||
// 2) бейдж угла: 45° красит
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml = sr().querySelector('.measurelabel');
|
||||
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml2 = sr().querySelector('.measurelabel');
|
||||
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
|
||||
c._path = []; c._cursorPt = null;
|
||||
// 3) редактор устройств: drag значка в линию с другим значком
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const devs = c._devices.filter((d) => d.space === c._space);
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
c._drag = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuideGone = guides() === 0;
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
|
||||
// 5) в Просмотре гидов не бывает
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noneInView = guides() === 0;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Аудит v1.49.0: HP-1490-03 (редакторы видят весь холст) и HP-1490-04
|
||||
// (Save ждёт пропорции выбранного сохранённого плана, старые не наследуются).
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 1000 }, 1);
|
||||
const out = {};
|
||||
|
||||
// ---- HP-1490-03: content-fit только в просмотре -------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
// рукописное пространство: одна маленькая комната в центре квадрата
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].plan_url = null; cfg.spaces[0].plan_aspect = null;
|
||||
cfg.spaces[0].rooms = [{ id: 'r1', name: 'One', area: 'living_room',
|
||||
poly: [[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]] }];
|
||||
c._serverCfg = cfg; c._model = null; c._view = null; c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inView = c._baseVb();
|
||||
o.viewIsContentFit = inView[2] < 999; // меньше холста (устройства тоже содержимое)
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
|
||||
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
|
||||
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
|
||||
// даёт запас панорамирования (§5) и зум-аут до 3x.
|
||||
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
|
||||
o.editorCanPanPastContent = (() => {
|
||||
const fit = c._viewOr(c._baseVb());
|
||||
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
|
||||
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
|
||||
})();
|
||||
o.editorZoomOutGivesRoom = (() => {
|
||||
c._resetZoom(); c._applyView(1 / 3);
|
||||
const wide = c._view.w; c._resetZoom();
|
||||
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
|
||||
})();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const back = c._baseVb();
|
||||
o.contentFitRestored = back[2] < 999;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- HP-1490-04: Save ждёт aspect --------------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
let saved = null;
|
||||
let signDelay = 500; // подпись приходит поздно
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: [
|
||||
{ name: 'wide.svg', url: '/api/houseplan/content/plans/_/wide.svg', size: 10, modified: 1, used_by: [] },
|
||||
] };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
await new Promise((r) => setTimeout(r, signDelay));
|
||||
const urls = {}; for (const p of m.paths) urls[p] = '/assets/wide.svg'; return { urls };
|
||||
}
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
if (m.type === 'houseplan/config/get') return { config: saved || c._serverCfg, rev: c._cfgRev || 0 };
|
||||
return base(m);
|
||||
} };
|
||||
// страница отдаёт /assets/wide.svg размером 800x200 (создан рядом)
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
|
||||
await c.updateComplete;
|
||||
c._useServerPlan('/api/houseplan/content/plans/_/wide.svg');
|
||||
o.oldAspectCleared = c._spaceDialog.savedAspect === undefined;
|
||||
// Save сразу, до прихода подписи
|
||||
const p = c._saveSpaceDialog();
|
||||
await p;
|
||||
o.savedUrl = saved?.spaces?.[0]?.plan_url === '/api/houseplan/content/plans/_/wide.svg';
|
||||
const a = saved?.spaces?.[0]?.plan_aspect;
|
||||
o.savedAspectIsReal = Math.abs((a || 0) - 4) < 0.01; // 800x200
|
||||
o.notTheOldAspect = a !== 1.25;
|
||||
return o;
|
||||
}));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
|
||||
*
|
||||
* The whole contract, on the demo's f1 — which IS an image plan, so every
|
||||
* assertion here lands on exactly the case that used to behave differently:
|
||||
* • the transform frame exists in the backdrop editor and nowhere else;
|
||||
* • 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 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;
|
||||
* • the static card reads the same three fields.
|
||||
*/
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
|
||||
const restore = () => page.evaluate((s) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(s);
|
||||
c._bdDrag = null;
|
||||
c._cfgEpoch++; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, snap);
|
||||
|
||||
const mode = async (m) => {
|
||||
await page.evaluate((m) => {
|
||||
const c = window.__card;
|
||||
c._setMode(m); c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, 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,
|
||||
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(() => {
|
||||
const im = window.__card.renderRoot.querySelector('.stage svg image');
|
||||
if (!im) return null;
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const frameRect = () => page.evaluate(() => {
|
||||
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
|
||||
if (!b) return null;
|
||||
const a = (n) => Number(b.getAttribute(n));
|
||||
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
|
||||
});
|
||||
const badges = () => page.evaluate(() =>
|
||||
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
|
||||
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
|
||||
const screenPt = (x, y) => page.evaluate(([x, y]) => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const r = stage.getBoundingClientRect();
|
||||
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
|
||||
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
|
||||
}, [x, y]);
|
||||
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
|
||||
|
||||
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
|
||||
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
|
||||
const PITCH = 1000 / 240;
|
||||
// "on a node", allowing for the 6 decimals the normalised config keeps
|
||||
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
|
||||
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
|
||||
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
|
||||
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
|
||||
|
||||
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
|
||||
await mode('view');
|
||||
await settle();
|
||||
check('no_frame_in_view', await q('.bdframe'), 0);
|
||||
await mode('plan');
|
||||
await settle();
|
||||
check('no_frame_in_plan', await q('.bdframe'), 0);
|
||||
const tool = (t) => page.evaluate((t) => {
|
||||
const c = window.__card;
|
||||
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
|
||||
return c.updateComplete && true;
|
||||
}, t);
|
||||
|
||||
await mode('decor');
|
||||
await settle();
|
||||
// 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'), 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('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();
|
||||
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
|
||||
await tool('backdrop');
|
||||
await settle();
|
||||
|
||||
// ---------- 2) drag the body: the picture moves, the plan does not --------
|
||||
const roomsBefore = await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
// +102 / +52 render units: neither is a whole number of grid steps, so the
|
||||
// snap has something to do (24 steps = 100, 12 steps = 50)
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
const live = await badges();
|
||||
check('live_badge_while_dragging', live.length, 1);
|
||||
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
|
||||
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
|
||||
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
|
||||
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
}
|
||||
check('badge_gone_after_release', (await badges()).length, 0);
|
||||
{
|
||||
const cfg = await spaceCfg();
|
||||
const im = await imageRect();
|
||||
// the gesture asked for +102/+52 render units; the pointer arrives in whole
|
||||
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
|
||||
// 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', 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).
|
||||
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
|
||||
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
|
||||
// or the picture would run away from the finger.
|
||||
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
|
||||
check('frame_follows_the_picture', await frameRect(), im);
|
||||
}
|
||||
check('the_plan_geometry_never_moved', await page.evaluate(() =>
|
||||
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
|
||||
|
||||
// ---------- 3) Shift cannot step off the grid -----------------------------
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
const [ax, ay] = await screenPt(500, 500);
|
||||
const [bx, by] = await screenPt(602, 552);
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up('Shift');
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
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 ----
|
||||
await restore();
|
||||
await settle();
|
||||
{
|
||||
// the bottom-right corner of the default rect is (1000, 900); pull it in to
|
||||
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
|
||||
// stay 1.25, whatever the vertical component of the gesture says
|
||||
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
|
||||
const [bx, by] = await screenPt(700, 600);
|
||||
await page.mouse.move(ax, ay);
|
||||
await page.mouse.down();
|
||||
await settle();
|
||||
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
|
||||
await settle();
|
||||
check('live_badge_while_scaling', (await badges()).length, 1);
|
||||
await page.mouse.move(bx, by, { steps: 3 });
|
||||
await settle();
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
const im = await imageRect();
|
||||
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
|
||||
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('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;
|
||||
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
|
||||
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
|
||||
c.requestUpdate();
|
||||
return c.updateComplete.then(() =>
|
||||
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
|
||||
}), `${metres(im.w)} × ${metres(im.h)}`);
|
||||
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
|
||||
}
|
||||
|
||||
// ---------- 5) the reset button ------------------------------------------
|
||||
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, 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
|
||||
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
|
||||
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
|
||||
await tool('select');
|
||||
await settle();
|
||||
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const before = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
|
||||
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
const after = { ...c._viewOr(c._baseVb()) };
|
||||
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
|
||||
await c.updateComplete;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
|
||||
}), true);
|
||||
|
||||
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
|
||||
await mode('view');
|
||||
await settle();
|
||||
const layers = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const m = c._spaceModel();
|
||||
const svgEl = c.renderRoot.querySelector('.stage svg');
|
||||
const kids = [...svgEl.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
const papers = [...svgEl.querySelectorAll('.hp-paper')];
|
||||
return {
|
||||
paperShapes: papers.length,
|
||||
rooms: m.rooms.length,
|
||||
// not one paper shape the size of the picture any more
|
||||
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
|
||||
};
|
||||
});
|
||||
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
|
||||
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;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
|
||||
c._cfgEpoch++; c._frame = null; c.requestUpdate();
|
||||
return c.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
const fit = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
c._fitAll();
|
||||
const b = c._baseVb();
|
||||
const bg = c._spaceModel().bg;
|
||||
return { b, bg };
|
||||
});
|
||||
check('the_frame_reaches_the_moved_picture',
|
||||
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
|
||||
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
|
||||
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
|
||||
check('and_the_moved_picture_is_where_the_transform_says',
|
||||
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
|
||||
|
||||
// ---------- 8) the static card reads the same three fields ---------------
|
||||
const stat = await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const main = window.__card;
|
||||
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
|
||||
const hass = { ...main.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return { ok: true };
|
||||
} };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const card = document.createElement('houseplan-space-card');
|
||||
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
card.hass = hass;
|
||||
host.appendChild(card);
|
||||
const t0 = Date.now();
|
||||
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const root = card.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const im = root.querySelector('image');
|
||||
const a = (n) => Number(im.getAttribute(n));
|
||||
const kids = [...root.children];
|
||||
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
return {
|
||||
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
|
||||
papers: root.querySelectorAll('.hp-paper').length,
|
||||
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
|
||||
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
|
||||
iPaper: idx('.hp-paper'), iImage: idx('image'),
|
||||
};
|
||||
});
|
||||
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
|
||||
check('static_card_papers_the_rooms', stat.papers, 4);
|
||||
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
|
||||
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
|
||||
|
||||
await restore();
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,366 @@
|
||||
// New setting "background color around the plan": global (config.settings.bg_color,
|
||||
// gear dialog) with a per-space override (space dialog, empty = inherit), applied
|
||||
// to the stage in view and kiosk modes and to the static space-card. Editors keep
|
||||
// their own canvas; the open dialogs preview the pending color live.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const stageBg = async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
|
||||
};
|
||||
const waitStageBg = async (accept) => {
|
||||
const deadline = performance.now() + 700;
|
||||
let value = await stageBg();
|
||||
while (!accept(value) && performance.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
value = await stageBg();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
||||
|
||||
const themeBg = await stageBg(); // theme default from the stylesheet
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
|
||||
// 1) global setting colors the stage
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#224466' };
|
||||
out.globalApplies = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 2) per-space override wins
|
||||
sp.settings = { ...(sp.settings || {}), bg_color: '#663311' };
|
||||
out.spaceOverrides = (await stageBg()) === rgb('#663311');
|
||||
|
||||
// 3) clearing the space setting inherits the global again
|
||||
delete sp.settings.bg_color;
|
||||
out.clearInheritsGlobal = (await stageBg()) === rgb('#224466');
|
||||
|
||||
// 4) clearing the global returns the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
out.clearRestoresTheme = (await stageBg()) === themeBg;
|
||||
|
||||
// 5) invalid stored value is ignored (renderer never trusts garbage)
|
||||
c._serverCfg.settings.bg_color = 'url(javascript:alert(1))';
|
||||
out.garbageIgnored = (await stageBg()) === themeBg;
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
|
||||
// 6) the settings dialog previews its pending color live + saves it
|
||||
let saved = null;
|
||||
const origWS = c.hass.callWS;
|
||||
c.hass.callWS = async (m) => {
|
||||
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
|
||||
return origWS.call(c.hass, m);
|
||||
};
|
||||
c._openSettingsDialog();
|
||||
await c.updateComplete;
|
||||
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
|
||||
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
|
||||
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
|
||||
await c._saveSettingsDialog();
|
||||
out.dialogSaves = saved?.settings?.bg_color === '#0a2a4a';
|
||||
out.savedApplies = (await stageBg()) === rgb('#0a2a4a');
|
||||
|
||||
// 7) the space dialog previews and saves the override
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: '#4a0a2a' };
|
||||
out.spaceDialogPreviews = (await stageBg()) === rgb('#4a0a2a');
|
||||
saved = null;
|
||||
await c._saveSpaceDialog();
|
||||
const savedSp = saved?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogSaves = savedSp?.settings?.bg_color === '#4a0a2a';
|
||||
out.spaceSavedApplies = (await stageBg()) === rgb('#4a0a2a');
|
||||
|
||||
// ...and clearing it in the dialog inherits the global again
|
||||
c._openSpaceDialog('edit', c._space);
|
||||
await c.updateComplete;
|
||||
out.spaceDialogPrefilled = c._spaceDialog.bgColor === '#4a0a2a';
|
||||
c._spaceDialog = { ...c._spaceDialog, bgColor: null };
|
||||
await c._saveSpaceDialog();
|
||||
// over the wire `undefined` keys vanish — check the JSON form, like the backend sees it
|
||||
const wire = JSON.parse(JSON.stringify(saved));
|
||||
const savedSp2 = wire?.spaces?.find((s) => s.id === c._space);
|
||||
out.spaceDialogClears = !('bg_color' in (savedSp2?.settings || {}));
|
||||
out.backToGlobal = (await stageBg()) === rgb('#0a2a4a');
|
||||
c.hass.callWS = origWS;
|
||||
|
||||
// 8) editors keep their own canvas (inline bg only in view/kiosk)
|
||||
c._setMode('decor');
|
||||
const editorBg = await waitStageBg((value) => value !== rgb('#0a2a4a'));
|
||||
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
|
||||
c._setMode('view');
|
||||
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
|
||||
|
||||
// 9) kiosk instance respects the setting
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
k.hass = c.hass;
|
||||
k.style.cssText = 'position:fixed;left:0;top:0;width:860px;height:860px;z-index:99';
|
||||
document.body.appendChild(k);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// the kiosk instance re-reads the pristine demo config — inject the color
|
||||
// into ITS copy; what matters here is the kiosk rendering path
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
|
||||
k.hass = { ...c.hass };
|
||||
k.requestUpdate();
|
||||
await k.updateComplete;
|
||||
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
|
||||
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
|
||||
k.remove();
|
||||
|
||||
// 10) the static space-card paints the same background
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
|
||||
const hass2 = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return origWS.call(c.hass, m);
|
||||
} };
|
||||
const scHost = document.createElement('div');
|
||||
document.body.appendChild(scHost);
|
||||
const sc = document.createElement('houseplan-space-card');
|
||||
sc.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
sc.hass = hass2;
|
||||
scHost.appendChild(sc);
|
||||
const t0 = Date.now();
|
||||
while (!sc.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 80));
|
||||
await sc.updateComplete;
|
||||
const st = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
|
||||
// ...and the space override beats the global there too
|
||||
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
|
||||
sc.hass = { ...hass2 };
|
||||
sc._cfg = null; // force a config re-read
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
sc.requestUpdate(); await sc.updateComplete;
|
||||
const st2 = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardSpaceWins = !!st2 && getComputedStyle(st2).backgroundColor === rgb('#654321');
|
||||
scHost.remove();
|
||||
|
||||
// tidy: drop the color so later smokes on this page see the theme default
|
||||
delete c._serverCfg.settings.bg_color;
|
||||
const spx = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
if (spx?.settings) delete spx.settings.bg_color;
|
||||
await stageBg();
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
|
||||
// The scene bg_color must never bleed through the plan itself: opaque
|
||||
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
|
||||
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
|
||||
// bounding box (section 12), and never the backdrop image rect either
|
||||
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
|
||||
// asserts the new rule on exactly the case that used to be the exception.
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
const m = c._spaceModel();
|
||||
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
|
||||
const p = shapes()[0];
|
||||
out.paperExists = !!p;
|
||||
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
|
||||
// …and NOT the image: no paper rect the size of the backdrop any more
|
||||
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
|
||||
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
|
||||
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
|
||||
if (p) {
|
||||
const cs = getComputedStyle(p);
|
||||
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// …and the picture is drawn ON that paper: image after paper, before walls
|
||||
const kids = [...sr().querySelector('.stage svg').children];
|
||||
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
|
||||
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
|
||||
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._serverCfg.settings.north_deg = 0;
|
||||
c._cfgRev++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 180, elevation: -20 },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
const pn = sr().querySelector('.stage svg .hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
delete c._serverCfg.settings.north_deg;
|
||||
c._cfgRev++;
|
||||
await upd();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, paper);
|
||||
|
||||
// ---- 12) drawn plan: the paper follows the ROOM CONTOURS ---------------
|
||||
// Owner: «белая подложка должна быть по границам комнат» — an L-shaped house
|
||||
// plus a detached building must NOT grow a white bounding rectangle. One
|
||||
// paper shape per room, in exactly the room's own geometry; the acid scene
|
||||
// colour shows in the L's pocket and between the buildings, right up to the
|
||||
// exterior walls; inside the rooms it never shows. A live resize preview
|
||||
// (_rszPreview) moves the paper together with the dragged wall.
|
||||
const drawn = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
sp.plan_url = ''; // hand-drawn plan now
|
||||
// binary-exact fractions -> exact render coords (0.125*1000 = 125 etc.)
|
||||
sp.rooms = [
|
||||
{ id: 'rL', name: 'L', poly: [[0.125, 0.125], [0.625, 0.125], [0.625, 0.375], [0.375, 0.375], [0.375, 0.625], [0.125, 0.625]] },
|
||||
{ id: 'rQ', name: 'Q', x: 0.75, y: 0.75, w: 0.1875, h: 0.1875 },
|
||||
];
|
||||
sp.openings = [];
|
||||
sp.decor = [];
|
||||
c._devices = []; // no icons over the pixel probes below
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
const papers = () => [...sr().querySelectorAll('.stage.noplan svg .hp-paper')];
|
||||
let ps = papers();
|
||||
out.paperPerRoom = ps.length === sp.rooms.length;
|
||||
out.paperShapesMatchRooms = ps.length === 2
|
||||
&& ps[0].tagName.toLowerCase() === 'polygon'
|
||||
&& ps[0].getAttribute('points') === sp.rooms[0].poly.map((p) => (p[0] * 1000) + ',' + (p[1] * 1000)).join(' ')
|
||||
&& ps[1].tagName.toLowerCase() === 'rect';
|
||||
out.drawnPaperWhite = ps.length > 0 && ps.every((p) => getComputedStyle(p).fill === 'rgb(255, 255, 255)');
|
||||
out.paperNoStroke = ps.length > 0 && ps.every((p) => {
|
||||
const cs = getComputedStyle(p);
|
||||
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
|
||||
});
|
||||
// paper first, everything else on top of it (one .hp-paperg group wraps
|
||||
// all paper shapes so the daynight drop shadow composites without seams)
|
||||
const first = sr().querySelector('.stage.noplan svg').firstElementChild;
|
||||
out.paperUnderneath = !!first && first.classList.contains('hp-paperg')
|
||||
&& !!first.firstElementChild && first.firstElementChild.classList.contains('hp-paper');
|
||||
// live resize preview: drag the L's right wall 0.625 -> 0.6875 — the paper
|
||||
// must move WITH the room, not lag behind until the drop
|
||||
const pv = JSON.parse(JSON.stringify(sp));
|
||||
pv.rooms[0].poly = pv.rooms[0].poly.map(([x, y]) => [x === 0.625 ? 0.6875 : x, y]);
|
||||
c._rszPreview = { space: c._space, sp: pv };
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewMovesPaper = (papers()[0]?.getAttribute('points') || '').includes('687.5,125');
|
||||
c._rszPreview = null;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.previewRestores = (papers()[0]?.getAttribute('points') || '').includes('625,125');
|
||||
// screen coords for the pixel probes (svg user units -> viewport px)
|
||||
const svgEl = sr().querySelector('.stage svg');
|
||||
const ctm = svgEl.getScreenCTM();
|
||||
const toScr = (x, y) => { const q = svgEl.createSVGPoint(); q.x = x; q.y = y; const r = q.matrixTransform(ctm); return [r.x, r.y]; };
|
||||
out.probes = {
|
||||
pocket: toScr(500, 500), // the L's notch — bg must show here
|
||||
gap: toScr(687, 300), // between the two buildings
|
||||
inside: [toScr(250, 250), toScr(500, 250), toScr(250, 500), toScr(843.75, 843.75)],
|
||||
};
|
||||
return out;
|
||||
});
|
||||
const { probes } = drawn;
|
||||
delete drawn.probes;
|
||||
Object.assign(res, drawn);
|
||||
|
||||
// pixel proof: the acid scene colour IS in the L's pocket and between the
|
||||
// buildings (where the old bounding-box paper drew a white square), and is
|
||||
// NOT inside any room
|
||||
if (probes) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, probes }) => {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/png;base64,' + shot;
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = img.width;
|
||||
cv.height = img.height;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const at = ([x, y]) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
|
||||
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
|
||||
return {
|
||||
acidInPocket: acid(at(probes.pocket)),
|
||||
acidBetweenBuildings: acid(at(probes.gap)),
|
||||
noAcidInsideRooms: probes.inside.every((q) => !acid(at(q))),
|
||||
};
|
||||
}, { shot, probes });
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.acidInPocket = false;
|
||||
res.acidBetweenBuildings = false;
|
||||
res.noAcidInsideRooms = false;
|
||||
}
|
||||
|
||||
// ---- 13) the static space-card follows the same paper contract ----------
|
||||
const stat = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
// NOTE: the config-store cache is module-level and already primed by the
|
||||
// earlier static-card sections (with the PRE-§12 rooms), so a mocked
|
||||
// callWS would never reach the card — inject the snapshot directly.
|
||||
const mk = async (cfg) => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
el._snap = { config: cfg, rev: 1, layout: {} };
|
||||
el._loadedOnce = true;
|
||||
el.hass = c.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await el.updateComplete;
|
||||
return { el, host };
|
||||
};
|
||||
const base = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
|
||||
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
|
||||
const a = await mk(base);
|
||||
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
|
||||
out.staticPaperExists = !!pa;
|
||||
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
|
||||
&& getComputedStyle(pa).fill !== 'none';
|
||||
a.host.remove();
|
||||
// drawn plan: per-room paper shapes (the L + the detached rect from §12)
|
||||
const drawnCfg = JSON.parse(JSON.stringify(base));
|
||||
const dsp = drawnCfg.spaces.find((s) => s.id === c._space);
|
||||
dsp.plan_url = '';
|
||||
const b = await mk(drawnCfg);
|
||||
const pbs = [...b.el.renderRoot.querySelectorAll('.hp-static-stage svg .hp-paper')];
|
||||
out.staticDrawnPaperPerRoom = pbs.length === dsp.rooms.length;
|
||||
out.staticDrawnPaperShapes = pbs.length === 2
|
||||
&& pbs[0].tagName.toLowerCase() === 'polygon'
|
||||
&& pbs[1].tagName.toLowerCase() === 'rect';
|
||||
b.host.remove();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, stat);
|
||||
checkAll({ ...paper, ...drawn, ...stat,
|
||||
acidInPocket: res.acidInPocket,
|
||||
acidBetweenBuildings: res.acidBetweenBuildings,
|
||||
noAcidInsideRooms: res.noAcidInsideRooms });
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
// новый маркер: радио «Виртуальное» по умолчанию, списка нет
|
||||
c._openMarkerDialog(); await c.updateComplete;
|
||||
const radios = () => [...sr().querySelectorAll('.bindsel input[type="radio"]')];
|
||||
out.twoRadios = radios().length === 2;
|
||||
out.virtDefault = radios()[0].checked && !radios()[1].checked;
|
||||
out.noDropWhenVirtual = !sr().querySelector('.dropbtn');
|
||||
// переключить на «Из списка HA» → появляется дропдаун, открыт (нет выбора), сейв заблокирован
|
||||
radios()[1].click(); await c.updateComplete;
|
||||
out.dropShown = !!sr().querySelector('.dropbtn');
|
||||
out.panelOpen = !!sr().querySelector('.droppanel');
|
||||
const saveBtn = [...sr().querySelectorAll('hp-dialog .btn.on')].pop();
|
||||
out.saveDisabled = saveBtn?.disabled === true;
|
||||
// без чекбокса в списке нет individual-сущностей устройств
|
||||
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
|
||||
const entLabel = c._t('marker.sub_entity');
|
||||
out.noDeviceEntities = !subs().some((s) => s.includes(entLabel));
|
||||
// (группы/хелперы в демо все уже размещены и потому скрыты как занятые —
|
||||
// их «всегда в списке» проверяется кодом: блок вне чекбокса)
|
||||
// включить чекбокс → сущности появились
|
||||
const cb = sr().querySelector('.entcheck input');
|
||||
cb.click(); await c.updateComplete;
|
||||
out.entitiesShown = [...sr().querySelectorAll('.cand .cs')].some((s) => s.textContent.includes(entLabel));
|
||||
out.tooltip = sr().querySelector('.entcheck').getAttribute('title') === c._t('marker.show_entities_tip');
|
||||
// выбрать первый кандидат → панель закрылась, сейв разблокирован
|
||||
sr().querySelector('.cand').click(); await c.updateComplete;
|
||||
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
|
||||
out.panelClosed = !sr().querySelector('.droppanel');
|
||||
out.saveEnabled = [...sr().querySelectorAll('hp-dialog .btn.on')].pop()?.disabled === false;
|
||||
// радио назад на «Виртуальное» → binding=virtual
|
||||
radios()[0].click(); await c.updateComplete;
|
||||
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// редактирование существующего устройства: радио «Из списка», выбранная привязка в кнопке
|
||||
const dev = c._devices.find((d) => !d.virtual && d.bindingKind === 'device');
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
out.editHaMode = c._markerDialog.bindingMode === 'ha';
|
||||
out.editShowsCur = sr().querySelector('.dropbtn b') !== null;
|
||||
out.editPanelClosed = !sr().querySelector('.droppanel');
|
||||
c._markerDialog = null;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,212 @@
|
||||
// The content frame is PRESENTATION (docs/CANVAS.md §4) — audit dev@2c947f4.
|
||||
//
|
||||
// DEV-2C947-01: a hidden device is not drawn, but it still stretched the
|
||||
// frame — hiding a marker that had wandered into the yard left the visible
|
||||
// house a dot in the corner (the full card AND the static
|
||||
// houseplan-space-card, which drew `devs` but framed `spaceDevs`).
|
||||
//
|
||||
// DEV-2C947-02: inside an editor the frame only grows (a frame that shrank
|
||||
// mid-drag would move the ground under the pointer); that union was memoised
|
||||
// without the mode, so it survived the way back into View.
|
||||
//
|
||||
// DEV-2C947-03: a room-outlier is rejected from the frame, but iconUnit()
|
||||
// measured ALL rooms — so the stray it had just rejected still blew every
|
||||
// marker of the main plan up ~91x.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// Load a config/layout into the card AND into the demo's WS stub, so the
|
||||
// static card (which fetches its own copy) sees exactly the same house.
|
||||
const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
|
||||
const c = window.__card;
|
||||
const srvCfg = (await c.hass.callWS({ type: 'houseplan/config/get' })).config;
|
||||
const srvLay = (await c.hass.callWS({ type: 'houseplan/layout/get' })).layout;
|
||||
for (const k of Object.keys(srvCfg)) delete srvCfg[k];
|
||||
Object.assign(srvCfg, JSON.parse(JSON.stringify(c2)));
|
||||
for (const k of Object.keys(srvLay)) delete srvLay[k];
|
||||
Object.assign(srvLay, JSON.parse(JSON.stringify(l2)));
|
||||
c._serverCfg = srvCfg;
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = sp;
|
||||
c._cfgEpoch++;
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout, space]);
|
||||
|
||||
// ---------------------------------------------------------------- 01: hidden
|
||||
// The auditor's probe, verbatim: ONE visible room and ONE marker parked 90
|
||||
// canvases away. Two content items is below MIN_VOTERS, so the outlier vote
|
||||
// never runs and the far position is guaranteed to stay in the core — the
|
||||
// frame either sees the marker or it does not.
|
||||
const oneRoom = (hidden) => ({
|
||||
spaces: [{
|
||||
id: 'h1', title: 'Hidden', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]] }],
|
||||
}],
|
||||
// a made-up area: no auto device of the demo house lands in this space, so
|
||||
// the only content is the room and the one marker below
|
||||
markers: [{ id: 'm_far', binding: 'device:d_motion', area: 'zx_room', hidden }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const FAR_POS = { m_far: { s: 'h1', x: 90, y: 0.5 } };
|
||||
|
||||
await load(oneRoom(false), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const b = c._baseVb();
|
||||
return {
|
||||
// premise: while it IS drawn, the marker is content and the frame holds it
|
||||
visibleMarkerIsDrawn: sr.querySelectorAll('.devlayer .dev').length === 1,
|
||||
visibleMarkerWidensTheFrame: b[2] > 50000,
|
||||
};
|
||||
}));
|
||||
|
||||
await load(oneRoom(true), FAR_POS, 'h1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
o.hiddenMarkerNotDrawn = sr.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.hiddenMarkerStillBuilt = !!c._devices.find((d) => d.id === 'm_far' && d.hidden === true);
|
||||
// the room is 100..900 render units, padded by 5 % of the longer side
|
||||
o.frameIsTheVisibleRoom = Math.round(b[0]) === 60 && Math.round(b[2]) === 880
|
||||
&& Math.round(b[1]) === 60 && Math.round(b[3]) === 880;
|
||||
// and the room fills the screen instead of being a dot 112x too small
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
const poly = sr.querySelector('polygon.room').getBoundingClientRect();
|
||||
o.roomFillsTheStage = poly.width > st.width * 0.7;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// the same house on the STATIC card (space-render.ts drew `devs` but framed
|
||||
// `spaceDevs` — the identical split)
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const o = {};
|
||||
const host = document.createElement('div');
|
||||
host.style.cssText = 'width:400px;height:400px';
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: 'h1' });
|
||||
el.hass = window.__card.hass;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await el.updateComplete;
|
||||
const svg = el.renderRoot.querySelector('.hp-static-stage svg');
|
||||
const vb = (svg?.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
||||
o.staticCardHidesTheMarker = el.renderRoot.querySelectorAll('.devlayer .dev').length === 0;
|
||||
o.staticFrameIsTheVisibleRoom = vb.length === 4
|
||||
&& Math.round(vb[0]) === 60 && Math.round(vb[2]) === 880;
|
||||
host.remove();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------- 02: editor -> view
|
||||
const oneRoomAt = (x0) => ({
|
||||
spaces: [{
|
||||
id: 'e1', title: 'Editor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Room', area: 'zx_room',
|
||||
poly: [[x0, 0.1], [x0 + 0.8, 0.1], [x0 + 0.8, 0.9], [x0, 0.9]] }],
|
||||
}],
|
||||
markers: [], settings: { filter_seeded: true },
|
||||
});
|
||||
|
||||
await load(oneRoomAt(0.1), {}, 'e1');
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const round = () => c._baseVb().map((n) => Math.round(n));
|
||||
o.viewFrameBefore = JSON.stringify(round()) === JSON.stringify([60, 60, 880, 880]);
|
||||
// move the only room 5 canvases to the right, inside the Plan editor
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms[0].poly = [[5.1, 0.1], [5.9, 0.1], [5.9, 0.9], [5.1, 0.9]];
|
||||
c._serverCfg = cfg;
|
||||
c._modelCache = null;
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const inEditor = round();
|
||||
// inside the editor the frame is the UNION — that is deliberate: it bounds
|
||||
// pan and defines zoom 1, and it must not shrink under a live gesture
|
||||
o.editorFrameOnlyGrows = inEditor[2] > 5000;
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
const back = round();
|
||||
o.viewFrameFollowsTheRoomAgain = JSON.stringify(back) === JSON.stringify([5060, 60, 880, 880]);
|
||||
if (!o.viewFrameFollowsTheRoomAgain) console.log('frame after exit', back, 'editor', inEditor);
|
||||
// and back into an editor it starts from the CURRENT geometry, not the old union
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
o.reenteringTheEditorDoesNotResurrectTheUnion =
|
||||
JSON.stringify(round()) === JSON.stringify([5060, 60, 880, 880]);
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------- 03: icons and outliers
|
||||
// Three rooms in the core plus one dragged 90 canvases out. The frame rejects
|
||||
// the stray (that part always worked); the ICON UNIT used to measure all four
|
||||
// rooms, so every marker of the plan the user is looking at came out ~91x too
|
||||
// big and covered the house.
|
||||
const withFarRoom = (far) => ({
|
||||
spaces: [{
|
||||
id: 'i1', title: 'Icons', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'A', area: 'zx_a', poly: [[0.10, 0.10], [0.30, 0.10], [0.30, 0.30], [0.10, 0.30]] },
|
||||
{ id: 'r2', name: 'B', area: 'zx_b', poly: [[0.30, 0.10], [0.55, 0.10], [0.55, 0.35], [0.30, 0.35]] },
|
||||
{ id: 'r3', name: 'C', area: 'zx_c', poly: [[0.10, 0.30], [0.35, 0.30], [0.35, 0.55], [0.10, 0.55]] },
|
||||
...(far ? [{ id: 'r4', name: 'Far', area: 'zx_d', poly: [[90, 90], [91, 90], [91, 91], [90, 91]] }] : []),
|
||||
],
|
||||
}],
|
||||
markers: [{ id: 'm_one', binding: 'device:d_light1', area: 'zx_a' }],
|
||||
settings: { filter_seeded: true },
|
||||
});
|
||||
const ICON_POS = { m_one: { s: 'i1', x: 0.2, y: 0.2 } };
|
||||
|
||||
const iconPx = () => page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
// the badge is --dev-size plus a 1 px border on each side (smoke_icon_scale)
|
||||
return { px: el ? Math.round((el.getBoundingClientRect().width - 2) * 100) / 100 : null,
|
||||
outliers: c._outliers, frameW: Math.round(c._baseVb()[2]) };
|
||||
});
|
||||
|
||||
await load(withFarRoom(false), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const plain = await iconPx();
|
||||
await load(withFarRoom(true), ICON_POS, 'i1');
|
||||
await raf();
|
||||
const strayed = await iconPx();
|
||||
out.strayRoomIsRejectedFromTheFrame = strayed.outliers === 1 && strayed.frameW < 1000;
|
||||
out.strayRoomDoesNotInflateTheIcons =
|
||||
plain.px > 4 && Math.abs(strayed.px - plain.px) < 0.5;
|
||||
if (!out.strayRoomDoesNotInflateTheIcons) console.log('icon px', plain, strayed);
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
// Reproduce the narrow wall-tablet/mobile dialog from the field report.
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
await c.updateComplete;
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// устройство с управляемой сущностью
|
||||
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
|
||||
out.hasDev = !!dev;
|
||||
c._infoCard = dev; await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
out.infoUsesWideShell = sr().querySelector('hp-dialog')?.hasAttribute('wide') === true;
|
||||
const footer = sr().querySelector('.infofooter');
|
||||
const footerBox = footer?.getBoundingClientRect();
|
||||
const footerButtons = [...(footer?.querySelectorAll('button') || [])];
|
||||
out.footerActionsContained = !!footerBox && footerButtons.length >= 2 && footerButtons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.left >= footerBox.left - 1 && rect.right <= footerBox.right + 1;
|
||||
});
|
||||
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
|
||||
const body = sr().querySelector('hp-dialog .body');
|
||||
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|
||||
|| body.querySelector('.entlist') === body.children[0];
|
||||
const rows = [...sr().querySelectorAll('.entrow')];
|
||||
out.rows = rows.length > 0;
|
||||
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
|
||||
const btn = sr().querySelector('.entbtn');
|
||||
out.hasButton = !!btn;
|
||||
const box = btn?.getBoundingClientRect();
|
||||
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
|
||||
// 3) кнопка реально переключает
|
||||
const before = calls.length;
|
||||
btn.click(); await c.updateComplete;
|
||||
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
|
||||
// 4) замок никогда не переключается из карточки
|
||||
const n = calls.length;
|
||||
c._cardToggle('lock.front_door');
|
||||
out.lockNeverToggles = calls.length === n;
|
||||
// 5) диагностические/конфиг-сущности не засоряют список
|
||||
const ents = c._cardEntities(dev).map((e) => e.eid);
|
||||
out.noConfigEntities = ents.every((e) => {
|
||||
const cat = c.hass.entities[e]?.entity_category;
|
||||
return cat !== 'config' && cat !== 'diagnostic';
|
||||
});
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
const stage = sr().querySelector('.stage');
|
||||
const label = sr().querySelector('.roomlabel');
|
||||
out.hasLabel = !!label;
|
||||
// 1) draw: клик по карточке не добавляет точку
|
||||
c._tool = 'draw'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.drawIgnored = c._path.length === 0;
|
||||
// 2) клик по угловой метке тоже не считается кликом инструмента
|
||||
const handle = label.querySelector('.rlhandle');
|
||||
handle.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.handleIgnored = c._path.length === 0;
|
||||
// 3) во время резайза клики по сцене игнорируются
|
||||
const room = c._spaceModel().rooms.find((r) => r.name);
|
||||
c._rlResize = { id: 'rl_' + room.id, space: c._space, k0: 1, cx: 0, cy: 0, d0: 50 };
|
||||
const before = c._path.length;
|
||||
c._markupClick(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 200 }));
|
||||
out.duringResizeIgnored = c._path.length === before;
|
||||
c._rlResize = null;
|
||||
// 4) обычный клик по сцене (мимо карточки) по-прежнему работает — точка ставится
|
||||
const r = stage.getBoundingClientRect();
|
||||
// найти долю экрана, попадающую в свободное место (не внутри комнаты)
|
||||
let fx = 0.4, fy = 0.4;
|
||||
outer: for (let ix = 1; ix < 20; ix++) for (let iy = 1; iy < 20; iy++) {
|
||||
const ev = new MouseEvent('click', { clientX: r.left + r.width * ix / 20, clientY: r.top + r.height * iy / 20 });
|
||||
const raw = c._svgPoint(ev);
|
||||
if (!c._roomAt(c._snap(raw))) { fx = ix / 20; fy = iy / 20; break outer; }
|
||||
}
|
||||
stage.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true,
|
||||
clientX: r.left + r.width * fx, clientY: r.top + r.height * fy }));
|
||||
await c.updateComplete;
|
||||
out.normalClickWorks = c._path.length === 1;
|
||||
// 5) delroom: клик по карточке не зовёт confirm (переопределим)
|
||||
let confirmCalled = false;
|
||||
window.confirm = () => { confirmCalled = true; return false; };
|
||||
c._tool = 'delroom'; c._path = []; await c.updateComplete;
|
||||
label.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: 10, clientY: 10 }));
|
||||
await c.updateComplete;
|
||||
out.delroomIgnored = !confirmCalled;
|
||||
// 6) Delete has exactly one semantic result: the clicked room disappears;
|
||||
// it never closes a virtual boundary or merges a neighbour into it.
|
||||
const roomCount = c._curSpaceCfg.rooms.length;
|
||||
const deleteId = room.id;
|
||||
window.confirm = () => true;
|
||||
c._deleteRoomClick(c._roomCenter(room));
|
||||
await c.updateComplete;
|
||||
out.deleteOnlyClickedRoom = c._curSpaceCfg.rooms.length === roomCount - 1
|
||||
&& !c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
|
||||
c._undoGeometry();
|
||||
await c.updateComplete;
|
||||
out.deleteUndoRestoresRoom = c._curSpaceCfg.rooms.length === roomCount
|
||||
&& c._curSpaceCfg.rooms.some((x) => x.id === deleteId);
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,77 @@
|
||||
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
|
||||
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
|
||||
// перечитывании метаданных, которые не менялись. Карта строится один раз на
|
||||
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
|
||||
let scans = 0;
|
||||
const wrap = (h) => {
|
||||
const ents = h.entities;
|
||||
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
|
||||
return { ...h, entities: traced };
|
||||
};
|
||||
const fresh = () => wrap(window.__mkHass());
|
||||
|
||||
// включаем и заливку по температуре, и подписи — два потребителя климата
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
|
||||
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
|
||||
})};
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
const fewRooms = scans;
|
||||
|
||||
// повторные рендеры на том же снимке hass реестр не трогают
|
||||
scans = 0;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.scansOnRerender = scans;
|
||||
|
||||
// главный инвариант: обходов НЕ становится больше от числа комнат
|
||||
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
const extra = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
|
||||
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
|
||||
}
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
|
||||
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
|
||||
c._cfgEpoch++;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
scans = 0;
|
||||
c.hass = fresh(); await c.updateComplete;
|
||||
out.roomCount = c._spaceModel('f1').rooms.length;
|
||||
out.scansSameWith44Rooms = scans === fewRooms;
|
||||
out.scansPerUpdate = scans;
|
||||
|
||||
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
|
||||
out.tempBefore = c._climate().get('living_room')?.temp;
|
||||
const h = fresh();
|
||||
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
|
||||
c.hass = h; await c.updateComplete;
|
||||
out.tempAfter = c._climate().get('living_room')?.temp;
|
||||
out.climateIsMap = c._climate() instanceof Map;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
|
||||
// scansPerUpdate = 1: active-registry projection is shared by the climate map
|
||||
// and buildDevices. Важно
|
||||
// не само число, а что оно не растёт вместе с числом комнат.
|
||||
checkAll(res, {
|
||||
scansOnRerender: 0,
|
||||
roomCount: 44,
|
||||
scansSameWith44Rooms: true,
|
||||
scansPerUpdate: 1,
|
||||
tempBefore: 22.4,
|
||||
tempAfter: 33.3,
|
||||
climateIsMap: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,147 @@
|
||||
// Опция «Использовать датчик температуры устройства» (marker.use_climate_temp):
|
||||
// климат-устройства (кондиционеры, термостаты) знают температуру комнаты
|
||||
// (attributes.current_temperature). По умолчанию ВЫКЛ — ни плашки, ни вклада
|
||||
// в среднюю. Включена через диалог (реальный клик по чекбоксу): плашка .tval
|
||||
// у значка + голос в средней комнаты (термометр 20 + климат 23.5 → 21.75,
|
||||
// т.е. 21.8 по сетке карточки 0.1°). Unavailable-климат не ломает ничего.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
// --- впрыскиваем климат-устройства в демо-hass ---------------------------
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const h = window.__mkHass();
|
||||
const inject = (hass) => ({
|
||||
...hass,
|
||||
devices: {
|
||||
...hass.devices,
|
||||
d_ac: { id: 'd_ac', name: 'Air conditioner', model: 'AC-12', area_id: 'living_room', identifiers: [['demo', 'd_ac']], entry_type: null, via_device_id: null },
|
||||
d_trv: { id: 'd_trv', name: 'Radiator valve', model: 'TRV-1', area_id: 'bedroom', identifiers: [['demo', 'd_trv']], entry_type: null, via_device_id: null },
|
||||
},
|
||||
entities: {
|
||||
...hass.entities,
|
||||
'climate.ac': { entity_id: 'climate.ac', device_id: 'd_ac', platform: 'demo' },
|
||||
'climate.trv': { entity_id: 'climate.trv', device_id: 'd_trv', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
...hass.states,
|
||||
// термометр гостиной — ровно 20, чтобы средняя с климатом была детерминированной
|
||||
'sensor.living_temp': { ...hass.states['sensor.living_temp'], state: '20' },
|
||||
'climate.ac': { entity_id: 'climate.ac', state: 'cool', attributes: { friendly_name: 'Air conditioner', current_temperature: 23.5, temperature: 24 } },
|
||||
// мёртвый климат: state unavailable, атрибутов нет
|
||||
'climate.trv': { entity_id: 'climate.trv', state: 'unavailable', attributes: { friendly_name: 'Radiator valve' } },
|
||||
},
|
||||
});
|
||||
window.__injectClimate = () => { c.hass = inject(window.__mkHass()); };
|
||||
window.__injectClimate();
|
||||
c._regSignature = '';
|
||||
c._maybeRebuildDevices();
|
||||
c.requestUpdate();
|
||||
});
|
||||
await page.waitForFunction(() => window.__card._devices.some((d) => d.entities?.includes('climate.ac')));
|
||||
|
||||
// --- без опции: ни плашки, ни вклада в среднюю ---------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.acBuilt = !!ac;
|
||||
o.noBadgeByDefault = c._liveTemp(ac) === null;
|
||||
o.avgWithoutOption = c._climate().get('living_room')?.temp; // === 20
|
||||
return o;
|
||||
}), { avgWithoutOption: 20 });
|
||||
|
||||
// --- диалог: чекбокс есть у климат-устройства, нет у обычного, клик мышью --
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
// у обычного термометра чекбокса нет
|
||||
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
|
||||
c._openMarkerDialog(th); await c.updateComplete;
|
||||
o.noCheckboxForThermometer = !sr().querySelector('hp-dialog .climrow');
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
// у кондиционера — есть и по умолчанию снят
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
const row = sr().querySelector('hp-dialog .climrow input');
|
||||
o.checkboxForClimate = !!row;
|
||||
o.uncheckedByDefault = !!row && !row.checked;
|
||||
if (row) row.scrollIntoView({ block: 'center' });
|
||||
return o;
|
||||
}));
|
||||
|
||||
// реальный клик по чекбоксу — координатами, через весь стек событий
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const r = sr.querySelector('hp-dialog .climrow input')?.getBoundingClientRect();
|
||||
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
|
||||
});
|
||||
check('checkboxClickable', !!box);
|
||||
if (box) await page.mouse.click(box.x, box.y);
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
await c.updateComplete;
|
||||
o.clickTicksState = c._markerDialog?.useClimateTemp === true;
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const m = (c._serverCfg.markers || []).find((x) => x.binding === 'device:d_ac');
|
||||
o.savedInMarker = m?.use_climate_temp === true;
|
||||
return o;
|
||||
}));
|
||||
|
||||
// --- с опцией: плашка «23.5°», средняя пересчиталась ----------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
await c.updateComplete;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
o.liveTempFromClimate = c._liveTemp(ac); // === 23.5
|
||||
const badges = [...sr().querySelectorAll('.dev .tval')].map((el) => el.textContent.trim());
|
||||
o.badgeVisible = badges.includes('23.5°');
|
||||
// средняя гостиной: (20 + 23.5) / 2 = 21.75 → 21.8 по сетке карточки (0.1°)
|
||||
o.avgWithOption = c._climate().get('living_room')?.temp;
|
||||
// и на КАРТОЧКЕ КОМНАТЫ: включаем метрику температуры и читаем текст
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.settings = { ...(sp.settings || {}), label_temp: true, show_names: true };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const labels = [...sr().querySelectorAll('.roomlabel .rlm')].map((el) => el.textContent.trim());
|
||||
o.roomCardShowsAvg = labels.some((t) => t.includes('21.8°'));
|
||||
return o;
|
||||
}), { liveTempFromClimate: 23.5, avgWithOption: 21.8 });
|
||||
|
||||
// --- unavailable-климат: опция включена, но ни плашки, ни вклада ----------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
c._serverCfg.markers = [...c._serverCfg.markers,
|
||||
{ id: 'd_trv', binding: 'device:d_trv', use_climate_temp: true, hidden: false }];
|
||||
c._cfgEpoch++; c._regSignature = '';
|
||||
c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
|
||||
const trv = c._devices.find((d) => d.entities?.includes('climate.trv'));
|
||||
o.unavailNoBadge = !!trv && c._liveTemp(trv) === null;
|
||||
o.unavailNoVote = c._climate().get('bedroom')?.temp ?? null; // в спальне нет термометров
|
||||
// а гостиная как была 21.8
|
||||
o.avgStillCorrect = c._climate().get('living_room')?.temp;
|
||||
return o;
|
||||
}), { unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
|
||||
// --- чекбокс переживает пересоздание диалога ------------------------------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
|
||||
c._openMarkerDialog(ac); await c.updateComplete;
|
||||
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
|
||||
o.checkboxChecked = sr().querySelector('hp-dialog .climrow input')?.checked === true;
|
||||
c._markerDialog = null;
|
||||
return o;
|
||||
}));
|
||||
|
||||
checkAll(out, { avgWithoutOption: 20, liveTempFromClimate: 23.5, avgWithOption: 21.8, unavailNoVote: null, avgStillCorrect: 21.8 });
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,67 @@
|
||||
// HP-1454-03: две локальные правки уходили с одной ревизией, вторая терялась.
|
||||
// Debounce разносил только СТАРТЫ. Если первый config/set отвечал дольше 500 мс,
|
||||
// вторая правка уходила с тем же expected_rev, сервер принимал первую и
|
||||
// отклонял вторую как conflict — а обработчик конфликта перечитывал серверную
|
||||
// копию поверх локальной. Правка исчезала, и тост винил «другое окно».
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
const writes = [];
|
||||
let rev = 10;
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/set') {
|
||||
const n = writes.length + 1;
|
||||
writes.push({ expected: m.expected_rev, titles: m.config.spaces.map((s) => s.title) });
|
||||
if (n === 1) await firstGate; // первый ответ задержан
|
||||
if (m.expected_rev !== rev) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
||||
rev += 1;
|
||||
return { ok: true, rev };
|
||||
}
|
||||
if (m.type === 'houseplan/config/get') {
|
||||
const r = await base(m);
|
||||
return { config: JSON.parse(JSON.stringify(r.config)), rev };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
c._cfgRev = rev;
|
||||
|
||||
// правка №1 и, пока первая запись висит, правка №2
|
||||
c._serverCfg.spaces[0].title = 'FIRST';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.oneInFlight = writes.length === 1;
|
||||
|
||||
c._serverCfg.spaces[0].title = 'SECOND';
|
||||
c._saveConfig();
|
||||
c._saveConfigDebounced.flush();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
out.stillOneInFlight = writes.length === 1; // вторая ждёт очереди, не летит параллельно
|
||||
|
||||
releaseFirst();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
|
||||
out.writes = writes.length;
|
||||
out.revisions = writes.map((w) => w.expected); // вторая обязана взять новую ревизию
|
||||
out.secondCarriedTheEdit = writes[1]?.titles[0] === 'SECOND';
|
||||
out.editSurvived = c._serverCfg.spaces[0].title === 'SECOND';
|
||||
out.noConflictToast = !(c._toast || '').length;
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.0 и сверено с кодом
|
||||
checkAll(res, {
|
||||
oneInFlight: true,
|
||||
stillOneInFlight: true,
|
||||
writes: 2,
|
||||
revisions: [10, 11],
|
||||
secondCarriedTheEdit: true,
|
||||
editSurvived: true,
|
||||
noConflictToast: true,
|
||||
});
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,100 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const calls = [];
|
||||
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
|
||||
// два подопытных light-entity
|
||||
const lights = Object.keys(c.hass.states).filter((e) => e.startsWith('light.')).slice(0, 2);
|
||||
out.twoLights = lights.length === 2;
|
||||
const setSt = async (m) => {
|
||||
const st = { ...c.hass.states };
|
||||
for (const [e, v] of Object.entries(m)) st[e] = { ...st[e], state: v };
|
||||
c.hass = { ...c.hass, states: st }; await c.updateComplete;
|
||||
};
|
||||
// виртуальный маркер-выключатель с controls и tap_action=toggle
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
c._openMarkerDialog();
|
||||
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
|
||||
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
|
||||
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights,
|
||||
icon: 'mdi:light-switch', display: 'icon_ripple' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const dev = c._devices.find((d) => d.name === 'Выключатель');
|
||||
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// 1) все выключены → клик включает все
|
||||
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
|
||||
const dev2 = () => c._devices.find((d) => d.name === 'Выключатель');
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.turnOnAll = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_on', lights]);
|
||||
// 2) частично включено → клик выключает все
|
||||
await setSt({ [lights[0]]: 'on', [lights[1]]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.partialTurnsOff = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_off', lights]);
|
||||
// 3) значок отражает цели: горит одна → класс on
|
||||
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
|
||||
out.stateOn = !!c._stateClass(dev2()).includes('on');
|
||||
out.aggregateActivityRuns = c._stateClass(dev2()).includes('activity-running');
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
out.stateOff = c._stateClass(dev2()) === '';
|
||||
// 4) без tap_action=toggle клик НЕ переключает (инфо)
|
||||
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
|
||||
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
const n = calls.length;
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
out.infoNoToggle = calls.length === n && !!c._infoCard;
|
||||
// 5) инфо-карточка показывает цели
|
||||
await c.updateComplete;
|
||||
out.infoListsTargets = sr().querySelectorAll('.ctrlstate').length === 2;
|
||||
c._infoCard = null; await c.updateComplete;
|
||||
// 6) замок в controls отфильтрован при клике (защита)
|
||||
cfg.tap_action = 'toggle'; cfg.controls = ['lock.front_door', lights[0]];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
await setSt({ [lights[0]]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), dev2());
|
||||
const last = calls.at(-1);
|
||||
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
|
||||
|
||||
// Opening and saving must not rewrite configuration that runtime cannot
|
||||
// currently execute: YAML-only domains and duplicates are still user data.
|
||||
const losslessControls = [lights[0], 'input_boolean.yaml_only', lights[0], 'sensor.vendor_option'];
|
||||
cfg.controls = [...losslessControls];
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
c._openMarkerDialog(dev2()); await c.updateComplete;
|
||||
out.dialogKeepsUnknownAndDuplicateControls = JSON.stringify(c._markerDialog?.controls)
|
||||
=== JSON.stringify(losslessControls);
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const savedLossless = c._serverCfg.markers.find((m) => m.name === 'Выключатель')?.controls;
|
||||
out.saveKeepsUnknownAndDuplicateControls = JSON.stringify(savedLossless)
|
||||
=== JSON.stringify(losslessControls);
|
||||
|
||||
// Legacy configs could store a standalone entity as its own external
|
||||
// control. That self-reference must neither become a light source/group nor
|
||||
// disappear behind ambiguous dialog UI. Its normal Toggle action still
|
||||
// operates the entity, and an ON hood/fan still has working-state yellow.
|
||||
const own = Object.keys(c.hass.states).find((eid) => eid.startsWith('switch.'));
|
||||
out.hasStandaloneSwitch = !!own;
|
||||
const self = own && {
|
||||
id: 'self-control-regression', name: 'Hood', icon: 'mdi:fan',
|
||||
space: c._space, area: room.area, entities: [own], primary: own,
|
||||
bindingKind: 'entity', bindingRef: own, tapAction: 'toggle',
|
||||
marker: { id: 'self-control-regression', binding: `entity:${own}`, controls: [own] },
|
||||
};
|
||||
if (self) {
|
||||
await setSt({ [own]: 'off' });
|
||||
c._clickDevice(new MouseEvent('click'), self);
|
||||
out.selfControlUsesDirectToggle = JSON.stringify(calls.at(-1))
|
||||
=== JSON.stringify(['homeassistant', 'toggle', own]);
|
||||
c._openMarkerDialog(self); await c.updateComplete;
|
||||
out.selfControlAbsentFromDialog = c._markerDialog?.controls.length === 0;
|
||||
c._markerDialog = null;
|
||||
await setSt({ [own]: 'on' });
|
||||
out.selfSwitchStillShowsWorkingState = c._stateClass(self).includes('on');
|
||||
}
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -0,0 +1,208 @@
|
||||
// A cover NEVER wears a coloured plate (owner's contract 2026-08-04):
|
||||
// «у штор не должно быть жёлтой подложки никогда, индикация открыто/закрыто
|
||||
// за счёт морфинга иконки».
|
||||
//
|
||||
// Before this the domain shared one branch with `valve`: 'open'/'opening' put
|
||||
// the orange «открыто» frame on the badge (.dev.open — a FILLED orange plate,
|
||||
// --hp-open #ff9f43, not just a border), and the travelling states carried it
|
||||
// underneath the breathing ring. Now a cover returns no plate class at all:
|
||||
//
|
||||
// closed / open / ajar -> neutral plate, the ICON tells the state
|
||||
// opening / closing -> neutral plate + activity-transition when the
|
||||
// marker opts into «Icon + activity»
|
||||
//
|
||||
// The frame itself is NOT gone: door/window binary sensors and locks still
|
||||
// wear it, and a valve keeps it too (nothing morphs its icon). Those are
|
||||
// re-checked at the bottom, or «no yellow for curtains» would quietly cost
|
||||
// the whole house its «открыто» indication.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
|
||||
// --- colour probes: resolve the tokens the same way the badge does ------
|
||||
const probe = (css) => {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = `position:absolute;left:-999px;background:${css}`;
|
||||
sr().appendChild(d);
|
||||
const v = getComputedStyle(d).backgroundColor;
|
||||
d.remove();
|
||||
return v;
|
||||
};
|
||||
const YELLOW = probe('var(--hp-on)'); // «включено»
|
||||
const ORANGE = probe('var(--hp-open)'); // «открыто» frame
|
||||
const NEUTRAL = probe('var(--hp-bg)'); // the plain badge
|
||||
o.tokensDiffer = new Set([YELLOW, ORANGE, NEUTRAL]).size === 3;
|
||||
o.yellowIsTheYellow = YELLOW === 'rgb(255, 212, 92)';
|
||||
o.orangeIsTheOpenFrame = ORANGE === 'rgb(255, 159, 67)';
|
||||
|
||||
// --- the gate marker, alone in the Garden, told it IS the curtain -------
|
||||
const setMarker = async () => {
|
||||
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.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const setCover = async (state, attrs = {}) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state, attributes: { friendly_name: 'Gate', ...attrs } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
// .dev animates `background 0.15s` — a colour read in the same frame is
|
||||
// still mid-fade and would pass on a plate that ends up orange
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
};
|
||||
c._setMode('view');
|
||||
c._space = 'garden';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await setMarker();
|
||||
await setCover('closed', { device_class: 'curtain' });
|
||||
|
||||
const devEl = () => sr().querySelector('.devlayer .dev');
|
||||
const cls = () => [...(devEl()?.classList || [])];
|
||||
const plate = () => getComputedStyle(devEl()).backgroundColor;
|
||||
const icon = () => devEl()?.querySelector('ha-icon')?.getAttribute('icon') || '';
|
||||
o.onlyTheCoverOnThePlan = sr().querySelectorAll('.devlayer .dev').length === 1;
|
||||
|
||||
/** One state: the plate must be the neutral one, and nothing else. */
|
||||
const plateIsNeutral = (tag) => {
|
||||
o[tag + 'PlateNeutral'] = plate() === NEUTRAL;
|
||||
o[tag + 'NotYellow'] = plate() !== YELLOW;
|
||||
o[tag + 'NotOrange'] = plate() !== ORANGE;
|
||||
o[tag + 'NoPlateClass'] = !cls().includes('on') && !cls().includes('open');
|
||||
};
|
||||
|
||||
plateIsNeutral('closed');
|
||||
o.closedNoRing = !cls().includes('activity-transition');
|
||||
o.closedIconClosed = icon() === 'mdi:curtains-closed';
|
||||
|
||||
await setCover('open', { device_class: 'curtain' });
|
||||
plateIsNeutral('open');
|
||||
// This fixture jumps closed → open without an intermediate HA state, so
|
||||
// the unified resolver supplies the agreed ~3.3 s transition fallback.
|
||||
o.openDirectTransition = cls().includes('activity-transition');
|
||||
o.openIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
// ajar: HA reports a positioned curtain as plain 'open' — it reads as OPEN
|
||||
await setCover('open', { device_class: 'curtain', current_position: 40 });
|
||||
plateIsNeutral('ajar');
|
||||
o.ajarIconOpen = icon() === 'mdi:curtains';
|
||||
|
||||
await setCover('opening', { device_class: 'curtain' });
|
||||
plateIsNeutral('opening');
|
||||
o.openingRings = cls().includes('activity-transition');
|
||||
o.openingRingIsTheBreathingOne = (() => {
|
||||
const ring = devEl()?.querySelector('.activity-ring.transition i:first-child');
|
||||
const cs = ring ? getComputedStyle(ring) : null;
|
||||
return !!cs && cs.animationName === 'hp-activity-breathe' && cs.animationDuration === '2.2s';
|
||||
})();
|
||||
|
||||
await setCover('closing', { device_class: 'curtain' });
|
||||
plateIsNeutral('closing');
|
||||
o.closingRings = cls().includes('activity-transition');
|
||||
|
||||
// --- the morph covers every class, both ways ---------------------------
|
||||
const morph = async (dc, closedIcon, openIcon) => {
|
||||
await setCover('closed', dc ? { device_class: dc } : {});
|
||||
const a = icon();
|
||||
await setCover('open', dc ? { device_class: dc } : {});
|
||||
const b = icon();
|
||||
return a === closedIcon && b === openIcon && a !== b;
|
||||
};
|
||||
o.morphBlind = await morph('blind', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShade = await morph('shade', 'mdi:blinds', 'mdi:blinds-open');
|
||||
o.morphShutter = await morph('shutter', 'mdi:window-shutter', 'mdi:window-shutter-open');
|
||||
o.morphCurtain = await morph('curtain', 'mdi:curtains-closed', 'mdi:curtains');
|
||||
o.morphWindow = await morph('window', 'mdi:window-closed', 'mdi:window-open');
|
||||
o.morphAwning = await morph('awning', 'mdi:awning-outline', 'mdi:awning');
|
||||
o.morphDoor = await morph('door', 'mdi:door-closed', 'mdi:door-open');
|
||||
o.morphGarage = await morph('garage', 'mdi:garage', 'mdi:garage-open');
|
||||
o.morphGate = await morph('gate', 'mdi:gate', 'mdi:gate-open');
|
||||
o.morphDamper = await morph('damper', 'mdi:circle-slice-8', 'mdi:circle-outline');
|
||||
// no device_class at all (z2m ships plenty): the gate's auto icon is the
|
||||
// one the name rule «ворота|garage|gate» hands out, and it morphs too
|
||||
o.gateAutoIcon = c._devices.find((x) => x.bindingRef === 'd_gate')?.icon === 'mdi:garage-variant';
|
||||
o.morphNoClass = await morph('', 'mdi:garage-variant', 'mdi:garage-open-variant');
|
||||
// …and an unreadable state morphs nothing, plate still neutral
|
||||
await setCover('unknown', { device_class: 'curtain' });
|
||||
plateIsNeutral('unknown');
|
||||
o.unknownNoMorph = icon() === 'mdi:garage-variant';
|
||||
o.unknownNoRing = !cls().includes('activity-transition');
|
||||
|
||||
// --- NOT touched: the «открыто» frame elsewhere ------------------------
|
||||
// the demo house has a lock and a window sensor on the ground floor
|
||||
c._space = 'f1';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const elOf = (dev) => {
|
||||
const v = c._viewOr(c._baseVb());
|
||||
const p = c._pos(dev);
|
||||
const left = ((p.x - v.x) / v.w) * 100;
|
||||
const top = ((p.y - v.y) / v.h) * 100;
|
||||
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
|
||||
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
|
||||
};
|
||||
const dev = (ref) => c._devices.find((x) => x.bindingRef === ref);
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'unlocked', attributes: { friendly_name: 'Front door' } },
|
||||
'binary_sensor.window': { entity_id: 'binary_sensor.window', state: 'on', attributes: { friendly_name: 'Window', device_class: 'window' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const lockEl = elOf(dev('d_lock'));
|
||||
const winEl = elOf(dev('d_window'));
|
||||
o.lockMarkerFound = !!lockEl;
|
||||
o.windowMarkerFound = !!winEl;
|
||||
o.unlockedLockKeepsTheFrame = !!lockEl && lockEl.classList.contains('open')
|
||||
&& getComputedStyle(lockEl).backgroundColor === ORANGE;
|
||||
o.openWindowKeepsTheFrame = !!winEl && winEl.classList.contains('open')
|
||||
&& getComputedStyle(winEl).backgroundColor === ORANGE;
|
||||
// …and a locked lock is neutral again, so the frame still MEANS something
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'lock.front_door': { entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
o.lockedLockIsNeutral = getComputedStyle(elOf(dev('d_lock'))).backgroundColor === NEUTRAL;
|
||||
|
||||
// a VALVE keeps the frame too: nothing morphs its icon, so the frame is the
|
||||
// only thing it has to say «открыт» with (deliberately left alone)
|
||||
const valve = { id: 'v', primary: 'valve.water', entities: ['valve.water'], marker: null };
|
||||
const clsOfState = (d, states) => {
|
||||
const saved = c.hass;
|
||||
const entities = Object.fromEntries(Object.keys(states).map((entity_id) => [
|
||||
entity_id,
|
||||
{ entity_id, platform: 'demo', disabled_by: null },
|
||||
]));
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states },
|
||||
};
|
||||
const r = c._stateClass(d);
|
||||
c.hass = saved;
|
||||
return r;
|
||||
};
|
||||
o.valveOpenKeepsTheFrame = clsOfState(valve, { 'valve.water': { state: 'open', attributes: {} } }) === 'open';
|
||||
o.valveClosedIsNeutral = clsOfState(valve, { 'valve.water': { state: 'closed', attributes: {} } }) === '';
|
||||
// and the cover's own class string, straight from the source
|
||||
const cover = { id: 'k', primary: 'cover.blind', entities: ['cover.blind'], marker: { display: 'icon_ripple' } };
|
||||
o.coverOpenClassIsEmpty = clsOfState(cover, { 'cover.blind': { state: 'open', attributes: { device_class: 'blind' } } }) === '';
|
||||
o.coverOpeningClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'opening', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverClosingClassIsRingOnly = clsOfState(cover, { 'cover.blind': { state: 'closing', attributes: { device_class: 'blind' } } }) === 'activity-transition';
|
||||
o.coverUnavailableStillFades = clsOfState(cover, { 'cover.blind': { state: 'unavailable', attributes: {} } }) === 'unavail';
|
||||
return o;
|
||||
});
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||