Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf77d7d2e1 | ||
|
|
8e2973fa7a | ||
|
|
9bb5f7c5a8 | ||
|
|
121c9f10b9 | ||
|
|
661eb784fb | ||
|
|
9e74051652 | ||
|
|
01980ac3e6 | ||
|
|
36e81e9fb1 | ||
|
|
6d0f97ef82 | ||
|
|
bd9b6a6d75 | ||
|
|
4b03b888ff | ||
|
|
58d952e94e | ||
|
|
5d3f580df0 | ||
|
|
4dc3fdef36 | ||
|
|
8a417539e0 | ||
|
|
c41231a7ba | ||
|
|
2158e5d3f6 | ||
|
|
554d2e6544 | ||
|
|
2cf5c2748e | ||
|
|
59d028caf7 | ||
|
|
4381f65fde | ||
|
|
446f33ed31 | ||
|
|
9419842333 | ||
|
|
02373bb31f | ||
|
|
9a4ecbf961 | ||
|
|
48eebfd8a5 | ||
|
|
5c5833d69a | ||
|
|
a41a75eba5 | ||
|
|
6f34c06d74 | ||
|
|
c237baaffd | ||
|
|
d2bc908280 | ||
|
|
b0c29fb57f | ||
|
|
37d71d8cbb | ||
|
|
f8f1718ad2 | ||
|
|
c8b06996b1 | ||
|
|
1ed281b0dd | ||
|
|
d55816acaf | ||
|
|
cd55a8e897 | ||
|
|
7d152e04f9 | ||
|
|
764129a45c | ||
|
|
fb382bfa11 | ||
|
|
112c260314 | ||
|
|
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 | ||
|
|
0ee80a6a52 | ||
|
|
1397a71f84 |
@@ -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,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
message_file=$1
|
||||
|
||||
# Git-generated merge commits do not represent an independently authored
|
||||
# product change and inherit provenance from their parents.
|
||||
case "$(basename "$message_file")" in
|
||||
MERGE_MSG) exit 0 ;;
|
||||
esac
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
node "$repo_root/scripts/validate-commit-provenance.mjs" \
|
||||
--message-file "$message_file" --staged --check-hook-mode
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Announce release
|
||||
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
# remains allowed to send a test message.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
reusable:
|
||||
required: true
|
||||
type: boolean
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
required: true
|
||||
type: string
|
||||
url:
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
required: true
|
||||
type: boolean
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
TELEGRAM_BOT_TOKEN:
|
||||
required: true
|
||||
TELEGRAM_CHAT_ID:
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
telegram:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
CALLED: ${{ inputs.reusable }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_NAME: ${{ inputs.release_name }}
|
||||
INPUT_URL: ${{ inputs.url }}
|
||||
INPUT_PRE: ${{ inputs.prerelease }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ github.event.release.name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
RELEASE_PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
RELEASE_BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
if [ "$CALLED" = "true" ]; then
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
else
|
||||
TAG=$RELEASE_TAG
|
||||
NAME=$RELEASE_NAME
|
||||
URL=$RELEASE_URL
|
||||
PRE=$RELEASE_PRE
|
||||
BODY=$RELEASE_BODY
|
||||
fi
|
||||
if [ "$PRE" = "true" ]; then
|
||||
echo "Prerelease Telegram announcement is disabled"
|
||||
exit 0
|
||||
fi
|
||||
KIND="🏠 Релиз"
|
||||
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
|
||||
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
|
||||
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
|
||||
fi
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
@@ -0,0 +1,172 @@
|
||||
name: Full Performance
|
||||
|
||||
on:
|
||||
# Every main promotion is a stable-release candidate and must have an
|
||||
# exact-SHA full comparison before stable assets are published.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
comparison_ref:
|
||||
description: "Optional baseline tag, branch or SHA; empty uses the candidate parent"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: full-performance-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
performance:
|
||||
# Base and candidate stay sequential on one hosted runner. Splitting them
|
||||
# across runners would turn machine variance into a false regression.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: candidate
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve comparison SHA
|
||||
id: base
|
||||
working-directory: candidate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
MANUAL_BASE: ${{ inputs.comparison_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
|
||||
git fetch --force --tags --prune --unshallow origin
|
||||
else
|
||||
git fetch --force --tags --prune origin
|
||||
fi
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$MANUAL_BASE" ]; then
|
||||
sha="$(git rev-parse "${MANUAL_BASE}^{commit}" 2>/dev/null || true)"
|
||||
source="manual comparison ref $MANUAL_BASE"
|
||||
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
|
||||
sha="$PUSH_BEFORE_SHA"
|
||||
source="push before"
|
||||
else
|
||||
sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||
source="candidate parent"
|
||||
fi
|
||||
requested_sha="$sha"
|
||||
|
||||
usable=true
|
||||
reason=""
|
||||
if [ -z "$sha" ] || ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
|
||||
usable=false
|
||||
reason="commit is not present after fetching all remote refs"
|
||||
elif [ "$source" = "push before" ] && ! git merge-base --is-ancestor "$sha" HEAD; then
|
||||
usable=false
|
||||
reason="commit is no longer an ancestor of the pushed revision"
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||
if [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
|
||||
sha="$parent_sha"
|
||||
source="candidate parent (unusable requested-base fallback)"
|
||||
echo "::warning::Comparison SHA ${requested_sha:-none} is unusable ($reason); using candidate parent $sha."
|
||||
usable=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
fallback_tag=""
|
||||
fallback_sha=""
|
||||
head_sha="$(git rev-parse HEAD)"
|
||||
while IFS= read -r tag; do
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
tag_sha="$(git rev-list -n 1 "$tag")"
|
||||
if [ "$tag_sha" != "$head_sha" ]; then
|
||||
fallback_tag="$tag"
|
||||
fallback_sha="$tag_sha"
|
||||
break
|
||||
fi
|
||||
done < <(git tag --merged HEAD --sort=-version:refname)
|
||||
if [ -z "$fallback_sha" ]; then
|
||||
echo "::error::No usable comparison commit or previous release tag is reachable from HEAD."
|
||||
exit 1
|
||||
fi
|
||||
sha="$fallback_sha"
|
||||
source="release tag $fallback_tag"
|
||||
echo "::warning::Using $fallback_tag ($sha) as the comparison base."
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
|
||||
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
|
||||
sha="$(git rev-parse HEAD^)"
|
||||
source="candidate parent (HP-PERF-01 compatibility)"
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out base SHA
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.base.outputs.sha }}
|
||||
path: baseline
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
candidate/package-lock.json
|
||||
baseline/package-lock.json
|
||||
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
- name: Install pinned Chromium
|
||||
working-directory: candidate
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
npm --prefix candidate run build
|
||||
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
|
||||
npm --prefix baseline run build
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
|
||||
- name: Capture base and candidate profiles
|
||||
working-directory: candidate
|
||||
run: |
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
|
||||
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
|
||||
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
|
||||
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
|
||||
fi
|
||||
|
||||
- name: Enforce relative and absolute performance budgets
|
||||
working-directory: candidate
|
||||
run: |
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
|
||||
- name: Upload full performance reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: full-performance
|
||||
path: artifacts/performance
|
||||
@@ -0,0 +1,205 @@
|
||||
name: Publish prerelease
|
||||
run-name: Publish ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Exact prerelease tag, for example v1.61.0-beta.4"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: publish-prerelease-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Pin the current dev candidate
|
||||
id: candidate
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$REF_NAME" = "dev" || {
|
||||
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
|
||||
exit 1
|
||||
}
|
||||
SHA=$(git rev-parse HEAD)
|
||||
git fetch origin dev
|
||||
test "$(git rev-parse origin/dev)" = "$SHA" || {
|
||||
echo "::error::The dispatched SHA is no longer the origin/dev tip"
|
||||
exit 1
|
||||
}
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify version, changelogs and bilingual release notes
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
||||
- name: Require green Validate for this exact SHA
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
|
||||
publish:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
VERSION=${TAG#v}
|
||||
grep -Fq "$VERSION" dist/houseplan-card.js
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
|
||||
test "$ZIP_VERSION" = "$VERSION" || {
|
||||
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
|
||||
exit 1
|
||||
}
|
||||
test -s dist/houseplan-card.js
|
||||
test -s houseplan.zip
|
||||
- name: Create or verify the annotated tag
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
||||
if [ -n "$REMOTE" ]; then
|
||||
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
||||
test -n "$PEELED" || {
|
||||
echo "::error::Existing remote tag $TAG is not annotated"
|
||||
exit 1
|
||||
}
|
||||
test "$PEELED" = "$SHA" || {
|
||||
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" "$SHA" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
- name: Stage, verify and publish the prerelease
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
|
||||
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
fi
|
||||
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
|
||||
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
|
||||
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
|
||||
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
- name: Verify the public release and assets
|
||||
id: verify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
|
||||
throw new Error('release is not a public prerelease for the requested tag');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
|
||||
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
|
||||
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Verify HACS prerelease discovery order
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((release) => release.prerelease && !release.draft);
|
||||
if (first?.tag_name !== process.env.EXPECTED_TAG) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
|
||||
process.env.EXPECTED_TAG,
|
||||
);
|
||||
}
|
||||
|
||||
announce:
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
with:
|
||||
reusable: true
|
||||
tag: ${{ needs.gate.outputs.tag }}
|
||||
release_name: ${{ needs.gate.outputs.tag }}
|
||||
url: ${{ needs.publish.outputs.url }}
|
||||
prerelease: true
|
||||
ref: ${{ needs.gate.outputs.tag }}
|
||||
secrets: inherit
|
||||
@@ -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,16 +4,95 @@ on:
|
||||
types: [published]
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
jobs:
|
||||
build:
|
||||
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
|
||||
# workflow only built and uploaded, so an asset shipped while both Validate
|
||||
# runs for the very same commit were red. The asset now waits for a green
|
||||
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
|
||||
#
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# HEAD is the peeled commit even when TAG is annotated. Do not trust
|
||||
# target_commitish (it may be a branch name) or an event-context SHA.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "release tag: $TAG; exact commit: $SHA"
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
- name: Require full performance for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
|
||||
build:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
- name: Verify compositor frame continuity for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run continuity:screencast
|
||||
- name: Upload failed continuity frames
|
||||
if: ${{ failure() && !github.event.release.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: continuity-screencast
|
||||
path: artifacts/continuity-screencast
|
||||
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
# make that distribution failure impossible to miss in the release run.
|
||||
if: ${{ github.event.release.prerelease }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((r) => r.prerelease && !r.draft);
|
||||
const expected = context.payload.release.tag_name;
|
||||
if (first?.tag_name !== expected) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
|
||||
`Use an rc/new version line or correct the release ordering before announcing the update.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
# The branch commit is the release-gate authority. An annotated tag points
|
||||
# to the same SHA and must not duplicate the browser validation jobs.
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
concurrency:
|
||||
group: validate-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
range="$BASE_SHA..$HEAD_SHA"
|
||||
elif [ -n "$BEFORE_SHA" ] && ! echo "$BEFORE_SHA" | grep -Eq '^0+$'; then
|
||||
range="$BEFORE_SHA..$HEAD_SHA"
|
||||
else
|
||||
range="$HEAD_SHA^..$HEAD_SHA"
|
||||
fi
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --range "$range"
|
||||
|
||||
hacs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -13,18 +46,22 @@ jobs:
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
@@ -32,23 +69,25 @@ jobs:
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Card bundle in sync with integration
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
- name: Card bundle snapshots in sync
|
||||
run: |
|
||||
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
|
||||
smoke:
|
||||
# audit T2: the end-to-end layer used to run only when a human remembered.
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a FRESH bundle for the smokes
|
||||
# the committed demo/srv/assets copy is a snapshot; testing it would
|
||||
# report green about code that no longer exists (audit T2)
|
||||
- name: Build a fresh bundle for the smokes
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
run: |
|
||||
@@ -72,10 +111,77 @@ jobs:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
golden:
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
|
||||
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:verify
|
||||
else
|
||||
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
|
||||
npm run golden:capture
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
|
||||
performance_smoke:
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Install pinned Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact candidate source
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture the heaviest Glow state
|
||||
run: |
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
- name: Enforce absolute smoke ceilings
|
||||
run: |
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Browser fixtures are generated by their real ESM factories and then
|
||||
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.13" }
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
|
||||
@@ -5,3 +5,5 @@ test-build/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
.agents/
|
||||
|
||||
@@ -7,13 +7,67 @@ House Plan is one HACS package with two parts plus a demo harness:
|
||||
- **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.
|
||||
Issue and commit provenance is defined in repository-visible `PROCESS.md`;
|
||||
install its commit-message hook in every writable clone.
|
||||
|
||||
## Two-agent workflow
|
||||
|
||||
House Plan is developed by two agents: **Codex is the author** (analysis,
|
||||
estimate, spec, implementation) and **Claude is the reviewer** (estimate, spec,
|
||||
code). They exchange remarks through a local, git-ignored message bus —
|
||||
**read `.agents/PROTOCOL.md` before acting on any task**. It defines the
|
||||
message format, the estimate scales every agent must use, the three stages
|
||||
(`estimate` → `spec` → `code`), the three-round convergence limit and what gets
|
||||
published to GitHub.
|
||||
|
||||
Two rules that matter even if you read nothing else:
|
||||
|
||||
- write only into the *other* agent's inbox, never edit a file you did not
|
||||
create, and move a processed message to `.agents/archive/`;
|
||||
- "verified" without a command and its output is not evidence — from either side.
|
||||
|
||||
## Canonical backlog
|
||||
|
||||
GitHub is the only active backlog for House Plan:
|
||||
|
||||
- [GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the
|
||||
canonical task records: problem, scope, acceptance criteria and discussion.
|
||||
- [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is
|
||||
the canonical prioritization and workflow-status view. Every open in-scope
|
||||
issue must be present there.
|
||||
|
||||
Before starting planned work, find or create its issue and keep its description,
|
||||
labels and Project status current as decisions and implementation state change.
|
||||
Close an issue only after the result is verified. Specs, audits and ADRs may
|
||||
remain under `docs/`, but must link to their issue and must not become a parallel
|
||||
task list. When repository documentation disagrees with Issues or Project v2,
|
||||
the GitHub backlog wins.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
|
||||
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
|
||||
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
|
||||
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, 424 tests at v1.60.0), `npm run build`. After building, keep both committed snapshots in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/` and `cp dist/houseplan-card.js demo/srv/assets/`. CI enforces both comparisons byte-for-byte.
|
||||
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (150 tests at v1.60.0: 100 pure + 50 HA harness). Running `python3 -m pytest tests_backend` without Home Assistant silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the pure set.
|
||||
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed demo snapshot must remain byte-identical to `dist` (CI checks it); rebuilding first also guarantees the browser suite tests the current source in an uncommitted worktree. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
- **Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a stale demo bundle. Build and copy the current bundle first, then review `artifacts/golden/actual/` and `diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the complete Linux CI artifact; never accept a partial scenario or images merely to make CI green. See `demo/golden/README.md`.
|
||||
- **Freshness contract**: the embedded fingerprint covers `src/` plus Rollup, TypeScript and package-lock build inputs. Benchmark and golden tooling must call `assertFreshDemoBundle` before recording any result; a missing or mismatched fingerprint is a hard failure, not a warning.
|
||||
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
|
||||
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
|
||||
- **Owner's local Windows checkout is invisible here.** Path
|
||||
`C:\Users\Sergey\Downloads\dev\houseplan-dev` (workflow notes + often
|
||||
unpushed edits) is **not mounted** into managed Cloud Agent VMs. Do not
|
||||
expect to `ls` or diff that folder. To bring local work into the cloud
|
||||
agent: push a branch to GitHub and say its name, or run a **local** Cursor
|
||||
Agent / My Machines worker inside that checkout. Day-to-day source of truth
|
||||
for cloud sessions remains **`origin/dev`** (minors) and **`origin/main`**
|
||||
(releases) — see `docs/STATUS.md`.
|
||||
|
||||
## Promotion rule
|
||||
|
||||
Every new feature or material behaviour change must be published as a beta/RC
|
||||
before it can enter a stable release, even when its local audit is clean. The
|
||||
stable release commit is promotion-only: version fields, generated bundle
|
||||
snapshots and changelog/release metadata. Do not add feature source code in
|
||||
that commit. An explicit owner-requested emergency hotfix is the only exception
|
||||
and must be called out in the release handoff.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Code review — issue #68 (contextual help)
|
||||
|
||||
Date: 2026-08-12
|
||||
|
||||
Branch: `dev`
|
||||
|
||||
Scope: `hp-help`, Houseplan help factory, localization contract, dialog/overlay lifecycle,
|
||||
keyboard and touch interaction, responsive placement, consumers and regression coverage.
|
||||
|
||||
## Outcome
|
||||
|
||||
The overlay, focus, Escape, outside-click, scroll, Popover/fallback and visual-viewport
|
||||
paths are internally consistent. The shared dialog overlay registry is used correctly,
|
||||
the trigger remains reachable in disabled fieldsets through `legend`, and all seven
|
||||
current call sites have non-empty RU/EN body and ARIA strings.
|
||||
|
||||
Four hardening findings were accepted and fixed locally. No model, saved configuration
|
||||
or user data contract changed.
|
||||
|
||||
## Findings and resolutions
|
||||
|
||||
### CR68-01 — dead trigger is rendered without help content (P1)
|
||||
|
||||
`hp-help` blocked `_openHelp()` when `text` was empty, but `render()` still returned a
|
||||
focusable button. The result was the exact reported defect: a visible help glyph that
|
||||
could not open any explanation.
|
||||
|
||||
Resolution: `hp-help` renders nothing unless both trimmed `text` and `ariaLabel` exist.
|
||||
The same predicate guards opening and closes an already-open surface if either value is
|
||||
removed dynamically.
|
||||
|
||||
### CR68-02 — missing translation could be displayed as a key (P1)
|
||||
|
||||
The card factory called `t()` directly. Its intended generic fallback returns the key
|
||||
name when neither dictionary contains a value, so a future incomplete help pair could
|
||||
produce a real trigger with implementation text such as `marker.foo.help`.
|
||||
|
||||
Resolution: the factory now checks the localized value and its derived `.aria` value
|
||||
through `hasTranslation()` before it creates `hp-help`. English fallback remains valid;
|
||||
a genuinely absent or whitespace-only pair produces no host and no layout gap.
|
||||
|
||||
### CR68-03 — accessible name had a hard-coded English fallback (P1)
|
||||
|
||||
Direct use without `ariaLabel` produced `aria-label="Help"`. This violated the issue
|
||||
contract requiring a complete localized accessible name and made an incomplete component
|
||||
look valid to keyboard and screen-reader users.
|
||||
|
||||
Resolution: the fallback was removed. Missing ARIA copy suppresses the affordance just
|
||||
like missing visible copy.
|
||||
|
||||
### CR68-04 — icon did not follow the product icon system (P2)
|
||||
|
||||
The trigger used a font `?`, whose shape and optical alignment depended on the platform
|
||||
font and did not visually mean “question in a circle”.
|
||||
|
||||
Resolution: the glyph is now the shared MDI `help-circle-outline` vector inside the same
|
||||
32/40 px target. It remains decorative because the button already has a full ARIA label.
|
||||
|
||||
### CR68-05 — regression coverage missed incomplete content (P2)
|
||||
|
||||
The smoke covered all open/close and overlay paths but never instantiated an empty or
|
||||
half-configured component, so CR68-01/03 could pass the release gate.
|
||||
|
||||
Resolution: the #68 smoke now asserts that empty body and empty ARIA copy create no
|
||||
trigger, and that restoring a complete pair creates the circled-question SVG.
|
||||
|
||||
## Reviewed without changes
|
||||
|
||||
- Mouse hover timing, keyboard focus, touch click and the second-Escape dialog path.
|
||||
- `aria-describedby` only while open; the visible bubble stays hidden from the
|
||||
accessibility tree to avoid duplicate announcements.
|
||||
- Exclusive transient-surface ownership with the colour/opacity picker and toast.
|
||||
- Popover API path and dialog-owned fallback portal.
|
||||
- Cached dialog scroll-listener cleanup and disconnect cleanup.
|
||||
- Visual viewport placement, flipping and edge clamping.
|
||||
- Existing call sites and RU/EN localization parity.
|
||||
|
||||
## Verification policy
|
||||
|
||||
Per project policy, no tests were run during this local edit. Static type checking,
|
||||
syntax checking and whitespace validation are recorded in the handoff; the updated
|
||||
targeted smoke is intended for the next prerelease gate.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Код-ревью issue #94 — универсальное «Переключить состояние»
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
|
||||
- **Проверенная версия:** локальный `dev` после `v1.62.0-beta.3`, включая
|
||||
незакоммиченные исправления #95–#97
|
||||
- **Итог ревью до правок:** changes requested — 2 high, 4 medium, 1 minor
|
||||
- **Итог после локальных правок:** замечания устранены; проверки отложены до
|
||||
ближайшего pre-release по принятому правилу владельца
|
||||
|
||||
## 1. Охват
|
||||
|
||||
Проверены:
|
||||
|
||||
1. нормативный алгоритм и acceptance criteria в
|
||||
`docs/specs/094-universal-state-toggle.md`;
|
||||
2. pure resolver `src/device-toggle.ts`;
|
||||
3. target selection через exact binding, device role и `controls`;
|
||||
4. capability/security/service guards;
|
||||
5. dialog projection, hint, lossless Save и preview;
|
||||
6. обычный click, confirmation re-resolve и обработка ошибок;
|
||||
7. общий cover target для действия и presentation;
|
||||
8. backend schema и import/export round-trip;
|
||||
9. unit/smoke-матрица и архитектурная документация;
|
||||
10. совместимость с визуальной непрерывностью #73 и локальными правками
|
||||
#95–#97.
|
||||
|
||||
## 2. Найденные и исправленные замечания
|
||||
|
||||
### CR94-01 — High: domain service ошибочно считался capability конкретной entity
|
||||
|
||||
**Было:** `POWER_DOMAINS` разрешал `climate`, `water_heater`, `siren` и
|
||||
`camera`, если нужный service существовал на уровне domain. Но HA публикует
|
||||
services для всего domain; неподдерживающая их конкретная entity всё равно
|
||||
оставалась «исполняемой» в hint, а вызов затем отклонялся Home Assistant.
|
||||
|
||||
Это прямо противоречило §9.1 и mutation gate 6 ТЗ. Home Assistant Core
|
||||
подтверждает entity-level guards:
|
||||
|
||||
- Climate `TURN_OFF=128`, `TURN_ON=256`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/climate/const.py
|
||||
- Water heater `ON_OFF=8`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/water_heater/__init__.py
|
||||
- Siren `TURN_ON=1`, `TURN_OFF=2`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/siren/const.py
|
||||
- Camera `ON_OFF=1`:
|
||||
https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py
|
||||
|
||||
**Исправлено:** введён декларативный `POWER_ADAPTERS` с state semantics,
|
||||
unknown policy и точными feature masks. Feature-gated entity теперь получает
|
||||
команду только при наличии требуемых bits; service catalog остаётся вторым
|
||||
guard. Media player и legacy vacuum включены в тот же реестр.
|
||||
|
||||
**Покрытие:** параметрические unit-матрицы для всех базовых power adapters и
|
||||
для climate, media player, siren, water heater, camera, legacy vacuum — как
|
||||
разрешённые, так и запрещённые/missing-feature варианты; отдельная матрица
|
||||
`unknown` проверяет полный/неполный capability mask.
|
||||
|
||||
### CR94-02 — High: click мог использовать target из сохранённого визуального frame
|
||||
|
||||
**Было:** #73 намеренно может некоторое время показывать последний цельный
|
||||
`_renderDevices` snapshot, но `_clickDevice(ev, d)` разрешал action прямо по
|
||||
переданному `d`. Если binding/controls изменились до атомарной смены frame,
|
||||
нажатие без confirmation могло вызвать прежнюю цель. Confirmation уже делал
|
||||
повторное разрешение, обычный click — нет.
|
||||
|
||||
**Исправлено:** в View действие сначала находит текущий `DevItem` в
|
||||
`this._devices` по стабильному marker id. Action, binding, controls и command
|
||||
разрешаются только из него; исчезнувший marker даёт no-op. Локальная
|
||||
House Plan info-card по-прежнему может использовать видимый snapshot — это
|
||||
безопасная read-only поверхность и намеренный контракт исправления #96.
|
||||
|
||||
**Покрытие:** smoke сохраняет старый `DevItem`, меняет controls, перестраивает
|
||||
live devices и проверяет, что click вызывает только новую группу.
|
||||
|
||||
### CR94-03 — Medium: неизвестный persisted action расходил UI и runtime
|
||||
|
||||
**Было:** неизвестный token на light проецировался как default `toggle`, тогда
|
||||
как `toggleOriginOf()` правильно не признавал его toggle-origin. Селектор мог
|
||||
показать «Переключить состояние», hint оставался пустым, а click был no-op.
|
||||
|
||||
**Исправлено:** light default применяется только к действительно отсутствующему
|
||||
token (`null`, `undefined`, пустая legacy-строка). Неизвестное значение fail-
|
||||
closed проецируется в локальную карточку; backend по-прежнему отклоняет его при
|
||||
записи.
|
||||
|
||||
### CR94-04 — Medium: legacy cover терял identity после disable в HA
|
||||
|
||||
**Было:** legacy `tap_action: cover` искал cover только в active
|
||||
`device.entities`, если рядом оставался хотя бы один активный sibling. После
|
||||
disable cover в HA старое явное намерение превращалось в анонимный no-target и
|
||||
presentation переставал знать прежнюю cover entity.
|
||||
|
||||
**Исправлено:** legacy-cover origin сначала сохраняет приоритет активной cover,
|
||||
а при её отсутствии ищет историческую цель в `allEntities`. Общий resolver
|
||||
возвращает `ha-disabled` и сохраняет тот же cover identity для hint/presentation,
|
||||
но более ранняя disabled registry row не может заслонить рабочую cover. Новый
|
||||
device-role toggle по-прежнему исключает disabled rows.
|
||||
|
||||
### CR94-05 — Medium: пустой service catalog считался поддержкой всех services
|
||||
|
||||
**Было:** отсутствие/пустой `hass.services` давало optimistic `true` для любого
|
||||
service. Это нарушало runtime guard из ТЗ и позволяло построить команду без
|
||||
доказательства её существования.
|
||||
|
||||
**Исправлено:** отсутствующий catalog/domain/service теперь означает
|
||||
`unsupported`. После появления актуального HA snapshot resolver автоматически
|
||||
пересчитывает hint и command. Синтетический HA в `demo/srv/demo.html` теперь
|
||||
публикует явный service catalog, поэтому smoke-среда проверяет тот же fail-closed
|
||||
контракт и не создаёт ложные no-op.
|
||||
|
||||
### CR94-06 — Medium: device binding не выбирал первую действительно поддерживаемую entity роли
|
||||
|
||||
**Было:** resolver выбирал первую entity «подходящего domain», а затем мог
|
||||
остановиться на `unsupported`, хотя следующая равноправная entity той же
|
||||
functional role имела требуемую capability. Это не соответствовало формулировке
|
||||
§8.1 «первая поддерживаемая entity».
|
||||
|
||||
**Исправлено:** проверка идёт по уже выбранной shared functional role.
|
||||
Capability-unsupported peer можно пропустить только внутри неё; missing,
|
||||
unavailable и secure identity сохраняются без retarget. Config/diagnostic
|
||||
switch более слабой роли по-прежнему никогда не подставляется.
|
||||
|
||||
### CR94-07 — Minor: статус ТЗ оставался «готово к реализации»
|
||||
|
||||
**Исправлено:** ТЗ, specs index, архитектура, STATUS, TESTING и RU/EN changelog
|
||||
актуализированы под опубликованную beta.3 и этот локальный hardening pass.
|
||||
|
||||
## 3. Проверенные инварианты без изменений
|
||||
|
||||
- exact `entity:` binding не ищет sibling при unsupported/missing/unavailable;
|
||||
- raw external controls владеют tap только у explicit toggle и не дают fallback
|
||||
на собственную entity контроллера;
|
||||
- passive forced-light marker сохраняет единственное документированное driver-
|
||||
исключение и дедупликацию;
|
||||
- partial group вызывает только отображённое доступное подмножество;
|
||||
- any-on/all-off group semantics соответствует ТЗ;
|
||||
- lock, alarm и cover classes `garage`/`door`/`gate` остаются secure no-op;
|
||||
- cover/valve open/close/stop используют одновременно feature bit и service;
|
||||
- confirmation сравнивает target set, а направление намеренно пересчитывается
|
||||
по текущему state;
|
||||
- legacy `cover` и отсутствующий default-light action сохраняются lossless до
|
||||
явного изменения select;
|
||||
- backend принимает текущие actions и legacy `cover`, неизвестные tokens
|
||||
отклоняет; import/export сохраняет action-поля без преобразования;
|
||||
- отдельного `cover` в текущем UI нет;
|
||||
- right-click, long press, touch/pinch и confirmation UX этим проходом не
|
||||
менялись.
|
||||
|
||||
## 4. Изменённые файлы
|
||||
|
||||
- `src/device-toggle.ts`
|
||||
- `src/houseplan-card.ts`
|
||||
- `test/device-toggle.test.mjs`
|
||||
- `demo/smoke_controls.mjs`
|
||||
- `demo/srv/demo.html`
|
||||
- `docs/specs/094-universal-state-toggle.md`
|
||||
- `docs/specs/README.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/STATUS.md`
|
||||
- `docs/TESTING.md`
|
||||
- `docs/CHANGELOG.md`
|
||||
- `docs/CHANGELOG.ru.md`
|
||||
|
||||
## 5. Проверка
|
||||
|
||||
Локально выполнены только read-only/static проверки ревью:
|
||||
|
||||
- `git diff --check`;
|
||||
- `npm run typecheck`;
|
||||
- `node --check test/device-toggle.test.mjs`;
|
||||
- `node --check demo/smoke_controls.mjs`;
|
||||
- поиск всех consumers `resolveToggleIntent`, `projectedTapAction`,
|
||||
`toggleCoverEntity`, `sameToggleCommandTargets`;
|
||||
- сверка backend schema/import-export;
|
||||
- сверка capability flags с официальным Home Assistant Core.
|
||||
|
||||
Unit, browser smoke, backend tests, build и generated bundles **не запускались**
|
||||
по правилу проекта: локальные правки делаются без тестов, минимальный целевой
|
||||
прогон выполняется при следующем pre-release.
|
||||
@@ -16,6 +16,16 @@ writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
|
||||
is the quickest route to the author and other users. Bugs and concrete feature
|
||||
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
|
||||
## Backlog and work status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
|
||||
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
|
||||
only active project backlog. Issues own scope and acceptance criteria; Project
|
||||
v2 owns prioritization and workflow status. Before starting planned work, link
|
||||
it to an existing issue or create one, add it to the Project, and keep both
|
||||
surfaces current until the verified result is closed. Design specs and ADRs may
|
||||
support an issue, but they do not replace it or maintain a separate checklist.
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
```bash
|
||||
@@ -25,6 +35,7 @@ npm run typecheck # tsc --noEmit (strict)
|
||||
npm test # node:test — pure logic, i18n parity, tap-action security
|
||||
npm run build # tsc + rollup → dist/houseplan-card.js
|
||||
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
|
||||
git config core.hooksPath .githooks # issue/release provenance trailers
|
||||
```
|
||||
|
||||
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
|
||||
@@ -40,7 +51,8 @@ every push — locally they are skipped when `homeassistant` is not importable.
|
||||
- The built card must be committed in sync: `cp dist/houseplan-card.js
|
||||
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
|
||||
- Tap actions have a security model (locks/alarms never toggle from the plan) —
|
||||
see `resolveTapAction` in `src/logic.ts`; don't weaken it.
|
||||
see `resolveToggleIntent` in `src/device-toggle.ts`; don't weaken it.
|
||||
- Every commit follows the issue and trailer contract in `PROCESS.md`.
|
||||
- Follow the Integration Quality Scale where applicable —
|
||||
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# House Plan change provenance
|
||||
|
||||
This file is the repository-visible minimum process contract. The detailed
|
||||
two-agent workflow lives in `.agents/PROTOCOL.md` in the owner's checkout;
|
||||
contributors and fresh agents must still be able to discover the rules below
|
||||
from a plain clone.
|
||||
|
||||
## Before changing code
|
||||
|
||||
Every product, test, documentation or release change must belong to a GitHub
|
||||
issue in the canonical House Plan Project. Keep scope and acceptance criteria
|
||||
there; a local spec supports an issue but does not replace it.
|
||||
|
||||
## Commit trailers
|
||||
|
||||
Every non-merge commit carries:
|
||||
|
||||
```text
|
||||
Issue: #123
|
||||
User-Visible: yes
|
||||
```
|
||||
|
||||
Use `User-Visible: no` for refactoring, tests, build tooling and documentation
|
||||
that do not change product behaviour. A commit that changes reviewed golden
|
||||
baselines additionally carries both:
|
||||
|
||||
```text
|
||||
Release: v1.2.3-beta.1
|
||||
Baseline-Reviewed: <CI run or artifact reference>
|
||||
```
|
||||
|
||||
Never invent a review reference merely to pass a gate. Baselines are accepted
|
||||
only from the complete Linux CI artifact via `golden:accept -- --reviewed`.
|
||||
|
||||
Install the repository hook once per clone:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
The hook checks message provenance locally; `validate.yml` enforces the same
|
||||
terminal-trailer contract for every non-merge commit in a push or PR, so
|
||||
`--no-verify`, rebases and fresh clones cannot bypass it. Test, build and release gates remain
|
||||
the commands documented in `CONTRIBUTING.md` and `docs/TESTING.md`.
|
||||
|
||||
## Release history
|
||||
|
||||
Do not rewrite published commits to add missing trailers. Record the gap in an
|
||||
audit and enforce this contract on future work. Promotion-only stable commits
|
||||
remain subject to the same Issue/User-Visible trailers.
|
||||
@@ -15,6 +15,11 @@ room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wa
|
||||
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
|
||||
right on your Lovelace dashboard.
|
||||
|
||||
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
|
||||
> on phones and tablets. The editors are designed primarily for a desktop
|
||||
> browser with a mouse and keyboard; individual editing operations on touch
|
||||
> devices may be awkward, limited, or unavailable.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Try it live — no install needed
|
||||
@@ -39,8 +44,9 @@ right on your Lovelace dashboard.
|
||||
you drag, so the drawing and the photo of your plan finally line up.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp casts a pool
|
||||
of its own color that spills through doorways and open zone boundaries.
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
|
||||
the floor it can see: through doorways and open boundaries, stopped by walls,
|
||||
columns and partitions, which cast real shadows.
|
||||
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
|
||||
the day (white noon → golden hour → deep night), while windows on exterior
|
||||
walls cast real wedges of sunlight into the rooms; optional cloud cover
|
||||
@@ -57,8 +63,10 @@ right on your Lovelace dashboard.
|
||||
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
|
||||
drives the plan in real time, pouring its path out from under itself;
|
||||
current and previous cleanup runs are recorded server-side. Calibration is
|
||||
one click (rooms matched by name) or a drag-and-stretch overlay. Works with
|
||||
Xiaomi Cloud Map Extractor, Tasshack dreame-vacuum and Valetudo.
|
||||
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
|
||||
source picker also covers registry-less map cameras without silently
|
||||
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
|
||||
dreame-vacuum and Valetudo.
|
||||
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
|
||||
**server-side** — one shared plan for every user and screen, synced live.
|
||||
|
||||
@@ -84,16 +92,27 @@ The integration consists of two parts that are installed together:
|
||||
|
||||
## How it differs from alternatives
|
||||
|
||||
A house plan in Home Assistant is usually built with `picture-elements`, `ha-floorplan` and similar solutions. There you have to write YAML by hand, calculate the coordinates of every icon, and edit the config again after every change. House Plan works differently:
|
||||
A house plan in Home Assistant is usually built with `picture-elements`,
|
||||
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
|
||||
dashboard. Those either lock you into YAML/SVG, or store the plan in the
|
||||
Lovelace card config. House Plan is a **shared live map** backed by a Home
|
||||
Assistant integration:
|
||||
|
||||
| | House Plan | Typical solutions (picture-elements / ha-floorplan) |
|
||||
|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML and code editing |
|
||||
| **Adding devices** | Automatic, by room | You type in every entity by hand |
|
||||
| **Icon coordinates** | Drag with the mouse | You count pixels and write them into the config |
|
||||
| **Room markup** | Built-in outline editor | You draw in an external SVG editor |
|
||||
| **Storage** | On the HA server (shared by all devices) | In the dashboard YAML |
|
||||
| **Zoom** | Smooth zoom, everything stays crisp (vector) | Usually a fixed image |
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
|
||||
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
|
||||
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
|
||||
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
|
||||
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
|
||||
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
|
||||
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
|
||||
|
||||
**One sentence:** House Plan is the shared, area-aware live map of your home —
|
||||
not a general-purpose CAD package. Its Background editor covers practical
|
||||
decor, labels and furniture; if you need unrestricted architectural drafting,
|
||||
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
|
||||
every tablet on the same live layout.
|
||||
|
||||
Key advantages in short:
|
||||
|
||||
@@ -102,11 +121,17 @@ Key advantages in short:
|
||||
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
|
||||
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
|
||||
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
|
||||
a light is shining, a socket is powering, a fan is spinning, media is playing, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). Orange = open / unlocked.
|
||||
a light is shining, a socket is powering, a fan is spinning, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
|
||||
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
|
||||
that mode is the best available fallback. Orange = open / unlocked.
|
||||
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
|
||||
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
|
||||
A translucent icon = unavailable. Dark = idle.
|
||||
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
|
||||
furniture, edit physical styles and transform every object with the same
|
||||
selection model. The plan image has its own move/resize/rotate tool, numeric
|
||||
properties and shared Undo/Redo.
|
||||
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
|
||||
|
||||
---
|
||||
@@ -188,7 +213,7 @@ for each floor (names prefilled, a plan image is asked for one by one; any floor
|
||||
|
||||

|
||||
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions and can be moved and resized at any time in the Background editor.
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
|
||||
|
||||

|
||||
|
||||
@@ -212,15 +237,17 @@ Rooms may not overlap: a click strictly inside an existing room, or an outline t
|
||||
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||
|
||||
|
||||
### Doors, windows and locks
|
||||
### Doors, windows, gates and locks
|
||||
|
||||
In markup mode the **"Opening"** tool places doors and windows: click next to a wall and the
|
||||
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
|
||||
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
|
||||
window 120 cm), an open/close sensor and — for doors — a **lock entity**.
|
||||
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
|
||||
|
||||
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
|
||||
draws itself in as the real door opens; a window opens its two casements. While open, the moving
|
||||
parts take an accent colour. A door with a lock shows a padlock badge next to it — green when
|
||||
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
|
||||
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
|
||||
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
|
||||
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
|
||||
on the opening shows a status card with both states instead.
|
||||
|
||||
@@ -231,7 +258,7 @@ walls** (it slides around corners too), and a **double click opens its propertie
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Show hidden"**: hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light.
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
|
||||
|
||||
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
|
||||
|
||||
@@ -251,12 +278,15 @@ Switch to the **Devices** tab to arrange icons: drag them with the mouse, click
|
||||
|
||||
### Tap actions: control devices from the plan
|
||||
|
||||
By default a tap on an icon opens its info card. In the card settings you can switch
|
||||
**Tap on a device** to *Toggle* — a tap then switches lights, sockets, fans and
|
||||
humidifiers directly on the plan (wall-tablet style). For safety, a card-wide toggle
|
||||
never affects locks, alarms, covers or valves; you can consciously enable toggle for a
|
||||
specific device (except locks and alarms — those never toggle from the plan) in its
|
||||
edit dialog. A **long press** always opens the info card.
|
||||
By default a tap on an icon opens its info card. A device can instead use the
|
||||
universal **Toggle state** action. Its editor shows the exact entity or configured
|
||||
group, the current state and what the next tap will do; when nothing can be toggled,
|
||||
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
|
||||
Lights keep their convenient toggle default. Covers and valves use open/close/stop
|
||||
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
|
||||
remain blocked. An exact entity binding never falls through to a sibling switch, and
|
||||
temporarily unavailable group members are skipped without erasing the configuration.
|
||||
A **long press** still opens the info card and right-click still opens HA more-info.
|
||||
|
||||
### Icon rules
|
||||
|
||||
@@ -272,6 +302,16 @@ You can also place a **single entity** (not just a whole device): start typing i
|
||||
|
||||
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
|
||||
|
||||
To represent a dumb physical lamp controlled by a smart relay, place a virtual
|
||||
point where the lamp really is and set **Light source → Always**. Manual colour,
|
||||
brightness and radius stay available even though the point has no HA entity.
|
||||
Then open the relay and add that plan source under **Controls other light
|
||||
sources**. The relay continues to show the aggregate working state, while Glow,
|
||||
room fill and statistics belong to the lamp's position. An unlinked passive
|
||||
Always source is deliberately constant-on. With several own `light.*`/`switch.*`
|
||||
entities, Always also offers a leading-entity selector; a missing saved choice
|
||||
is warned about and retained while a deterministic fallback is used.
|
||||
|
||||
The same dialog controls how the device looks on the plan. **Display** switches between the
|
||||
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
|
||||
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
|
||||
@@ -280,6 +320,10 @@ turned the way it is mounted.
|
||||
|
||||

|
||||
|
||||
### Styling the plan with card-mod (advanced, unsupported)
|
||||
|
||||
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
@@ -313,7 +357,7 @@ Services → House Plan**.
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. If the device exists but is hidden (the "Hide device from plan" checkbox — set automatically for bridges, scenes and other non-physical records) — open the device editor, press **"Show hidden"** and untick the box in its dialog.
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
@@ -13,6 +15,11 @@
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
|
||||

|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
@@ -29,14 +36,15 @@
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери и окна, комнаты-острова,
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
даёт пятно своего цвета, проникающее через дверные проёмы и открытые зоны.
|
||||
освещает ровно тот пол, который видит: через проёмы и открытые границы,
|
||||
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
@@ -53,8 +61,10 @@
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Работают Xiaomi
|
||||
Cloud Map Extractor, dreame-vacuum (Tasshack) и Valetudo.
|
||||
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
|
||||
выбор источника поддерживают registry-less камеры карт и не подменяют молча
|
||||
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
|
||||
(Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
@@ -81,16 +91,26 @@ House Plan показывает ваш умный дом так, как он в
|
||||
|
||||
## Чем отличается от аналогов
|
||||
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan` и подобные решения. Там приходится вручную писать YAML, вычислять координаты каждой иконки и заново править конфиг при каждом изменении. House Plan устроен иначе:
|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
|
||||
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
|
||||
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
|
||||
план** на серверной интеграции Home Assistant:
|
||||
|
||||
| | House Plan | Обычные решения (picture-elements / ha-floorplan) |
|
||||
|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML и правка кода |
|
||||
| **Добавление устройств** | Автоматически по комнатам | Каждую сущность вписываете руками |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели и пишете в конфиг |
|
||||
| **Разметка комнат** | Встроенный редактор контуров | Рисуете в стороннем редакторе SVG |
|
||||
| **Хранение** | На сервере HA (общее для всех устройств) | В YAML дашборда |
|
||||
| **Масштаб** | Плавный зум, всё остаётся чётким (вектор) | Обычно фиксированная картинка |
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
|
||||
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
|
||||
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
|
||||
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
|
||||
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
|
||||
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
|
||||
|
||||
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
|
||||
универсальная CAD-система. Редактор подложки покрывает практический декор,
|
||||
надписи и мебель; для свободного архитектурного черчения лучше отдельный
|
||||
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
|
||||
на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
@@ -99,12 +119,14 @@ House Plan показывает ваш умный дом так, как он в
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, медиа играет, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
|
||||
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
|
||||
Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Подложку можно двигать и масштабировать.** Картинка плана не прибита к холсту: в редакторе подложки её тянут за тело и за угол, а плашка показывает реальный размер в метрах — чертёж и фотография плана наконец совмещаются.
|
||||
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
@@ -188,7 +210,7 @@ title: План дома
|
||||
|
||||

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

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

|
||||
|
||||
### Свои стили через card-mod (для продвинутых, без поддержки)
|
||||
|
||||
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
@@ -314,7 +340,7 @@ title: План дома
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Если устройство есть, но скрыто (галка «Скрыть устройство с плана» — для мостов, сцен и прочих нефизических записей она ставится автоматически) — откройте редактор устройств, нажмите **«Показать скрытые»** и снимите галку в его диалоге.
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# Ревью ТЗ `docs/specs/089-isometric-view-stage1.md`
|
||||
|
||||
Дата ревью: 2026-08-11
|
||||
Issue: [#89](https://github.com/Matysh/houseplan-card/issues/89)
|
||||
Проверенная версия ТЗ: локальный `dev`, после `v1.62.0-beta.1`, SHA `2cf5c27`
|
||||
|
||||
## Итог
|
||||
|
||||
Направление выбрано правильно: фиксированная SVG-проекция, отсутствие новой
|
||||
модели данных, каноническая геометрия стен, плоские редакторы, скрытая поставка
|
||||
через Labs и обязательные golden/performance gates хорошо соответствуют
|
||||
текущей архитектуре House Plan.
|
||||
|
||||
Однако статус **«готово к реализации» пока преждевременен**. В ТЗ остаются
|
||||
восемь блокирующих неоднозначностей. Главная из них — документ описывает
|
||||
проекцию точек, но не определяет переход между тремя реально существующими
|
||||
системами координат и не задаёт новый контракт viewport/frame. Если начать
|
||||
реализацию буквально по текущему тексту, наиболее вероятный результат —
|
||||
прыжок масштаба при переключении, рассинхронизация HTML-маркеров и SVG, неверный
|
||||
warm-remount либо двойное обратное преобразование pointer events.
|
||||
|
||||
Рекомендация: внести блокеры B1–B8 и существенные замечания M1–M8 в ТЗ, после
|
||||
чего документ можно переводить в `approved`. Переписывать продуктовую часть
|
||||
или менять выбранный renderer не требуется.
|
||||
|
||||
## Что уже зафиксировано хорошо
|
||||
|
||||
1. Labs не меняет backend, schema/config/layout и не попадает в backup.
|
||||
2. Плоский вид остаётся default и fallback; редакторы остаются плоскими.
|
||||
3. CSS 3D, WebGL и многократное клонирование SVG явно запрещены.
|
||||
4. Источник wall geometry — `wallBodiesGeometry()`, а не новый параллельный
|
||||
контур.
|
||||
5. Проёмы должны быть настоящими разрывами masonry geometry.
|
||||
6. Glow сохраняет один регион на источник и один blur на слой по `LIGHT.md`.
|
||||
7. Кэш не должен зависеть только от `_cfgEpoch`.
|
||||
8. У stage 1 нет публичного обещания и пользовательской миграции.
|
||||
|
||||
---
|
||||
|
||||
## Блокирующие замечания
|
||||
|
||||
### B1 — Не определены системы координат, pivot и projected frame
|
||||
|
||||
**Где:** §4, §7, §8, AC 8.
|
||||
**Критичность:** blocker.
|
||||
|
||||
`projectPoint(p, z, cam)` и `unprojectPoint(screen, cam)` недостаточны для
|
||||
существующего renderer. Сейчас House Plan различает как минимум:
|
||||
|
||||
- plan/model units (`room`, `wall`, `marker`);
|
||||
- координаты SVG scene/viewBox (`_view`);
|
||||
- client pixels внутри `.stage` (`_screenToVb()`).
|
||||
|
||||
В объёмном виде plan units и scene units перестают совпадать. Кроме того,
|
||||
`IsoCamera` не содержит pivot/origin и масштаба оси Z. Проекция вокруг `(0, 0)`
|
||||
сместит план при смене пространства, а старый `_baseVb()` не включает поднятые
|
||||
верхние грани и начнёт обрезать стены.
|
||||
|
||||
**Что добавить в ТЗ:**
|
||||
|
||||
```ts
|
||||
type PlanPoint = readonly [number, number];
|
||||
type ScenePoint = readonly [number, number];
|
||||
|
||||
interface IsoCamera {
|
||||
rotDeg: number;
|
||||
tiltDeg: number;
|
||||
xyScale: number;
|
||||
zScale: number;
|
||||
origin: PlanPoint;
|
||||
}
|
||||
|
||||
projectPlanPoint(p: PlanPoint, zUnits: number, cam: IsoCamera): ScenePoint;
|
||||
unprojectFloorPoint(p: ScenePoint, cam: IsoCamera): PlanPoint; // только z=0
|
||||
clientToScenePoint(client: readonly [number, number], stageRect: DOMRectReadOnly,
|
||||
view: ViewRect): ScenePoint;
|
||||
projectedFrame(input: IsoFrameInput, cam: IsoCamera): ViewRect;
|
||||
```
|
||||
|
||||
Нормативно определить:
|
||||
|
||||
1. `projectPoint` возвращает **scene**, а не screen/client coordinates.
|
||||
2. `unprojectFloorPoint` инвертирует только плоскость `z=0`; высотная грань не
|
||||
имеет единственной plan-точки.
|
||||
3. Pivot — одна фиксированная plan-space константа (рекомендуемо
|
||||
`[NORM_W / 2, NORM_W / 2]`), а не центр viewport/content frame и не
|
||||
положение курсора. Иначе появление far marker или переключение `_showFar`
|
||||
сдвинет уже построенные стены без изменения их геометрии.
|
||||
4. Wall height сначала переводится из общей константы в plan units, затем
|
||||
применяется `zScale`; выбранные значения фиксируются ADR.
|
||||
5. `fit`, pan clamp, home arrow, far-object hint и initial view используют
|
||||
`projectedFrame`, включающий floor content и поднятые wall faces.
|
||||
6. Projected frame не зависит от текущего zoom/pan и входит в geometry cache.
|
||||
|
||||
Без этого нельзя проверить «не меняет фокус плана скачком» и «не обрезает
|
||||
объекты».
|
||||
|
||||
### B2 — Не задано преобразование viewport при flat ↔ iso и при входе в редактор
|
||||
|
||||
**Где:** §7, §10, AC 7–9.
|
||||
**Критичность:** blocker.
|
||||
|
||||
Текущий `_view` хранит прямоугольник именно в координатах текущего SVG. Его
|
||||
нельзя без преобразования перенести из flat scene в iso scene. Текущий
|
||||
`_viewModeSnap` также хранит `cx/cy` в flat units. Требование «не менять zoom и
|
||||
фокус» сейчас не имеет алгоритма.
|
||||
|
||||
**Добавить нормативный алгоритм:**
|
||||
|
||||
1. Перед сменой проекции получить логический центр пола:
|
||||
- flat: центр `_view` уже является plan point;
|
||||
- iso: центр `_view` пропустить через `unprojectFloorPoint`.
|
||||
2. Построить target frame и target fit.
|
||||
3. Сохранить тот же scalar zoom.
|
||||
4. Спроецировать логический центр в target scene и вызвать `_applyView()` с
|
||||
этим scene center.
|
||||
5. Не переиспользовать raw `x/y/w/h` между видами.
|
||||
6. Вход в editor выполняет тот же iso → flat переход; выход — flat → прежний
|
||||
view kind. Смена пространства внутри editor сбрасывает старый snapshot по
|
||||
существующему правилу.
|
||||
|
||||
Предпочтение вида (`flat|iso`) и viewport — разные сущности. В localStorage
|
||||
пишется только предпочтение и существующий scalar zoom; raw viewport остаётся
|
||||
runtime/warm state.
|
||||
|
||||
### B3 — ТЗ не совместимо с `docs/WARM-REMOUNT.md`
|
||||
|
||||
**Где:** §7 «Непрерывность», §10.
|
||||
**Критичность:** blocker.
|
||||
|
||||
#73 переносит через `warmBoot` точный `_view`, `_viewModeSnap`, mode и
|
||||
fingerprint кадра. После введения iso один и тот же `ViewRect` имеет два разных
|
||||
смысла. Если новый экземпляр восстановит iso rectangle в flat mode (например,
|
||||
флаг снят/истёк) либо наоборот, получится именно тот скачок/пустой кадр, который
|
||||
#73 устраняет.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- warm viewport хранит `projection: 'flat'|'iso'` и `logicalCenter`;
|
||||
- raw `_view` усыновляется только при совпадении space, projection и активного
|
||||
Labs contract;
|
||||
- при несовпадении восстанавливаются scalar zoom + logical center через
|
||||
алгоритм B2, а не чужой rectangle;
|
||||
- frame fingerprint включает effective projection и iso geometry fingerprint;
|
||||
- выключение/expiry Labs никогда не может воскресить iso DOM из memo;
|
||||
- отдельный smoke: iso → remount → тот же iso frame; iso → снять flag →
|
||||
remount → корректный flat frame без veil/flash.
|
||||
|
||||
### B4 — Правило «все попадания через unproject» технически неверно
|
||||
|
||||
**Где:** §7 Pointer, §11 smoke Pointer.
|
||||
**Критичность:** blocker.
|
||||
|
||||
SVG сам hit-тестирует элементы внутри трансформированного `<g>`. Room hover и
|
||||
SVG opening symbols не нужно вручную unproject-ить: это даст двойное
|
||||
преобразование. HTML marker также получает click как обычный DOM-элемент.
|
||||
Кроме того, marker drag выполняется в Device editor, а по этому же ТЗ все
|
||||
редакторы плоские; smoke «перетаскивание маркера в объёмном виде» противоречит
|
||||
scope.
|
||||
|
||||
**Заменить правило на:**
|
||||
|
||||
- SVG/HTML interactive children используют нативный DOM/SVG hit-test;
|
||||
- pan и zoom anchor работают в scene coordinates;
|
||||
- `client → scene → unprojectFloor` применяется только там, где stage event
|
||||
действительно должен получить plan coordinate;
|
||||
- в stage 1 iso mode не создаёт/редактирует geometry и не перетаскивает
|
||||
markers, поэтому editor `_svgPoint()` остаётся flat;
|
||||
- тесты кликают реальные room/device/opening DOM targets и проверяют action;
|
||||
отдельный unit проверяет `client → scene → floor` для будущего использования.
|
||||
|
||||
### B5 — Kiosk UX противоречит фактическому DOM
|
||||
|
||||
**Где:** §3, §7, AC 2/4.
|
||||
**Критичность:** blocker.
|
||||
|
||||
ТЗ обещает кнопку «в режиме просмотра (и в киоске) рядом с шапкой». В текущем
|
||||
kiosk вся `.hdr.kioskhide` имеет `display:none`; такой кнопки физически не
|
||||
будет. Одновременно §7 говорит, что скрытая панель не должна лишить пользователя
|
||||
возврата в flat.
|
||||
|
||||
Для скрытого stage 1 рекомендуется закрепить простой вариант:
|
||||
|
||||
1. Кнопка существует только в обычном View под активным Labs.
|
||||
2. Kiosk читает последнее per-space предпочтение этого браузера.
|
||||
3. `hp-labs=-iso` или `hp-labs=off` — обязательный аварийный путь: kiosk сразу
|
||||
становится flat и не может восстановить iso из warm memo.
|
||||
4. В kiosk нет новой панели/диалога stage 1.
|
||||
5. Smoke покрывает загрузку kiosk с сохранённым `iso` и возврат в flat через
|
||||
URL operation.
|
||||
|
||||
Если владельцу нужен переключатель прямо в kiosk, его надо отдельно поместить
|
||||
в существующий long-press kiosk dialog; «рядом с шапкой» всё равно неверно.
|
||||
|
||||
### B6 — Грамматика Labs содержит противоречия и ломает комбинированный hash
|
||||
|
||||
**Где:** §2.2–2.4.
|
||||
**Критичность:** blocker.
|
||||
|
||||
Не определено:
|
||||
|
||||
- кто сильнее при одновременных `?hp-labs=` и `#hp-labs=`;
|
||||
- является URL полным replacement или операциями над storage;
|
||||
- что делает `iso,-iso`, `off,iso`, повторный параметр;
|
||||
- §2.2 требует не удалять параметр из URL, а §2.3 говорит, что `off` «очищает
|
||||
и то, и другое»;
|
||||
- как `#space=x&hp-labs=iso` сохраняет существующий deep link;
|
||||
- что происходит при `history.back()`/`popstate`.
|
||||
|
||||
**Предлагаемый точный контракт:**
|
||||
|
||||
1. База — валидный набор из storage.
|
||||
2. Query operations применяются слева направо, затем hash operations слева
|
||||
направо; hash сильнее, потому что именно он реактивен внутри Lovelace.
|
||||
3. `id` добавляет, `-id` удаляет, `off` очищает набор в этой позиции; следующие
|
||||
токены снова могут добавлять.
|
||||
4. Повторные `hp-labs` обрабатываются в порядке появления.
|
||||
5. Если в URL был хотя бы один известный operation или `off`, итог пишется в
|
||||
storage. Неизвестные значения сами по себе storage не переписывают.
|
||||
6. URL никогда не переписывается механизмом Labs. Из §2.3 убрать слова об
|
||||
очистке URL: `off` очищает **effective set и storage**, но остаётся видимым.
|
||||
7. Hash разбирается общим helper вместе с `space`; оба порядка параметров и
|
||||
percent-encoding тестируются. `_hashSpace()` не остаётся вторым regex parser.
|
||||
8. `hashchange` реактивен; `popstate` перечитывает query/hash, если URL реально
|
||||
сменился без reload.
|
||||
|
||||
### B7 — Не определена топология side faces и смысл «нет торцов в проёме»
|
||||
|
||||
**Где:** §5, AC 3/5/6.
|
||||
**Критичность:** blocker.
|
||||
|
||||
`wallBodiesGeometry().geom` — MultiPolygon с внешними и внутренними rings, уже
|
||||
после union, junction patches и opening cuts. «Граничные рёбра» недостаточно:
|
||||
нужно определить winding, outward normal, holes, culling и порядок отрисовки.
|
||||
Фраза «без торцов внутри проёма» двусмысленна. При полном разрыве стены
|
||||
вертикальные jamb faces по краям проёма являются корректной частью объёма;
|
||||
запретить их — значит получить визуально обрезанную плёнку вместо стены.
|
||||
|
||||
**Добавить:**
|
||||
|
||||
- faces строятся непосредственно из rings канонического MultiPolygon после
|
||||
union/cuts; исходные room edges для extrusion не используются;
|
||||
- winding нормализуется один раз, outward normal учитывает outer/hole ring;
|
||||
- face видима по знаку dot product normal и фиксированного view direction;
|
||||
- для фиксированной камеры задаётся детерминированный stable depth order;
|
||||
- opening slot создаёт две exposed jamb faces по концам разрыва — они нужны;
|
||||
- запрещены face/полоса, пересекающая сам gap, и cap на floor тоннеля;
|
||||
- на stage 1 дверь, окно и ворота являются full-height gaps осознанно, так как
|
||||
модель не хранит высоту подоконника;
|
||||
- opening никогда не вырезает coincident partition/column — сохраняется
|
||||
текущий порядок union extras после room opening cuts;
|
||||
- top face использует whole geometry с `fill-rule:evenodd`;
|
||||
- unit fixtures включают outer ring, hole, multipolygon, T/X join, opening у
|
||||
угла и coincident independent body.
|
||||
|
||||
### B8 — Fallback может зациклить exception и оставить кнопку во лжи
|
||||
|
||||
**Где:** §9, AC 10.
|
||||
**Критичность:** blocker.
|
||||
|
||||
«Вернуться в flat на этом кадре» не отвечает на вопросы: будет ли следующий
|
||||
Lit render снова падать, что показывает `aria-pressed`, сохраняется ли `iso` в
|
||||
localStorage и когда разрешён retry.
|
||||
|
||||
**Добавить state machine:**
|
||||
|
||||
- `desiredView` — сохранённое предпочтение;
|
||||
- `effectiveView` — реально нарисованный `flat|iso`;
|
||||
- исключение в pure geometry/iso template ловится на границе
|
||||
`renderIsoScene()`, для `(space, geometryFingerprint)` ставится session latch;
|
||||
- при latch effective view = flat, iso geometry больше не вызывается на каждом
|
||||
HA state update;
|
||||
- конфиг/layout и сохранённое предпочтение не меняются автоматически;
|
||||
- кнопка отражает `effectiveView` (`aria-pressed=false`), явное повторное
|
||||
нажатие или новый geometry fingerprint очищает latch и делает один retry;
|
||||
- console error содержит issue, space, fingerprint и короткий reason, но без
|
||||
config/entity payload; один раз на latch;
|
||||
- ошибка HTML overlay projection также входит в эту границу, иначе получится
|
||||
«стены flat, markers iso».
|
||||
|
||||
---
|
||||
|
||||
## Существенные замечания
|
||||
|
||||
### M1 — Spike ADR должен фиксировать больше, чем выбор renderer
|
||||
|
||||
Сейчас D6 требует ADR, но его обязательные решения не перечислены. ADR должен
|
||||
закрыть до основной реализации:
|
||||
|
||||
- формулу и pivot проекции;
|
||||
- camera constants, wall-height units и zScale;
|
||||
- top/side fill, stroke, hatch и side shading в light/dark theme;
|
||||
- ring normalization, face visibility и depth order;
|
||||
- z-order floor → Glow/sun/decor → faces/top → screen-facing HTML overlays;
|
||||
- осознанное правило stage 1: markers/room cards всегда выше wall faces и не
|
||||
получают геометрическую occlusion;
|
||||
- projected frame и flat↔iso viewport conversion;
|
||||
- результат проверки SVG filter/clip/mix-blend на Chromium, Firefox, WebKit;
|
||||
- причины отказа от проигравшего прототипа.
|
||||
|
||||
До ADR issue остаётся в статусе spike/implementation-prep, не renderer-ready.
|
||||
|
||||
### M2 — Fingerprint перечисляет не все входы iso geometry
|
||||
|
||||
В §8 добавить как минимум:
|
||||
|
||||
- `room_drafts` и их segment thickness;
|
||||
- нормализованные `openCuts`/virtual intervals;
|
||||
- canonical opening cuts;
|
||||
- partitions и columns с shape/angle/diameter;
|
||||
- `cell_cm`, grid pitch, coordinate scale/NORM;
|
||||
- camera constants и wall-height constant;
|
||||
- версию алгоритма projection/faces.
|
||||
|
||||
Массивы должны сериализоваться детерминированно, числа — нормализоваться как в
|
||||
существующих geometry fingerprints. Display state (`hover`, HA states,
|
||||
`show_borders`) не должен инвалидировать geometry cache. `show_borders:false`
|
||||
просто не рисует cached top/sides, но physics остаётся прежней.
|
||||
|
||||
### M3 — `since`/`expires` требуют точной version semantics
|
||||
|
||||
В проекте нет зависимости `semver`; строкового сравнения допускать нельзя.
|
||||
Зафиксировать parser `major.minor.patch[-prerelease]`, fail-closed для
|
||||
некорректной registry entry и инвариант `since < expires`.
|
||||
|
||||
Рекомендуемое продуктовое правило: сравнивать numeric core, поэтому
|
||||
`1.65.0-beta.1` уже достигает `expires: 1.65.0` и не тащит мёртвый флаг в новый
|
||||
release cycle. Добавить тесты `1.64.9`, `1.65.0-beta.1`, `1.65.0`, malformed.
|
||||
|
||||
### M4 — Не определён runtime owner механизма Labs
|
||||
|
||||
Нужно указать, что availability flags глобальны для загруженного JS-модуля, а
|
||||
effective `flat|iso` остаётся состоянием конкретной карточки/пространства.
|
||||
Один module-level resolver/subscription не должен создавать по listener на
|
||||
каждый render.
|
||||
|
||||
`window.__hpLabs` должен иметь нормативную форму, например frozen sorted array:
|
||||
|
||||
```ts
|
||||
Object.freeze(['iso'])
|
||||
```
|
||||
|
||||
При изменении URL property заменяется новым frozen array, все подключённые
|
||||
карточки получают update. Нельзя отдавать внутренний mutable `Set`.
|
||||
|
||||
### M5 — Scope `houseplan-space-card` не указан
|
||||
|
||||
В репозитории есть второй renderer: `src/space-card.ts` + `src/space-render.ts`.
|
||||
Текущий текст можно прочитать как требование объёмного вида для обеих карточек.
|
||||
|
||||
Рекомендация для stage 1: явно записать, что `houseplan-space-card` остаётся
|
||||
flat и Labs `iso` на него не влияет. Его поддержка — отдельный будущий scope.
|
||||
Иначе придётся сразу заводить вторую композицию сцены, что противоречит цели
|
||||
скрытого первого этапа.
|
||||
|
||||
### M6 — Performance contract не совпадает с существующей инфраструктурой
|
||||
|
||||
`compare.mjs` использует profile-specific budgets, noise allowance,
|
||||
relative+absolute thresholds и exact same runner. Просто потребовать «≤20% по
|
||||
трём полям» недостаточно; `longTask.maxSingleMs` особенно нестабилен около
|
||||
нуля, а `modelReadyMs` почти не измеряет переключение renderer.
|
||||
|
||||
Добавить отдельный профиль `large-house-isometric-v1`:
|
||||
|
||||
- текущий benchmark harness запускает candidate bundle с `hp-labs=iso` и
|
||||
переключает view; тот же harness запускает base bundle, который игнорирует
|
||||
неизвестный flag и остаётся flat;
|
||||
- profile id в обоих reports одинаков, runtime/browser/fingerprint проверяются
|
||||
существующим fail-closed контрактом;
|
||||
- отдельный reviewed budget JSON задаёт 20% relative allowance **плюс**
|
||||
абсолютный noise allowance;
|
||||
- обязательные метрики: first stable iso frame, view toggle, pan/zoom,
|
||||
HA-state update, space switch, long-task count/total/max, heap growth,
|
||||
iso-cache entries/growth, rendered devices;
|
||||
- candidate-only prerelease smoke получает абсолютные ceilings;
|
||||
- перед завершением этапа выполняется exact-SHA full performance workflow, а
|
||||
не локальное сравнение с другой машиной.
|
||||
|
||||
Фразу «flat не должен подорожать вообще» заменить на проверяемое: при
|
||||
выключенном флаге iso geometry/cache/DOM отсутствуют и нет дополнительного
|
||||
прохода по room/device collections; timing находится внутри noise allowance.
|
||||
|
||||
### M7 — Golden coverage слишком мала для новой системы координат
|
||||
|
||||
Две картинки не покрывают заявленный scope. Минимальная матрица stage 1:
|
||||
|
||||
1. desktop dark: mixed walls + openings + Glow/sun + devices;
|
||||
2. desktop light: theme/shading/filter parity;
|
||||
3. mobile portrait или узкий kiosk: fit, marker/label alignment, no clipping;
|
||||
4. `show_borders:false`: стены не нарисованы, room fill/Glow сохраняются;
|
||||
5. remount/toggle sequence проверяется smoke, а финальный кадр — golden при
|
||||
необходимости.
|
||||
|
||||
Существующие flat baselines действительно не принимаются заново, если diff не
|
||||
нулевой. Новые baselines принимаются только из полного Linux CI artifact по
|
||||
действующему HP-QA-01 контракту.
|
||||
|
||||
### M8 — A11y toggle contract неполон
|
||||
|
||||
Для кнопки добавить:
|
||||
|
||||
- `aria-pressed="true|false"` по `effectiveView`;
|
||||
- стабильный accessible name «Объёмный вид» / `Volumetric view`;
|
||||
- focus остаётся на той же кнопке после переключения;
|
||||
- active visual state не кодируется только цветом;
|
||||
- DOM/tab order устройств и room actions совпадает с flat;
|
||||
- stage 1 не добавляет projection animation: swap атомарный. Если анимация
|
||||
будет добавлена через #82, `prefers-reduced-motion` делает её мгновенной.
|
||||
|
||||
---
|
||||
|
||||
## Замечания к тестам и формулировкам
|
||||
|
||||
### T1 — «innerHTML до и после ветки» нужно сделать воспроизводимым
|
||||
|
||||
Обычный тест не может сравнить текущий commit с кодом до ветки. Разделить
|
||||
контракт:
|
||||
|
||||
- в одном candidate build сравнить no-param и unknown-param: нет iso nodes,
|
||||
нет дополнительных WS/HTTP и config/layout writes;
|
||||
- golden гарантирует нулевой diff существующих flat scenes между revisions;
|
||||
- unit spy подтверждает, что iso geometry builder не вызывался;
|
||||
- чтение собственного Labs localStorage не считать сетевым/сторным изменением,
|
||||
но при отсутствии URL оно не должно переписывать ключ.
|
||||
|
||||
### T2 — Мутанты должны быть исполнимыми
|
||||
|
||||
Пункт «отдельная формула проекции HTML» нельзя надёжно поймать текстовым
|
||||
поиском. Нормативный mutant: внести controlled offset только в overlay mapping;
|
||||
smoke должен увидеть расхождение anchor больше 1 CSS px. Для cache mutant тест
|
||||
меняет geometry in-place без `_cfgEpoch`; iso faces обязаны обновиться. Для
|
||||
layer-copy mutant тест проверяет upper bound DOM face count как `O(E)`.
|
||||
|
||||
Для каждого из пяти mutants сохранить команду/patch id и имя краснеющего теста
|
||||
в PR/issue evidence; ручной тезис «проверено» недостаточен.
|
||||
|
||||
### T3 — Opening wording
|
||||
|
||||
В AC 6 заменить «без швов и торцов внутри проёма» на:
|
||||
|
||||
> Проём является full-height gap. Внутри gap нет wall top/side полосы;
|
||||
> вертикальные jamb faces на двух границах masonry разрыва являются ожидаемыми.
|
||||
|
||||
Это снимает конфликт с B7 и делает golden однозначным.
|
||||
|
||||
### T4 — Первый запуск и сохранённое предпочтение
|
||||
|
||||
В §9/§10 уточнить:
|
||||
|
||||
- без записи `houseplan_card_view_v1[space]` effective view всегда flat, даже
|
||||
при активном Labs;
|
||||
- toggle в обычном View пишет `flat|iso` per space;
|
||||
- при неактивном/expired flag сохранённое `iso` игнорируется, не меняет DOM и
|
||||
не попадает в warm memo;
|
||||
- вход/выход editor не перезаписывает предпочтение;
|
||||
- fallback B8 не перезаписывает предпочтение автоматически.
|
||||
|
||||
### T5 — Backlog — канонический источник
|
||||
|
||||
Issue #89 сейчас говорит «черновик продуктового и технического решения», тогда
|
||||
как файл говорит «готово к реализации». По `AGENTS.md` Issue/Project являются
|
||||
каноническими. После принятия новой редакции:
|
||||
|
||||
- добавить в body issue ссылку на stage 1 spec как нормативную;
|
||||
- синхронизировать scope/acceptance criteria issue с утверждённой редакцией;
|
||||
- оставить Project `Todo` до фактического начала, затем перевести в
|
||||
`In progress`;
|
||||
- не закрывать #89 после одного spike ADR: закрытие только после всех AC этапа.
|
||||
|
||||
---
|
||||
|
||||
## Рекомендуемая новая структура нормативных разделов
|
||||
|
||||
Чтобы не раздувать основной текст, достаточно добавить четыре подраздела:
|
||||
|
||||
1. **§4.4 Coordinate spaces and viewport** — B1, B2.
|
||||
2. **§5.1 Wall-face topology and visual tokens** — B7, M1.
|
||||
3. **§7.1 Native hit testing and warm continuity** — B3, B4, B5.
|
||||
4. **§2.2.1 Labs operation precedence and version lifecycle** — B6, M3, M4.
|
||||
|
||||
Остальные замечания можно встроить в §8–§13.
|
||||
|
||||
## Definition of Ready после следующей итерации
|
||||
|
||||
ТЗ можно считать готовым к реализации, когда:
|
||||
|
||||
- [ ] определены plan/scene/client spaces, camera pivot/zScale и projected frame;
|
||||
- [ ] записан алгоритм flat↔iso viewport conversion;
|
||||
- [ ] обновлён warm-remount contract;
|
||||
- [ ] исправлено pointer rule и убран iso marker-drag smoke;
|
||||
- [ ] выбран однозначный kiosk escape contract;
|
||||
- [ ] полностью определена Labs grammar и expiry semantics;
|
||||
- [ ] определены ring/face/jamb/depth rules;
|
||||
- [ ] определена fallback state machine;
|
||||
- [ ] ADR имеет обязательный список решений;
|
||||
- [ ] fingerprint содержит все входы;
|
||||
- [ ] указан scope `houseplan-space-card`;
|
||||
- [ ] создан исполнимый performance profile/budget plan;
|
||||
- [ ] расширена golden/a11y/mutant matrix;
|
||||
- [ ] issue #89 ссылается на утверждённое ТЗ и не противоречит ему.
|
||||
|
||||
После этого оценка stage 1 остаётся **L/XL с высоким риском**, но работа станет
|
||||
декомпозируемой и проверяемой; менять выбранную продуктовую концепцию не нужно.
|
||||
@@ -1,6 +1,7 @@
|
||||
"""House Plan: server-side house plan configuration + Lovelace card serving."""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
@@ -23,7 +24,7 @@ from .const import (
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
from .store import HouseplanConfigEntry, async_save_layout_state, create_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,16 +33,27 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hp_ws.async_register(hass)
|
||||
from .http_api import HouseplanContentView, HouseplanUploadView
|
||||
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
|
||||
data = create_data(hass)
|
||||
# Home Assistant's installation id never leaves the instance. Exports
|
||||
# carry only a salted SHA-256 fingerprint so same-instance internal files
|
||||
# can be distinguished from cross-instance references.
|
||||
try:
|
||||
from homeassistant.helpers import instance_id as ha_instance_id
|
||||
|
||||
value = ha_instance_id.async_get(hass)
|
||||
data.instance_id = str(await value if inspect.isawaitable(value) else value)
|
||||
except Exception: # noqa: BLE001 - old HA/test harness fallback
|
||||
data.instance_id = str(entry.entry_id)
|
||||
# test-before-setup: storage must be readable, otherwise retry later
|
||||
try:
|
||||
await data.store.async_load()
|
||||
@@ -133,15 +145,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
if merged:
|
||||
lay_rev = int(lay_stored.get("rev", 0))
|
||||
if merged != pending: # 1. the durable intent, before anything moves
|
||||
await data.store.async_save(
|
||||
{"layout": layout, "rev": lay_rev, "geom_pending": merged}
|
||||
await async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev,
|
||||
metadata={"geom_pending": merged}, remove=("geom_pending",),
|
||||
)
|
||||
rev = int(stored.get("rev", 0))
|
||||
if cfg and migrate_config(cfg): # 2. the config half
|
||||
rev += 1
|
||||
await data.config_store.async_save({"config": cfg, "rev": rev})
|
||||
migrate_layout(layout, merged) # 3. the layout half + intent cleared
|
||||
await data.store.async_save({"layout": layout, "rev": lay_rev + 1})
|
||||
await async_save_layout_state(
|
||||
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan: migrated %s space(s) to the square canvas", len(merged)
|
||||
)
|
||||
@@ -149,6 +164,73 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
# Finish an explicit whole-plan optimization/undo interrupted between the
|
||||
# config and layout store writes. The target was persisted before either
|
||||
# visible half changed, so setup can always converge on the requested pair.
|
||||
optimize_revs: tuple[int, int] | None = None
|
||||
recovered_import = False
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
pending = lay_stored.get("optimize_pending")
|
||||
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
|
||||
and isinstance(pending.get("layout"), dict):
|
||||
target_config = pending["config"]
|
||||
target_layout = pending["layout"]
|
||||
config_rev = int(stored.get("rev", 0))
|
||||
layout_rev = int(lay_stored.get("rev", 0))
|
||||
target_config_rev = int(pending.get(
|
||||
"config_rev", config_rev + (stored.get("config") != target_config)
|
||||
))
|
||||
target_layout_rev = int(pending.get(
|
||||
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
|
||||
))
|
||||
if stored.get("config") != target_config or config_rev < target_config_rev:
|
||||
config_rev = max(config_rev, target_config_rev)
|
||||
await data.config_store.async_save({
|
||||
"config": target_config,
|
||||
"rev": config_rev,
|
||||
})
|
||||
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
|
||||
layout_rev = max(layout_rev, target_layout_rev)
|
||||
exact_metadata = pending.get("final_metadata")
|
||||
replace_metadata = isinstance(exact_metadata, dict)
|
||||
metadata = dict(exact_metadata) if replace_metadata else None
|
||||
if not replace_metadata and not pending.get("clear_backup") \
|
||||
and "optimize_backup" in lay_stored:
|
||||
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
|
||||
remove_metadata = ["optimize_pending", "optimize_backup"]
|
||||
if pending.get("clear_backup"):
|
||||
# A recovered whole-plan undo replaces the complete layout;
|
||||
# a point-wise repair snapshot from the replaced layout must
|
||||
# not survive and later restore coordinates into the new pair.
|
||||
remove_metadata.append("repair_backup")
|
||||
await async_save_layout_state(
|
||||
data,
|
||||
lay_stored,
|
||||
target_layout,
|
||||
layout_rev,
|
||||
metadata=metadata,
|
||||
remove=tuple(remove_metadata),
|
||||
replace_metadata=replace_metadata,
|
||||
)
|
||||
optimize_revs = (config_rev, layout_rev)
|
||||
recovered_import = str(pending.get("kind") or "").startswith("import")
|
||||
_LOGGER.warning(
|
||||
"House Plan: completed an interrupted %s",
|
||||
str(pending.get("kind") or "plan optimization").replace("_", " "),
|
||||
)
|
||||
if optimize_revs is not None:
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
|
||||
if recovered_import:
|
||||
await recorder.async_refresh()
|
||||
current = (await data.config_store.async_load() or {}).get("config") or {}
|
||||
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
|
||||
for marker_id in list(recorder.book.data):
|
||||
if marker_id not in live_ids:
|
||||
await recorder.async_delete(marker_id)
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Scheduled collection of everything nobody ended up referencing.
|
||||
|
||||
@@ -24,5 +24,8 @@ def may_write(hass: HomeAssistant, user) -> bool:
|
||||
entry = get_entry(hass)
|
||||
if entry is None:
|
||||
return is_admin
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||
# Default TRUE when the key is absent (audit P0-4, 2026-08-05): the card
|
||||
# UI has always been admin-gated, and an unset option must not open every
|
||||
# write WS/HTTP path to every authenticated household user.
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
|
||||
return is_admin if admin_only else True
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -45,7 +45,21 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.58.0"
|
||||
VERSION = "1.62.0-beta.9"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 6
|
||||
EXPORT_VERSION = 1
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_IMPORT_PREVIEWS_PER_USER = 3
|
||||
# Parsed documents are larger than their wire representation. Keep the
|
||||
# original three-preview memory ceiling global as well as per user so turning
|
||||
# off the admin-only policy cannot multiply it by the number of household
|
||||
# accounts.
|
||||
MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
@@ -31,6 +31,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"has_plan": bool(s.get("plan_url")),
|
||||
"rooms": len(s.get("rooms", [])),
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"room_drafts": len(s.get("room_drafts", [])),
|
||||
"partitions": len(s.get("partitions", [])),
|
||||
"wall_columns": len(s.get("wall_columns", [])),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
|
||||
@@ -64,6 +64,16 @@ def migrate_space(space: dict[str, Any]) -> bool:
|
||||
if room.get("poly"):
|
||||
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
|
||||
|
||||
for draft in space.get("room_drafts") or []:
|
||||
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
|
||||
|
||||
for part in space.get("partitions") or []:
|
||||
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
|
||||
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
|
||||
|
||||
for column in space.get("wall_columns") or []:
|
||||
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
|
||||
|
||||
for op in space.get("openings") or []:
|
||||
op["x"] = dx + float(op.get("x", 0)) * kx
|
||||
op["y"] = dy + float(op.get("y", 0)) * ky
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -22,10 +23,13 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT, PLANS_DIR,
|
||||
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import ImportFailure, create_preview
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .store import get_data
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -52,6 +56,69 @@ _MIME = {
|
||||
}
|
||||
|
||||
|
||||
class HouseplanImportPreviewView(HomeAssistantView):
|
||||
"""Upload a bounded JSON backup and return a server-side preview token."""
|
||||
|
||||
url = "/api/houseplan/import/preview"
|
||||
name = "api:houseplan:import-preview"
|
||||
requires_auth = True
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
user = request.get("hass_user")
|
||||
if not may_write(hass, user):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
runtime = get_data(hass)
|
||||
if runtime is None:
|
||||
return web.json_response({"error": "not_ready"}, status=503)
|
||||
policy = request.query.get("duplicate_policy", "skip")
|
||||
if policy not in ("skip", "virtual"):
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
declared = request.content_length
|
||||
if declared is not None and declared > MAX_EXPORT_BYTES:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
blocks: list[bytes] = []
|
||||
size = 0
|
||||
async for block in request.content.iter_chunked(_CHUNK):
|
||||
size += len(block)
|
||||
if size > MAX_EXPORT_BYTES:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
blocks.append(block)
|
||||
owner_id = str(getattr(user, "id", ""))
|
||||
try:
|
||||
# Hold the global writer only while taking one coherent store
|
||||
# snapshot. Parsing up to 8 MiB, schema validation and space remap
|
||||
# are CPU work and apply will revalidate both revisions anyway.
|
||||
async with runtime.write_lock:
|
||||
config_data = await runtime.config_store.async_load() or {}
|
||||
layout_data = await runtime.store.async_load() or {}
|
||||
try:
|
||||
registry_snapshot = import_registry_snapshot(hass)
|
||||
except Exception: # noqa: BLE001 - summary must not block a valid backup
|
||||
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
|
||||
registry_snapshot = None
|
||||
result = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_preview,
|
||||
runtime,
|
||||
b"".join(blocks),
|
||||
owner_id=owner_id,
|
||||
duplicate_policy=policy,
|
||||
current_config_data=config_data,
|
||||
current_layout_data=layout_data,
|
||||
config_root=Path(hass.config.path("")),
|
||||
registry_snapshot=registry_snapshot,
|
||||
)
|
||||
)
|
||||
except ImportFailure as err:
|
||||
status = 413 if err.code == "too_large" else 400
|
||||
return web.json_response({"error": err.code, "message": err.message}, status=status)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan import preview failed")
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
class HouseplanContentView(HomeAssistantView):
|
||||
"""Authenticated read access to plans and marker files (audit B1).
|
||||
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.58.0"
|
||||
"version": "1.62.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ rules:
|
||||
status: done
|
||||
config-flow-test-coverage:
|
||||
status: done
|
||||
comment: tests_backend/test_config_flow.py (runs in CI on Python 3.13).
|
||||
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
|
||||
dependency-transparency:
|
||||
status: done
|
||||
comment: No external requirements.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Small registry projection shared by import HTTP and WebSocket previews."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
def import_registry_snapshot(hass: HomeAssistant) -> dict[str, set[str]]:
|
||||
"""Return non-sensitive target inventory used only for preview counts."""
|
||||
from homeassistant.helpers import area_registry as ar
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
entities = list(er.async_get(hass).entities.values())
|
||||
active_entity: set[str] = set()
|
||||
disabled_entity: set[str] = set()
|
||||
entities_by_device: dict[str, list[Any]] = {}
|
||||
for entry in entities:
|
||||
entity_id = str(entry.entity_id)
|
||||
if getattr(entry, "disabled_by", None) is None:
|
||||
active_entity.add(entity_id)
|
||||
else:
|
||||
disabled_entity.add(entity_id)
|
||||
if entry.device_id:
|
||||
entities_by_device.setdefault(str(entry.device_id), []).append(entry)
|
||||
|
||||
# Synthetic/runtime entities may legitimately have no registry row.
|
||||
active_entity.update(str(state.entity_id) for state in hass.states.async_all())
|
||||
|
||||
active_device: set[str] = set()
|
||||
disabled_device: set[str] = set()
|
||||
for entry in dr.async_get(hass).devices.values():
|
||||
device_id = str(entry.id)
|
||||
children = entities_by_device.get(device_id, [])
|
||||
disabled = getattr(entry, "disabled_by", None) is not None or (
|
||||
bool(children)
|
||||
and all(getattr(child, "disabled_by", None) is not None for child in children)
|
||||
)
|
||||
(disabled_device if disabled else active_device).add(device_id)
|
||||
return {
|
||||
"active_device": active_device,
|
||||
"disabled_device": disabled_device,
|
||||
"active_entity": active_entity,
|
||||
"disabled_entity": disabled_entity - active_entity,
|
||||
"areas": {str(entry.id) for entry in ar.async_get(hass).areas.values()},
|
||||
}
|
||||
@@ -54,6 +54,11 @@ class HouseplanData:
|
||||
# directly — a test that fakes a 24 h jump proves the timer fires, not that
|
||||
# the work happens, and those are different claims.
|
||||
sweep: Callable[[], Awaitable[None]] | None = None
|
||||
# Stable HA instance id used only through a one-way export fingerprint.
|
||||
instance_id: str = ""
|
||||
# Parsed import candidates are short-lived, user-bound and memory-only.
|
||||
# dict keeps insertion order, which lets the preview service evict oldest.
|
||||
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
@@ -79,3 +84,58 @@ def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
|
||||
"""The loaded config entry, or None."""
|
||||
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return entries[0] if entries else None
|
||||
|
||||
|
||||
OPTIMIZE_BACKUP = "optimize_backup"
|
||||
OPTIMIZE_PENDING = "optimize_pending"
|
||||
LAYOUT_STORE_CORE_KEYS = frozenset({"layout", "rev"})
|
||||
|
||||
|
||||
def layout_store_payload(
|
||||
stored: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
rev: int,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
remove: tuple[str, ...] = (),
|
||||
replace_metadata: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build one layout-store write without silently dropping metadata.
|
||||
|
||||
Layout used to be saved by several independent dict comprehensions. Every
|
||||
new metadata key therefore had to be added to every caller or was lost on
|
||||
the next drag. All writers now express only the metadata they intentionally
|
||||
add/remove and this helper preserves the rest.
|
||||
"""
|
||||
excluded = {*LAYOUT_STORE_CORE_KEYS, *remove}
|
||||
out = {} if replace_metadata else {
|
||||
key: value for key, value in stored.items() if key not in excluded
|
||||
}
|
||||
if metadata:
|
||||
out.update(metadata)
|
||||
out["layout"] = layout
|
||||
out["rev"] = rev
|
||||
return out
|
||||
|
||||
|
||||
async def async_save_layout_state(
|
||||
runtime: HouseplanData,
|
||||
stored: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
rev: int,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
remove: tuple[str, ...] = (),
|
||||
replace_metadata: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist layout and return the exact store document written."""
|
||||
payload = layout_store_payload(
|
||||
stored,
|
||||
layout,
|
||||
rev,
|
||||
metadata=metadata,
|
||||
remove=remove,
|
||||
replace_metadata=replace_metadata,
|
||||
)
|
||||
await runtime.store.async_save(payload)
|
||||
return payload
|
||||
|
||||
@@ -27,6 +27,9 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
|
||||
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
|
||||
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ class TrailBook:
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete(self, marker: str) -> bool:
|
||||
"""Forget every stored run of one plan marker."""
|
||||
return self.data.pop(marker, None) is not None
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
@@ -106,6 +110,9 @@ class TrailRecorder:
|
||||
self._unsub_track = None
|
||||
self._unsub_save = None
|
||||
self._last_fire = 0.0
|
||||
# One active incident per saved marker/source. `reason` is mutable so
|
||||
# missing↔disabled changes do not create warning storms.
|
||||
self._source_health: dict[tuple[str, str], str] = {}
|
||||
# HP-1540-05: config/set fires refresh as a detached task; two of them
|
||||
# interleaving across the awaited load both subscribed and the loser's
|
||||
# unsub handle was overwritten — a leak until HA restart
|
||||
@@ -130,27 +137,24 @@ class TrailRecorder:
|
||||
return
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
health_pairs: set[tuple[str, str]] = set()
|
||||
for m in cfg.get("markers") or []:
|
||||
if m.get("removed") is True:
|
||||
continue
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
continue
|
||||
marker_id = str(m.get("id"))
|
||||
health_pairs.add((marker_id, str(src)))
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((str(m.get("id")), vac))
|
||||
pairs.setdefault(src, []).append((marker_id, vac))
|
||||
self._refresh_source_health(health_pairs)
|
||||
self.pairs = pairs
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
# deduplicated: two markers of one robot share source AND vacuum
|
||||
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
|
||||
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
|
||||
if ents:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
self._resubscribe()
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
# next state change — otherwise the first seconds of the path are
|
||||
@@ -158,6 +162,93 @@ class TrailRecorder:
|
||||
for src in self.pairs:
|
||||
self._sample(src, time.time())
|
||||
|
||||
def _source_failure_reason(self, source: str) -> str | None:
|
||||
"""Classify only refresh-time health evidence.
|
||||
|
||||
A registry row or exact live state proves existence. No registry access
|
||||
is neutral: it can neither create a loss incident nor recover one.
|
||||
"""
|
||||
registry = er.async_get(self.hass)
|
||||
state = self.hass.states.get(source)
|
||||
if registry is None or not hasattr(registry, "async_get"):
|
||||
return None if state is not None else "unverified"
|
||||
entry = registry.async_get(source)
|
||||
if entry is not None and getattr(entry, "disabled_by", None) is not None:
|
||||
return "disabled"
|
||||
# Registry-less YAML entities are valid: exact live state is stronger
|
||||
# evidence than a missing registry row.
|
||||
if entry is not None or state is not None:
|
||||
return None
|
||||
return "missing"
|
||||
|
||||
def _refresh_source_health(self, expected: set[tuple[str, str]]) -> None:
|
||||
"""Refresh deduplicated source incidents during config refresh/restart.
|
||||
|
||||
`unavailable` and unsupported-but-existing states count as proven
|
||||
recovery. There is intentionally no registry subscription in Stage 1;
|
||||
the next config refresh or restart observes a later transition.
|
||||
"""
|
||||
for key in list(self._source_health):
|
||||
if key not in expected:
|
||||
del self._source_health[key]
|
||||
for marker_id, source in sorted(expected):
|
||||
key = (marker_id, source)
|
||||
reason = self._source_failure_reason(source)
|
||||
previous = self._source_health.get(key)
|
||||
# Limited/unavailable registry evidence is neutral: keep an
|
||||
# existing incident as-is, and never create or recover one.
|
||||
if reason == "unverified":
|
||||
continue
|
||||
if reason is None:
|
||||
if previous is not None:
|
||||
_LOGGER.info(
|
||||
"Vacuum source recovered: marker=%s source=%s (was %s)",
|
||||
marker_id, source, previous,
|
||||
)
|
||||
del self._source_health[key]
|
||||
continue
|
||||
if previous is None:
|
||||
_LOGGER.warning(
|
||||
"Vacuum source %s: marker=%s source=%s",
|
||||
reason, marker_id, source,
|
||||
)
|
||||
self._source_health[key] = reason
|
||||
|
||||
async def async_delete(self, marker: str) -> bool:
|
||||
"""Stop and erase one marker without racing subscription refresh/save."""
|
||||
async with self._refresh_lock:
|
||||
# The trail book owns deletion. When it has no such marker, this
|
||||
# is a no-op and must not silently damage the live tracking graph.
|
||||
removed = self.book.delete(marker)
|
||||
if not removed:
|
||||
return False
|
||||
for src in list(self.pairs):
|
||||
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
|
||||
if kept:
|
||||
self.pairs[src] = kept
|
||||
else:
|
||||
del self.pairs[src]
|
||||
self._resubscribe()
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
return True
|
||||
|
||||
def _resubscribe(self) -> None:
|
||||
"""Replace the state subscription for the current pair graph."""
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
# deduplicated: two markers of one robot share source AND vacuum
|
||||
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
|
||||
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
|
||||
if ents and not self._closed:
|
||||
self._unsub_track = async_track_state_change_event(
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
|
||||
# not re-subscribe after this cleanup has already run
|
||||
|
||||
@@ -4,6 +4,7 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -19,6 +20,228 @@ _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
MARKER_CONTROL_PREFIX = "marker:"
|
||||
_CONTROL_ENTITY_ID_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+\Z")
|
||||
|
||||
|
||||
class MarkerControlError(ValueError):
|
||||
"""Semantic marker-link error with a stable public code."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
VALUE_BADGE_ATTRIBUTES = {
|
||||
"current_temperature", "temperature", "current_humidity", "humidity",
|
||||
"current_position", "percentage", "brightness", "volume_level",
|
||||
"battery_level", "fan_speed",
|
||||
}
|
||||
VALUE_BADGE_POSITIONS = {"right", "bottom", "left", "top"}
|
||||
VALUE_BADGE_SOURCE_KINDS = {
|
||||
"entity_state", "entity_attribute", "derived_lqi", "derived_marker_state",
|
||||
}
|
||||
_LIGHT_ENTITY_RE = re.compile(r"^(?:light|switch)\.[a-z0-9_]+\Z")
|
||||
|
||||
|
||||
def _matching_previous_marker(
|
||||
marker: dict, marker_id: str, old_by_id: dict[str, dict], old_markers: list[dict],
|
||||
new_ids: set[str], consumed_old_ids: set[str], validate_all: bool,
|
||||
) -> dict | None:
|
||||
"""Match the previous marker across the binding-stable id rename path."""
|
||||
old_marker = old_by_id.get(marker_id)
|
||||
if old_marker is not None:
|
||||
consumed_old_ids.add(marker_id)
|
||||
return old_marker
|
||||
if validate_all:
|
||||
return None
|
||||
binding = marker.get("binding")
|
||||
matches = [
|
||||
old for old in old_markers
|
||||
if binding not in (None, "virtual")
|
||||
and old.get("binding") == binding
|
||||
and str(old.get("id")) not in new_ids
|
||||
and str(old.get("id")) not in consumed_old_ids
|
||||
]
|
||||
if len(matches) == 1:
|
||||
consumed_old_ids.add(str(matches[0].get("id")))
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def validate_marker_value_badges(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
|
||||
markers = config.get("markers") or []
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
|
||||
new_ids = set(by_id)
|
||||
consumed_old_ids: set[str] = set()
|
||||
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
|
||||
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
badge = marker.get("value_badge")
|
||||
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
|
||||
if not validate_all and badge == old_badge:
|
||||
continue
|
||||
if badge is None:
|
||||
continue
|
||||
if not isinstance(badge, dict):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
|
||||
if not isinstance(badge.get("enabled"), bool):
|
||||
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
|
||||
if badge.get("position") not in VALUE_BADGE_POSITIONS:
|
||||
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
|
||||
source = badge.get("source")
|
||||
if badge["enabled"] and not isinstance(source, dict):
|
||||
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
|
||||
if source is None:
|
||||
continue
|
||||
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
|
||||
kind = source["kind"]
|
||||
allowed_fields = {
|
||||
"entity_state": {"kind", "entity_id"},
|
||||
"entity_attribute": {"kind", "entity_id", "attribute"},
|
||||
"derived_lqi": {"kind"},
|
||||
"derived_marker_state": {"kind", "ref"},
|
||||
}[kind]
|
||||
if (known_source_fields & set(source)) - allowed_fields:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
|
||||
if kind in {"entity_state", "entity_attribute"}:
|
||||
entity_id = source.get("entity_id")
|
||||
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
|
||||
if kind == "entity_attribute":
|
||||
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
|
||||
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
|
||||
if kind == "derived_marker_state":
|
||||
ref = source.get("ref")
|
||||
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
|
||||
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
|
||||
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
|
||||
if target is None or target.get("removed") is True:
|
||||
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
|
||||
if target.get("is_light") is not True:
|
||||
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
|
||||
|
||||
|
||||
def validate_marker_light_entities(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate new/changed leading-light choices without rejecting dormant data.
|
||||
|
||||
The top-level schema must stay lossless: an old or future literal that the
|
||||
current frontend cannot edit may round-trip unchanged. Imports validate the
|
||||
whole incoming document because every imported value is new to this plan.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
|
||||
new_ids = {str(marker.get("id")) for marker in markers}
|
||||
consumed_old_ids: set[str] = set()
|
||||
for marker in markers:
|
||||
marker_id = str(marker.get("id"))
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
value = marker.get("light_entity")
|
||||
old_value = None if validate_all else (old_marker or {}).get("light_entity")
|
||||
if not validate_all and value == old_value:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
|
||||
raise MarkerControlError(
|
||||
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
|
||||
)
|
||||
|
||||
|
||||
def validate_marker_controls(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate newly introduced marker:* edges without rewriting old data.
|
||||
|
||||
Existing broken refs remain editable and round-trip losslessly. Imports use
|
||||
validate_all because their complete candidate graph is new to this plan.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {
|
||||
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
|
||||
}
|
||||
new_ids = set(by_id)
|
||||
consumed_old_ids: set[str] = set()
|
||||
graph: dict[str, list[str]] = {}
|
||||
added: list[tuple[str, str]] = []
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
raw_controls = [
|
||||
ref for ref in marker.get("controls") or [] if isinstance(ref, str)
|
||||
]
|
||||
old_controls = [] if validate_all else [
|
||||
ref for ref in (old_marker or {}).get("controls") or [] if isinstance(ref, str)
|
||||
]
|
||||
refs = [
|
||||
ref for ref in raw_controls
|
||||
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
|
||||
]
|
||||
graph[marker_id] = [ref[len(MARKER_CONTROL_PREFIX):] for ref in refs]
|
||||
old_refs = [
|
||||
ref for ref in old_controls
|
||||
if isinstance(ref, str) and ref.startswith(MARKER_CONTROL_PREFIX)
|
||||
]
|
||||
remaining = list(old_refs)
|
||||
for ref in refs:
|
||||
if ref in remaining:
|
||||
remaining.remove(ref)
|
||||
else:
|
||||
added.append((marker_id, ref[len(MARKER_CONTROL_PREFIX):]))
|
||||
new_counts, old_counts = Counter(refs), Counter(old_refs)
|
||||
if any(count > 1 and count > old_counts[ref] for ref, count in new_counts.items()):
|
||||
raise MarkerControlError("duplicate_marker_control", "Duplicate marker light target")
|
||||
remaining_controls = list(old_controls)
|
||||
for ref in raw_controls:
|
||||
if ref in remaining_controls:
|
||||
remaining_controls.remove(ref)
|
||||
elif not ref.startswith(MARKER_CONTROL_PREFIX) and not _CONTROL_ENTITY_ID_RE.fullmatch(ref):
|
||||
raise MarkerControlError("invalid_marker_control", f"Invalid entity target: {ref}")
|
||||
|
||||
def reaches(start: str, wanted: str) -> bool:
|
||||
stack, seen = [start], set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node == wanted:
|
||||
return True
|
||||
if node in seen:
|
||||
continue
|
||||
seen.add(node)
|
||||
stack.extend(graph.get(node, []))
|
||||
return False
|
||||
|
||||
for controller, target in added:
|
||||
if not target:
|
||||
raise MarkerControlError("invalid_marker_control", "Marker target id is empty")
|
||||
if target == controller:
|
||||
raise MarkerControlError("marker_control_self", "A marker cannot control itself")
|
||||
target_marker = by_id.get(target)
|
||||
if target_marker is None or target_marker.get("removed") is True:
|
||||
raise MarkerControlError("marker_control_missing", f"Marker target does not exist: {target}")
|
||||
if target_marker.get("is_light") is not True:
|
||||
raise MarkerControlError("marker_control_not_light", f"Marker target is not a forced light: {target}")
|
||||
if reaches(target, controller):
|
||||
raise MarkerControlError("marker_control_cycle", "Marker light controls contain a cycle")
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -62,12 +285,34 @@ def _finite(value):
|
||||
return f
|
||||
|
||||
|
||||
# Persisted colours deliberately use one small, browser-independent format.
|
||||
# Keep this exact contract in sync with src/color.ts.
|
||||
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
|
||||
# match the whole string exactly, in parity with the frontend validator.
|
||||
_COLOR = vol.Match(r"\A#[0-9a-fA-F]{6}\Z")
|
||||
_CUSTOM_FILL = vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||
MAX_SPACES = 50
|
||||
MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_WALLS = 500
|
||||
MAX_ROOM_DRAFTS = 200
|
||||
MAX_DRAFT_SEGMENTS = 2000
|
||||
MAX_PARTITIONS = 2000
|
||||
MAX_WALL_COLUMNS = 500
|
||||
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
|
||||
# Every span is a piece of a shared boundary, so there can never be more of
|
||||
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
|
||||
MAX_OPEN_SPANS = 500
|
||||
MAX_LAYOUT = 5000
|
||||
# Inner limits (HP-1454-05). The outer collections were capped, the collections
|
||||
# INSIDE them were not: a 150 000-point polygon or a 100 000-entry known_devices
|
||||
@@ -90,6 +335,8 @@ MAX_URL = 2000
|
||||
# they can act on. For scale, a real three-floor home with ~200 devices stores
|
||||
# about 70 KB, so this is ~30x headroom.
|
||||
MAX_CONFIG_BYTES = 2 * 1024 * 1024
|
||||
CELL_CM_MIN = 0.1
|
||||
CELL_CM_MAX = 1000.0
|
||||
|
||||
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
|
||||
_TEXT_OR_NONE = vol.Any(None, _TEXT)
|
||||
@@ -142,6 +389,45 @@ def _view_box(value):
|
||||
|
||||
POINT = vol.All([_GEOM], vol.Length(min=2, max=2))
|
||||
|
||||
# A virtual stretch shorter than this is a click, not a span. Mirrors
|
||||
# OPEN_SPAN_MIN_UNITS in src/open-spans.ts (normalised units).
|
||||
OPEN_SPAN_MIN_LEN = 1e-3
|
||||
|
||||
|
||||
def _open_span(value):
|
||||
"""One virtual stretch: exactly two distinct finite points a/b.
|
||||
|
||||
AUD-159B6-03: the field used to ride on `extra=ALLOW_EXTRA` — any shape
|
||||
passed the backend and the card crashed reading `e.a[0]` on render, for
|
||||
every reader of that space. A degenerate span (a == b) is not a stretch
|
||||
either: it can never be hit, closed or drawn, so it is corruption, not data.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("open_span must be an object with a/b points")
|
||||
a = POINT(value.get("a"))
|
||||
b = POINT(value.get("b"))
|
||||
if abs(a[0] - b[0]) < OPEN_SPAN_MIN_LEN and abs(a[1] - b[1]) < OPEN_SPAN_MIN_LEN:
|
||||
raise vol.Invalid("open_span: a and b must differ")
|
||||
out = {k: v for k, v in value.items() if k not in ("a", "b")}
|
||||
out["a"] = a
|
||||
out["b"] = b
|
||||
return out
|
||||
|
||||
|
||||
def _dedupe_open_spans(value):
|
||||
"""Drop repeats of the same stretch (either direction) — one wall, one span."""
|
||||
seen = set()
|
||||
out = []
|
||||
for span in value:
|
||||
a, b = span["a"], span["b"]
|
||||
fwd = (round(a[0], 6), round(a[1], 6), round(b[0], 6), round(b[1], 6))
|
||||
key = min(fwd, (fwd[2], fwd[3], fwd[0], fwd[1]))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(span)
|
||||
return out
|
||||
|
||||
|
||||
def _require_geometry(room: dict) -> dict:
|
||||
if "poly" in room or all(k in room for k in ("x", "y", "w", "h")):
|
||||
@@ -160,7 +446,9 @@ ROOM_SCHEMA = vol.All(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp"])),
|
||||
vol.Optional("fill_mode"): vol.Any(None, vol.In(["none", "lqi", "light", "temp", "custom", "glow"])),
|
||||
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
|
||||
vol.Optional("glow"): vol.Any(bool, None),
|
||||
vol.Optional("temp_source"): vol.Any(str, None),
|
||||
vol.Optional("hum_source"): vol.Any(str, None),
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
@@ -199,14 +487,19 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("show_borders"): bool,
|
||||
vol.Optional("show_names"): bool,
|
||||
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("room_color"): _COLOR,
|
||||
# per-space background around the plan; absent = inherit the global one
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "custom", "glow"]),
|
||||
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
|
||||
vol.Optional("glow_enabled"): bool,
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
vol.Optional("show_lqi"): bool,
|
||||
# "draw less" switches; absent = False = everything is drawn as before
|
||||
vol.Optional("hide_decor"): bool,
|
||||
vol.Optional("hide_openings"): bool,
|
||||
vol.Optional("label_temp"): bool,
|
||||
vol.Optional("label_hum"): bool,
|
||||
vol.Optional("label_lqi"): bool,
|
||||
@@ -220,9 +513,43 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
|
||||
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
|
||||
# underscores in both halves. The bound is a sanity limit, not a policy.
|
||||
MAX_ENTITY_ID = 255
|
||||
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
|
||||
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
|
||||
# A caption is a caption: the inline-reference template is bounded. Attribute
|
||||
# and unit bounds below apply only to legacy beta.9 link fields, which remain
|
||||
# accepted so an older saved plan can reach the frontend and migrate on edit.
|
||||
MAX_DECOR_TEXT = 200
|
||||
MAX_DECOR_ATTR = 64
|
||||
MAX_DECOR_UNIT = 16
|
||||
# The text block is scaled by dragging its corners; the range is what a human
|
||||
# could mean on a 1000-unit canvas, the rest is garbage insurance.
|
||||
DECOR_TEXT_SCALE_MIN = 0.15
|
||||
DECOR_TEXT_SCALE_MAX = 20.0
|
||||
DECOR_TEXT_CM_MAX = 2000.0
|
||||
# A furniture symbol id (docs/FURNITURE.md). Deliberately NOT the card's list:
|
||||
# the backend must accept a plan written by a NEWER card, and a card that has
|
||||
# learnt a new symbol must not have to wait for the integration to be updated
|
||||
# before the user can save. What is enforced is the shape of the id — a flat
|
||||
# lowercase name — and its length; an id this backend has never heard of simply
|
||||
# renders as nothing in an older card.
|
||||
MAX_FURN_SYMBOL = 32
|
||||
_FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
|
||||
vol.Match(r"^[a-z0-9_]+$"))
|
||||
# …and its size: strictly positive, capped by the same canvas insurance limit
|
||||
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
|
||||
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("color"): _COLOR,
|
||||
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
|
||||
# Physical centimetres are canonical. `width` remains accepted so plans
|
||||
# written by older cards keep their exact appearance until edited.
|
||||
vol.Optional("width_cm"): vol.All(_finite, vol.Range(min=0.1, max=100)),
|
||||
vol.Optional("width"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=30)),
|
||||
}
|
||||
# Decor lives on the same unbounded canvas as everything else (docs/CANVAS.md):
|
||||
@@ -231,24 +558,181 @@ _NORM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "line",
|
||||
vol.Required("x1"): _NORM, vol.Required("y1"): _NORM,
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM},
|
||||
vol.Required("x2"): _NORM, vol.Required("y2"): _NORM,
|
||||
vol.Optional("line_style"): vol.In(["solid", "dashed"])},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): vol.In(["rect", "ellipse"]),
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _NORM, vol.Required("h"): _NORM,
|
||||
vol.Optional("fill"): bool},
|
||||
# sizes are extents — negative/zero is garbage, not "canvas slack"
|
||||
vol.Required("w"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Required("h"): vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT)),
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Optional("fill"): bool,
|
||||
vol.Optional("fill_color"): _COLOR,
|
||||
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"])},
|
||||
# the template: newlines are the user's own line breaks and are
|
||||
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
|
||||
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
|
||||
# — the block is scaled by its corner handles — but a plan
|
||||
# written before that keeps it, and it is read as the scale it
|
||||
# used to render at. Kept in the schema so it stays BOUNDED.
|
||||
vol.Optional("size"): vol.In(["s", "m", "l"]),
|
||||
# Canonical physical font size plus the legacy scale. Both are
|
||||
# accepted so older plans remain pixel-identical until edited
|
||||
# or explicitly optimized.
|
||||
vol.Optional("size_cm"): vol.All(
|
||||
_finite, vol.Range(min=0.1, max=DECOR_TEXT_CM_MAX)),
|
||||
vol.Optional("scale"): vol.All(
|
||||
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
# Legacy one-value link (beta.9 and earlier). New labels store
|
||||
# every `{entity[:attribute]}` reference directly in `text`;
|
||||
# these stay accepted solely for backward compatibility.
|
||||
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
|
||||
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
|
||||
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
# A piece of furniture (docs/FURNITURE.md): a symbol id, a normalised box
|
||||
# and an optional rotation. It is a NEW kind, so no existing plan carries
|
||||
# it, nothing is migrated, and an integration that has this branch reads
|
||||
# every older config byte-for-byte as before.
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "furniture",
|
||||
vol.Required("symbol"): _FURN_SYMBOL,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
SPACE_SCHEMA = vol.Schema(
|
||||
|
||||
def _wall_endpoints_pair(entry: dict) -> dict:
|
||||
"""Exact wall endpoints are useful only as a complete a/b pair."""
|
||||
if ("a" in entry) != ("b" in entry):
|
||||
raise vol.Invalid("wall exact endpoints require both a and b")
|
||||
return entry
|
||||
|
||||
|
||||
WALL_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("key"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
# New writes retain exact normalized interval endpoints. The old
|
||||
# key remains the compatibility lookup; endpoints preserve a
|
||||
# differing-thickness breakpoint after a virtual span is closed.
|
||||
vol.Optional("a"): vol.All([_NORM], vol.Length(min=2, max=2)),
|
||||
vol.Optional("b"): vol.All([_NORM], vol.Length(min=2, max=2)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_wall_endpoints_pair,
|
||||
)
|
||||
|
||||
|
||||
def _room_draft_segments(value: dict) -> dict:
|
||||
"""An open draft has exactly one thickness per consecutive edge."""
|
||||
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
|
||||
raise vol.Invalid("room draft segments must match consecutive point pairs")
|
||||
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
|
||||
raise vol.Invalid("room draft consecutive points must differ")
|
||||
return value
|
||||
|
||||
|
||||
ROOM_DRAFT_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("points"): vol.All([POINT], vol.Length(min=2, max=500)),
|
||||
vol.Required("segments"): vol.All(
|
||||
[vol.Schema({vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100))},
|
||||
extra=vol.ALLOW_EXTRA)],
|
||||
vol.Length(min=1, max=499),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_room_draft_segments,
|
||||
)
|
||||
|
||||
def _partition_nonzero(value: dict) -> dict:
|
||||
if value["a"] == value["b"]:
|
||||
raise vol.Invalid("partition endpoints must differ")
|
||||
return value
|
||||
|
||||
|
||||
PARTITION_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("a"): POINT,
|
||||
vol.Required("b"): POINT,
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=100)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_partition_nonzero,
|
||||
)
|
||||
|
||||
def _strict_wall_column(value: dict) -> dict:
|
||||
"""Reject shape-inapplicable or non-canonical column fields."""
|
||||
if value["shape"] == "circle" and "angle" in value:
|
||||
raise vol.Invalid("angle is allowed only for square wall columns")
|
||||
if value["shape"] == "square" and value.get("angle", 0) >= 90:
|
||||
raise vol.Invalid("square wall column angle must be in [0, 90)")
|
||||
return value
|
||||
|
||||
|
||||
WALL_COLUMN_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("shape"): vol.In(["square", "circle"]),
|
||||
vol.Required("center"): POINT,
|
||||
# Outer side for a square, outer diameter for a circle.
|
||||
vol.Required("cm"): vol.All(_finite, vol.Range(min=1, max=150)),
|
||||
vol.Optional("angle"): vol.All(
|
||||
_finite, vol.Range(min=0, max=90)
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_strict_wall_column,
|
||||
)
|
||||
|
||||
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
seen: set[str] = set()
|
||||
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
|
||||
for item in value.get(key, []):
|
||||
item_id = item.get("id")
|
||||
if not item_id:
|
||||
continue
|
||||
if item_id in seen:
|
||||
raise vol.Invalid("geometry object ids must be unique within a space")
|
||||
seen.add(item_id)
|
||||
draft_segments = sum(
|
||||
len(item.get("segments", [])) for item in value.get("room_drafts", [])
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
return value
|
||||
|
||||
|
||||
SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("id"): vol.All(str, vol.Match(SPACE_ID_RE.pattern)),
|
||||
vol.Required("title"): str,
|
||||
# Physical grid scale. It feeds every px/cell -> centimetres migration,
|
||||
# so NaN/Infinity or an absurd value must not be allowed to manufacture
|
||||
# invalid decor sizes later.
|
||||
vol.Optional("cell_cm"): vol.All(
|
||||
_finite, vol.Range(min=CELL_CM_MIN, max=CELL_CM_MAX)
|
||||
),
|
||||
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA,
|
||||
vol.Optional("plan_url"): vol.Any(str, None),
|
||||
# The canvas is square since v1.48.0. What used to be the space's own
|
||||
@@ -260,10 +744,10 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
|
||||
# scaled UNIFORMLY on the canvas. All three are optional and their
|
||||
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
|
||||
# and an old config validates unchanged. The offset is a normalised
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
|
||||
# resized per axis and rotated. Every transform field is optional; its
|
||||
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
|
||||
# config validates unchanged. The offset is a normalised
|
||||
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
|
||||
# the scale is a positive multiplier in a range a human could mean.
|
||||
vol.Optional("plan_x"): vol.Any(None, _COORD),
|
||||
@@ -271,6 +755,17 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Optional("plan_scale"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
# New writes may stretch each axis independently and rotate. The old
|
||||
# uniform field remains a read-compatible fallback for both axes.
|
||||
vol.Optional("plan_scale_x"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Optional("plan_scale_y"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
|
||||
),
|
||||
vol.Optional("plan_angle"): vol.Any(
|
||||
None, vol.All(_finite, vol.Range(min=-360.0, max=360.0))
|
||||
),
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
@@ -278,7 +773,7 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window"),
|
||||
vol.Required("type"): vol.Any("door", "window", "gate"),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
@@ -294,6 +789,26 @@ SPACE_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
], vol.Length(max=MAX_OPENINGS)),
|
||||
# Wall thickness (docs/WALL-THICKNESS.md): keyed by a segment identity
|
||||
# (midpoint + direction), thickness always in centimetres. Optional —
|
||||
# a space without `walls` validates and renders exactly as before.
|
||||
vol.Optional("walls"): vol.All([WALL_SCHEMA], vol.Length(max=MAX_WALLS)),
|
||||
vol.Optional("room_drafts"): vol.All(
|
||||
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
|
||||
),
|
||||
vol.Optional("partitions"): vol.All(
|
||||
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
|
||||
),
|
||||
vol.Optional("wall_columns"): vol.All(
|
||||
[WALL_COLUMN_SCHEMA], vol.Length(max=MAX_WALL_COLUMNS)
|
||||
),
|
||||
# Open (virtual) wall stretches: a piece of a shared boundary that the
|
||||
# user opened. Optional and bounded — a space without `open_spans`
|
||||
# validates exactly as before, and the legacy `rooms[].open_to` index
|
||||
# keeps working on its own (AUD-159B6-03).
|
||||
vol.Optional("open_spans"): vol.All(
|
||||
vol.Length(max=MAX_OPEN_SPANS), [_open_span], _dedupe_open_spans,
|
||||
),
|
||||
# Legacy: walls are derived from room outlines since v1.19.0 — a line has no
|
||||
# independent existence. Still accepted so a stale browser tab cannot fail a save;
|
||||
# the card strips the field on every write.
|
||||
@@ -303,15 +818,22 @@ SPACE_SCHEMA = vol.Schema(
|
||||
vol.Remove("segments"): object,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
), _space_geometry_invariants)
|
||||
MARKER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
# 'device:<device_id>' | 'entity:<entity_id>' | 'virtual'
|
||||
vol.Required("binding"): str,
|
||||
vol.Required("binding"): vol.All(
|
||||
str,
|
||||
vol.Length(min=1, max=MAX_TEXT),
|
||||
vol.Match(r"^(device:.+|entity:.+|virtual)$"),
|
||||
),
|
||||
vol.Optional("space"): vol.Any(str, None),
|
||||
vol.Optional("area"): vol.Any(str, None),
|
||||
vol.Optional("hidden"): bool,
|
||||
# A binding-level tombstone: not rendered or aggregated, but retained
|
||||
# so automatic discovery does not put a deleted device straight back.
|
||||
vol.Optional("removed"): bool,
|
||||
vol.Optional("name"): _TEXT_OR_NONE,
|
||||
vol.Optional("icon"): _TEXT_OR_NONE,
|
||||
vol.Optional("model"): _TEXT_OR_NONE,
|
||||
@@ -344,14 +866,48 @@ MARKER_SCHEMA = vol.Schema(
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("glow_color"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Optional("bri"): vol.Any(
|
||||
None,
|
||||
vol.All(_finite, vol.Range(min=0.01, max=1.0)),
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
vol.Optional("is_light"): vol.Any(bool, None),
|
||||
# Explicit leading entity for composite Always sources. It is kept
|
||||
# literally when temporarily absent; runtime falls back without
|
||||
# deleting the user's choice.
|
||||
# Semantic delta validation below the schema preserves unknown/future
|
||||
# literals until that exact field is edited (lossless config doctrine).
|
||||
vol.Optional("light_entity"): object,
|
||||
vol.Optional("value_badge"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
{
|
||||
# Required semantically for changed/new data. Optional here
|
||||
# keeps old/future configs readable until the user edits it.
|
||||
vol.Optional("enabled"): object,
|
||||
vol.Optional("position"): object,
|
||||
vol.Optional("source"): vol.Any(
|
||||
None,
|
||||
vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# keep in sync with DISPLAY_MODES in src/logic.ts — a cross-language test
|
||||
# asserts every option the editor offers is accepted here (issue #3)
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", None),
|
||||
vol.Optional("ripple_color"): vol.Any(str, None),
|
||||
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
|
||||
# offered, but remains accepted while old stores migrate to icon_ripple.
|
||||
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", "value", "static_icon", None),
|
||||
vol.Optional("ripple_color"): vol.Any(None, _COLOR),
|
||||
vol.Optional("ripple_size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=1, max=20)), None),
|
||||
vol.Optional("size"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0.2, max=6)), None),
|
||||
vol.Optional("angle"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-360, max=360)), None),
|
||||
@@ -370,11 +926,14 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
@@ -382,7 +941,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,9 @@ import base64
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -23,37 +26,118 @@ from .const import (
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
create_export,
|
||||
get_candidate,
|
||||
live_layout,
|
||||
prepare_apply,
|
||||
revalidate_candidate,
|
||||
)
|
||||
from .plans import (
|
||||
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
|
||||
plan_basename, plan_refs, reserve_filename,
|
||||
)
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .store import (
|
||||
LAYOUT_STORE_CORE_KEYS,
|
||||
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
|
||||
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
|
||||
HouseplanData,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
get_entry,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
|
||||
"""An optimization can be undone before any later ordinary plan edit."""
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
if not isinstance(backup, dict):
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
int(backup.get("after_config_rev", -1)) == int(config_data.get("rev", 0))
|
||||
and int(backup.get("after_layout_rev", -1)) == int(layout_data.get("rev", 0))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _optimizer_backup_after_layout_maintenance(
|
||||
layout_data: dict[str, Any], new_layout_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Carry a one-deep plan snapshot across explicit layout maintenance.
|
||||
|
||||
Geometry repair is part of plan maintenance, not an ordinary user edit:
|
||||
losing the Optimize/Import undo there makes the advertised safety net
|
||||
disappear. The snapshot must also follow the new layout revision or the
|
||||
freshness guard will correctly, but unhelpfully, classify it as stale.
|
||||
"""
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
if not isinstance(backup, dict):
|
||||
return {}
|
||||
return {
|
||||
_OPTIMIZE_BACKUP: {
|
||||
**backup,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _undo_kind(config_data: dict[str, Any], layout_data: dict[str, Any]) -> str | None:
|
||||
if not _optimizer_backup_is_current(config_data, layout_data):
|
||||
return None
|
||||
backup = layout_data.get(_OPTIMIZE_BACKUP)
|
||||
return str(backup.get("kind") or "optimize") if isinstance(backup, dict) else None
|
||||
|
||||
|
||||
async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
|
||||
"""Free a snapshot made stale by a later ordinary config edit."""
|
||||
data = await rt.store.async_load() or {}
|
||||
if _OPTIMIZE_BACKUP not in data and _OPTIMIZE_PENDING not in data:
|
||||
return
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
data,
|
||||
data.get("layout") or {},
|
||||
int(data.get("rev", 0)),
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_delete)
|
||||
websocket_api.async_register_command(hass, ws_layout_set)
|
||||
websocket_api.async_register_command(hass, ws_geometry_repair)
|
||||
websocket_api.async_register_command(hass, ws_layout_update)
|
||||
websocket_api.async_register_command(hass, ws_layout_delete)
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
websocket_api.async_register_command(hass, ws_import_apply)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -74,9 +158,351 @@ def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||
return may_write(hass, getattr(connection, "user", None))
|
||||
|
||||
|
||||
def _connection_user_id(connection) -> str:
|
||||
return str(getattr(getattr(connection, "user", None), "id", ""))
|
||||
|
||||
|
||||
def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
|
||||
connection.send_error(msg_id, err.code, err.message)
|
||||
|
||||
|
||||
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
key: value for key, value in stored.items()
|
||||
if key not in LAYOUT_STORE_CORE_KEYS
|
||||
}
|
||||
|
||||
|
||||
async def _persist_pair_intent(
|
||||
rt: HouseplanData,
|
||||
pending: dict[str, Any],
|
||||
) -> None:
|
||||
"""Make a target pair recoverable before either visible half moves."""
|
||||
stored = await rt.store.async_load() or {}
|
||||
metadata = dict(pending.get("final_metadata") or {})
|
||||
metadata[_OPTIMIZE_PENDING] = pending
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
stored,
|
||||
stored.get("layout") or {},
|
||||
int(stored.get("rev", 0)),
|
||||
metadata=metadata,
|
||||
replace_metadata=True,
|
||||
)
|
||||
|
||||
|
||||
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
|
||||
"""Write both target halves and remove the durable intent last."""
|
||||
await rt.config_store.async_save({
|
||||
"config": pending["config"],
|
||||
"rev": int(pending["config_rev"]),
|
||||
})
|
||||
stored = await rt.store.async_load() or {}
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
stored,
|
||||
pending["layout"],
|
||||
int(pending["layout_rev"]),
|
||||
metadata=dict(pending.get("final_metadata") or {}),
|
||||
replace_metadata=True,
|
||||
)
|
||||
|
||||
|
||||
async def _commit_import_pair(
|
||||
rt: HouseplanData,
|
||||
pending: dict[str, Any],
|
||||
rollback: dict[str, Any],
|
||||
) -> None:
|
||||
"""Commit, retry once, or restore the before-pair before returning.
|
||||
|
||||
A Store write may raise after the bytes reached disk, so recovery always
|
||||
reloads and converges an explicit pair instead of guessing which half won.
|
||||
If the target cannot be completed, a rollback intent replaces it before we
|
||||
restore the old pair; setup will therefore restore, never unexpectedly
|
||||
finish an import that was reported as failed.
|
||||
"""
|
||||
try:
|
||||
await _persist_pair_intent(rt, pending)
|
||||
await _converge_pair(rt, pending)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - one retry handles fail-after-write too
|
||||
_LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True)
|
||||
try:
|
||||
await _persist_pair_intent(rt, pending)
|
||||
await _converge_pair(rt, pending)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - target is no longer the recovery policy
|
||||
_LOGGER.exception("House Plan import target retry failed; restoring previous pair")
|
||||
try:
|
||||
await _persist_pair_intent(rt, rollback)
|
||||
await _converge_pair(rt, rollback)
|
||||
except Exception as rollback_error: # noqa: BLE001
|
||||
_LOGGER.exception(
|
||||
"House Plan import rollback could not finish; rollback intent remains for setup"
|
||||
)
|
||||
raise ImportFailure(
|
||||
"commit_failed", "Import failed; the previous plan is pending recovery"
|
||||
) from rollback_error
|
||||
raise ImportFailure("commit_failed", "Import failed and the previous plan was restored")
|
||||
|
||||
|
||||
# ---------------- portable backup / transfer ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/export/create",
|
||||
vol.Required("kind"): vol.In(["full", "space"]),
|
||||
vol.Optional("space_id"): str,
|
||||
vol.Optional("card_version", default=""): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Build a consistent full or one-space JSON snapshot."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may export House Plan")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
document, filename = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_export,
|
||||
rt,
|
||||
config_data,
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan export failed")
|
||||
connection.send_error(msg["id"], "invalid_config", "Could not create export")
|
||||
return
|
||||
connection.send_result(msg["id"], {"document": document, "filename": filename})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/import/revalidate",
|
||||
vol.Required("token"): str,
|
||||
vol.Optional("duplicate_policy", default="skip"): vol.In(["skip", "virtual"]),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_import_revalidate(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Re-evaluate a space candidate after its duplicate policy changes."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
try:
|
||||
registry_snapshot = import_registry_snapshot(hass)
|
||||
except Exception: # noqa: BLE001 - summary must not block revalidation
|
||||
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
|
||||
registry_snapshot = None
|
||||
result = await hass.async_add_executor_job(
|
||||
partial(
|
||||
revalidate_candidate,
|
||||
candidate,
|
||||
config_data,
|
||||
layout_data,
|
||||
duplicate_policy=msg["duplicate_policy"],
|
||||
registry_snapshot=registry_snapshot,
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
)
|
||||
result["token"] = msg["token"]
|
||||
result["expires_at"] = datetime.fromtimestamp(
|
||||
candidate["expires"], UTC
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/import/apply",
|
||||
vol.Required("token"): str,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
vol.Optional("duplicate_policy"): vol.In(["skip", "virtual"]),
|
||||
vol.Optional("confirm_missing_content", default=False): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Commit exactly the previewed candidate as one crash-resumable pair."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may import House Plan")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
kind = ""
|
||||
details: dict[str, Any] = {}
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if (
|
||||
msg["expected_config_rev"] != config_rev
|
||||
or msg["expected_layout_rev"] != layout_rev
|
||||
or candidate.get("config_rev") != config_rev
|
||||
or candidate.get("layout_rev") != layout_rev
|
||||
):
|
||||
raise ImportFailure("conflict", "Plan changed after the preview")
|
||||
kind = str(candidate["document"]["kind"])
|
||||
requested_policy = msg.get("duplicate_policy", candidate.get("duplicate_policy"))
|
||||
if kind == "space" and requested_policy != candidate.get("duplicate_policy"):
|
||||
raise ImportFailure("conflict", "Duplicate policy changed after the preview")
|
||||
target_config, target_layout, details = await hass.async_add_executor_job(
|
||||
partial(
|
||||
prepare_apply,
|
||||
candidate,
|
||||
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}},
|
||||
layout_data.get("layout") or {},
|
||||
duplicate_policy=candidate.get("duplicate_policy"),
|
||||
confirm_missing_content=msg["confirm_missing_content"],
|
||||
)
|
||||
)
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
target_config,
|
||||
config_data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
raise ImportFailure(
|
||||
"missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
missing_attachments = _missing_internal_attachments(
|
||||
Path(hass.config.path("")), target_config, config_data.get("config")
|
||||
)
|
||||
if missing_attachments:
|
||||
raise ImportFailure(
|
||||
"missing_content",
|
||||
"Attachment no longer exists: " + ", ".join(sorted(missing_attachments)),
|
||||
)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = None
|
||||
if kind == "full":
|
||||
backup = {
|
||||
"kind": "import",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
original_metadata = _layout_metadata(layout_data)
|
||||
replaced_metadata = {_OPTIMIZE_PENDING, _OPTIMIZE_BACKUP}
|
||||
if kind == "full":
|
||||
replaced_metadata.update({"repair_backup", "geom_pending"})
|
||||
final_metadata = {
|
||||
key: value for key, value in original_metadata.items()
|
||||
if key not in replaced_metadata
|
||||
}
|
||||
if backup is not None:
|
||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||
pending = {
|
||||
"kind": "import",
|
||||
"config": target_config,
|
||||
"layout": target_layout,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"final_metadata": final_metadata,
|
||||
}
|
||||
rollback = {
|
||||
"kind": "import_rollback",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
await _commit_import_pair(rt, pending, rollback)
|
||||
# A token becomes single-use only after both durable halves land.
|
||||
get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan import commit failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
|
||||
return
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
if kind == "full":
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
live_marker_ids = {
|
||||
str(marker.get("id")) for marker in target_config.get("markers") or []
|
||||
}
|
||||
if recorder is not None:
|
||||
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
|
||||
if marker_id not in live_marker_ids:
|
||||
try:
|
||||
await recorder.async_delete(marker_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan: removing orphan import trail failed")
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"kind": kind,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"counts": details.get("counts", {}),
|
||||
"space_id": details.get("space_id"),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
|
||||
# ---------------- layout ----------------
|
||||
|
||||
|
||||
def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Drop positions which a deleted marker can no longer own.
|
||||
|
||||
HA device ids may be layout-only because auto-discovered devices need no
|
||||
marker entry. Virtual ids are different: every live virtual marker is
|
||||
explicit, so a missing `v_*` owner is stale data. The prefix itself is
|
||||
only a legacy naming convention, however; an explicit real marker remains
|
||||
authoritative even when its id happens to begin with `v_`.
|
||||
"""
|
||||
return live_layout(config, layout)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
@@ -85,8 +511,14 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
connection.send_result(
|
||||
msg["id"], {"layout": data.get("layout", {}), "rev": int(data.get("rev", 0))}
|
||||
msg["id"], {
|
||||
"layout": data.get("layout", {}),
|
||||
"rev": int(data.get("rev", 0)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
|
||||
"undo_kind": _undo_kind(config_data, data),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -113,6 +545,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
data = await rt.store.async_load() or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
@@ -120,9 +553,12 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||
)
|
||||
return
|
||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": msg["layout"], "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
@@ -144,6 +580,39 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
# A stale browser may still finish a drag after another client deleted
|
||||
# the marker. Its tombstone is the server-side authority: acknowledge
|
||||
# but ignore the late point so re-adding starts without a zombie
|
||||
# position.
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
config = config_data.get("config") or {}
|
||||
markers = config.get("markers") or []
|
||||
deleted = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
|
||||
for m in markers
|
||||
)
|
||||
live_virtual = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
and m.get("binding") == "virtual"
|
||||
for m in markers
|
||||
)
|
||||
live_explicit = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
for m in markers
|
||||
)
|
||||
orphan_virtual = (
|
||||
msg["device_id"].startswith("v_")
|
||||
and not live_virtual
|
||||
and not live_explicit
|
||||
)
|
||||
if deleted or orphan_virtual:
|
||||
data = await rt.store.async_load() or {}
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"ignored": "removed" if deleted else "missing_virtual",
|
||||
"rev": int(data.get("rev", 0)),
|
||||
})
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
@@ -151,8 +620,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
@@ -164,6 +635,7 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||
vol.Optional("dry_run"): bool,
|
||||
vol.Optional("undo"): bool,
|
||||
vol.Optional("expected_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -196,6 +668,12 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Layout was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
if msg.get("undo"):
|
||||
backup = data.get("repair_backup")
|
||||
if not isinstance(backup, dict) or backup.get("space") != space_id:
|
||||
@@ -205,7 +683,11 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
for key, pos in (backup.get("positions") or {}).items():
|
||||
restored[key] = pos
|
||||
new_rev = current_rev + 1
|
||||
await rt.store.async_save({"layout": restored, "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, restored, new_rev,
|
||||
metadata=_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||
remove=("repair_backup",),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev,
|
||||
"restored": len(backup.get("positions") or {})})
|
||||
@@ -237,10 +719,14 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
# the backup rides the same store write: either both are durable or
|
||||
# neither — the deletion-shy rules of this project apply to positions
|
||||
# too
|
||||
await rt.store.async_save({
|
||||
"layout": new_layout, "rev": new_rev,
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
})
|
||||
await async_save_layout_state(
|
||||
rt, data, new_layout, new_rev,
|
||||
metadata={
|
||||
**_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
},
|
||||
remove=("repair_backup",),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "moved": len(preview)})
|
||||
|
||||
@@ -548,8 +1034,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
new_rev = int(data.get("rev", 0)) + 1
|
||||
await rt.store.async_save({**{k: v for k, v in data.items() if k not in ("layout", "rev")},
|
||||
"layout": layout, "rev": new_rev})
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
if new_rev is not None:
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
@@ -561,13 +1049,28 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the configuration and its revision."""
|
||||
"""Return the configuration, its revision, and whether this user may write.
|
||||
|
||||
`can_write` is the single source of truth for the card's editor chrome
|
||||
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
|
||||
check that drifted from the integration option.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": config,
|
||||
"rev": data.get("rev", 0),
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(data, layout_data),
|
||||
"undo_kind": _undo_kind(data, layout_data),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -611,6 +1114,30 @@ def _missing_internal_plans(
|
||||
}
|
||||
|
||||
|
||||
def _missing_internal_attachments(
|
||||
config_root: Path, config: dict[str, Any], previous: dict[str, Any] | None = None
|
||||
) -> set[str]:
|
||||
"""New local attachments that vanished between import preview and apply.
|
||||
|
||||
Existing broken references stay writable for the same reason as historical
|
||||
plan URLs: refusing every unrelated write would prevent the user from
|
||||
detaching or repairing them.
|
||||
"""
|
||||
known = {
|
||||
str(item["url"])
|
||||
for item in content_manifest(previous or {}, config_root)
|
||||
if item.get("kind") == "attachment" and item.get("storage") == "internal"
|
||||
}
|
||||
return {
|
||||
str(item["url"])
|
||||
for item in content_manifest(config, config_root)
|
||||
if item.get("kind") == "attachment"
|
||||
and item.get("storage") == "internal"
|
||||
and item.get("exists_at_export") is False
|
||||
and str(item["url"]) not in known
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -660,6 +1187,16 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
f"Configuration was changed in another window (rev {current_rev} != {msg['expected_rev']})",
|
||||
)
|
||||
return
|
||||
# Marker-to-marker light links are a semantic graph layered on top of
|
||||
# the lossless controls array. Validate only edges introduced by this
|
||||
# write so an unrelated edit can still round-trip a legacy broken ref.
|
||||
try:
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
except MarkerControlError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
# plan and then delete it from the same dialog, and two clients can do
|
||||
# the same thing in either order — the lock serialises them but says
|
||||
@@ -679,6 +1216,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 — stale backup cleanup is best-effort
|
||||
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
@@ -704,6 +1245,205 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
|
||||
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize",
|
||||
vol.Required("config"): CONFIG_SCHEMA,
|
||||
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Atomically-intended replacement of config+layout with one-deep undo.
|
||||
|
||||
Home Assistant stores are separate files, so a literal cross-file
|
||||
transaction is impossible. Persisting the target as an intent before
|
||||
either half changes makes a crash resumable during the next setup; the UI
|
||||
only receives success once both halves are durable.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may optimize plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
size = len(json.dumps(msg["config"], separators=(",", ":")))
|
||||
if size > MAX_CONFIG_BYTES:
|
||||
connection.send_error(
|
||||
msg["id"], "too_large",
|
||||
f"Configuration is {size // 1024} KB, the limit is {MAX_CONFIG_BYTES // 1024} KB",
|
||||
)
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Plan changed elsewhere (config {config_rev}, layout {layout_rev})",
|
||||
)
|
||||
return
|
||||
|
||||
# Optimization is a normal configuration write with an additional
|
||||
# layout transaction. It must enforce the same marker-link semantics
|
||||
# as config/set; otherwise a crafted client can persist a new cycle.
|
||||
try:
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
except MarkerControlError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
|
||||
missing = await hass.async_add_executor_job(
|
||||
_missing_internal_plans,
|
||||
Path(hass.config.path(PLANS_DIR)),
|
||||
msg["config"],
|
||||
config_data.get("config"),
|
||||
)
|
||||
if missing:
|
||||
connection.send_error(
|
||||
msg["id"], "missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"kind": "optimize",
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
pending = {
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": False,
|
||||
}
|
||||
# Intent first. A setup-time finisher completes whichever half a crash
|
||||
# interrupted; until then the visible layout/revision remain unchanged.
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, layout_data.get("layout", {}), layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_config_rev})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, msg["layout"], new_layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"),
|
||||
)
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"can_undo": True,
|
||||
})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize_undo",
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Restore the snapshot, but never overwrite edits made after optimization."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may undo optimization")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
restored_kind = "optimize"
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
|
||||
return
|
||||
if not _optimizer_backup_is_current(config_data, layout_data):
|
||||
connection.send_error(
|
||||
msg["id"], "no_backup",
|
||||
"The optimization backup is unavailable or a later edit made it stale",
|
||||
)
|
||||
return
|
||||
|
||||
backup = layout_data[_OPTIMIZE_BACKUP]
|
||||
restored_kind = str(backup.get("kind") or "optimize")
|
||||
restored_config = backup.get("config") or DEFAULT_CONFIG
|
||||
restored_layout = backup.get("layout") or {}
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
pending = {
|
||||
"kind": "import_undo" if restored_kind == "import" else "optimize_undo",
|
||||
"config": restored_config,
|
||||
"layout": restored_layout,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": True,
|
||||
}
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, layout_data.get("layout", {}), layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await rt.config_store.async_save({"config": restored_config, "rev": new_config_rev})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, restored_layout, new_layout_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
|
||||
)
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
if restored_kind == "import":
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
live_marker_ids = {
|
||||
str(marker.get("id")) for marker in restored_config.get("markers") or []
|
||||
}
|
||||
if recorder is not None:
|
||||
for marker_id in list(getattr(getattr(recorder, "book", None), "data", {})):
|
||||
if marker_id not in live_marker_ids:
|
||||
try:
|
||||
await recorder.async_delete(marker_id)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan: removing orphan undo trail failed")
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"can_undo": False,
|
||||
})
|
||||
|
||||
|
||||
# ---------------- plan uploads ----------------
|
||||
|
||||
|
||||
@@ -785,3 +1525,22 @@ async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConn
|
||||
"""Current + previous cleanup runs per marker, raw robot coordinates."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/trail/delete",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=256)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Permanently forget one deleted marker's current and previous runs."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may delete trails")
|
||||
return
|
||||
if _runtime(hass, connection, msg["id"]) is None:
|
||||
return
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
removed = await rec.async_delete(msg["marker_id"]) if rec else False
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env node
|
||||
/** Isolated 1/10/30/60-pool performance profiles for #19 and #55. */
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { launch } from './serve.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const profile = valueArg('profile') || 'large-light-blend-v1';
|
||||
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
|
||||
throw new Error(`unknown Glow profile: ${profile}`);
|
||||
const parsedSamples = Number(valueArg('samples'));
|
||||
const parsedWarmups = Number(valueArg('warmups'));
|
||||
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
|
||||
const warmups = Math.max(0, Math.min(5, Number.isFinite(parsedWarmups) && parsedWarmups >= 0 ? parsedWarmups : 1));
|
||||
const requestedVariants = valueArg('variants')?.split(',').map(Number);
|
||||
if (requestedVariants?.some((count) => ![1, 10, 30, 60].includes(count)))
|
||||
throw new Error(`invalid Glow variants: ${valueArg('variants')}`);
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const additiveFixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
|
||||
));
|
||||
additiveFixture.sourceIds = Object.keys(additiveFixture.ha.states)
|
||||
.filter((entityId) => entityId.startsWith('light.'));
|
||||
additiveFixture.roomCount = additiveFixture.config.spaces
|
||||
.reduce((sum, space) => sum + space.rooms.length, 0);
|
||||
additiveFixture.deviceCount = Object.keys(additiveFixture.ha.devices).length;
|
||||
|
||||
const makeOverlayFixture = () => {
|
||||
const large = makeLargeHouseFixture();
|
||||
const firstSpace = large.config.spaces[0].id;
|
||||
const sourceDeviceIds = Object.entries(large.layout)
|
||||
.filter(([, position]) => position.s === firstSpace)
|
||||
.slice(0, 60)
|
||||
.map(([deviceId]) => deviceId);
|
||||
const sourceIds = [];
|
||||
sourceDeviceIds.forEach((deviceId, index) => {
|
||||
for (const [entityId, entity] of Object.entries(large.entities)) {
|
||||
if (entity.device_id !== deviceId) continue;
|
||||
delete large.entities[entityId];
|
||||
delete large.states[entityId];
|
||||
}
|
||||
const entityId = `light.glow_overlay_${String(index + 1).padStart(3, '0')}`;
|
||||
large.entities[entityId] = {
|
||||
entity_id: entityId, device_id: deviceId, platform: 'houseplan_perf',
|
||||
config_entry_id: 'perf_entry', disabled_by: null,
|
||||
};
|
||||
large.states[entityId] = {
|
||||
entity_id: entityId, state: 'on',
|
||||
attributes: {
|
||||
friendly_name: `Overlay light ${index + 1}`,
|
||||
brightness: 96 + (index % 5) * 32,
|
||||
rgb_color: index % 2 ? [255, 154, 72] : [92, 156, 255],
|
||||
},
|
||||
};
|
||||
sourceIds.push(entityId);
|
||||
});
|
||||
// The shared large-house fixture already contains a few ordinary lights.
|
||||
// Keep them as devices but turn them off so the profile's pool cardinality
|
||||
// is exactly the declared 1/10/30/60, not N plus an unrelated background lamp.
|
||||
for (const [entityId, state] of Object.entries(large.states)) {
|
||||
if (entityId.startsWith('light.') && !sourceIds.includes(entityId)) {
|
||||
large.states[entityId] = { ...state, state: 'off' };
|
||||
}
|
||||
}
|
||||
for (const space of large.config.spaces) {
|
||||
space.settings = { ...(space.settings || {}), fill_mode: 'temp', glow_enabled: true };
|
||||
}
|
||||
return {
|
||||
fixture: 'large-house-glow-overlay-v1', variants: [1, 10, 30, 60],
|
||||
config: large.config, layout: large.layout,
|
||||
ha: { devices: large.devices, entities: large.entities, areas: large.areas, states: large.states },
|
||||
sourceIds,
|
||||
roomCount: large.counts.rooms,
|
||||
deviceCount: large.counts.devices,
|
||||
};
|
||||
};
|
||||
const fixture = profile === 'large-light-blend-v1' ? additiveFixture : makeOverlayFixture();
|
||||
if (requestedVariants?.length) fixture.variants = [...new Set(requestedVariants)];
|
||||
const viewport = { width: 1280, height: 900 };
|
||||
|
||||
const { page, browser } = await launch(
|
||||
viewport, 1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{}, resolve(targetRoot, 'demo/srv'),
|
||||
);
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
|
||||
} catch (error) {
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const sample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({ fixture, profile, sample, cardContract }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('Glow benchmark timed out');
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
};
|
||||
const observeLongTasks = () => {
|
||||
const entries = [];
|
||||
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask'))
|
||||
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return { stop: async () => {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
const values = entries.map((entry) => entry.duration);
|
||||
return {
|
||||
supported: true,
|
||||
count: values.length,
|
||||
maxMs: Number((values.length ? Math.max(...values) : 0).toFixed(2)),
|
||||
totalMs: Number(values.reduce((sum, value) => sum + value, 0).toFixed(2)),
|
||||
};
|
||||
}};
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc(); await frame(); globalThis.gc(); await frame();
|
||||
return true;
|
||||
};
|
||||
const configFor = () => {
|
||||
const config = structuredClone(fixture.config);
|
||||
if (profile === 'large-light-blend-v1') {
|
||||
const settings = config.spaces[0].settings;
|
||||
settings.fill_mode = 'glow';
|
||||
delete settings.glow_enabled;
|
||||
}
|
||||
return config;
|
||||
};
|
||||
const statesFor = (count, brightnessDelta = 0) => {
|
||||
const active = new Set(fixture.sourceIds.slice(0, count));
|
||||
const sources = new Set(fixture.sourceIds);
|
||||
return Object.fromEntries(Object.entries(fixture.ha.states).map(([entityId, state]) => {
|
||||
if (!sources.has(entityId)) return [entityId, state];
|
||||
return [entityId, {
|
||||
...state,
|
||||
state: active.has(entityId) ? 'on' : 'off',
|
||||
attributes: {
|
||||
...state.attributes,
|
||||
brightness: Math.max(1, Math.min(255, Number(state.attributes.brightness) + brightnessDelta)),
|
||||
},
|
||||
}];
|
||||
}));
|
||||
};
|
||||
const connection = {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'glow-perf', name: 'Glow performance', is_admin: true },
|
||||
devices: fixture.ha.devices, entities: fixture.ha.entities,
|
||||
areas: fixture.ha.areas, states, floors: {}, connection,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: configFor(), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.ha.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.ha.entities);
|
||||
if (message.type === 'config_entries/get')
|
||||
return [{ entry_id: 'glow_fixture', domain: 'houseplan_fixture', title: 'Glow fixture' }];
|
||||
if (message.type === 'manifest/list')
|
||||
return [{ domain: 'houseplan_fixture', name: 'House Plan Glow Fixture' }];
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
const cacheSnapshot = (card) => ({
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
});
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(statesFor(1));
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
|
||||
if ('_glowScreenBlend' in card) {
|
||||
const probeDeadline = performance.now() + 2500;
|
||||
while (!card._glowScreenBlend && performance.now() < probeDeadline)
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
for (const count of fixture.variants) {
|
||||
// Mount cost is not part of this profile. Prime each source-count
|
||||
// state on the same full plan, then measure only the following HA tick.
|
||||
card.hass = hassFor(statesFor(count));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
let renders = 0;
|
||||
const originalUpdate = card.performUpdate.bind(card);
|
||||
card.performUpdate = () => { renders++; return originalUpdate(); };
|
||||
const longTasks = observeLongTasks();
|
||||
const started = performance.now();
|
||||
card.hass = hassFor(statesFor(count, 1));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
result[`stateUpdate${count}Ms`] = Number((performance.now() - started).toFixed(2));
|
||||
result.longTasks[`stateUpdate${count}`] = await longTasks.stop();
|
||||
result.renderCounts[count] = renders;
|
||||
result.poolCounts[count] = card.renderRoot.querySelectorAll('.glow-pool, .glowlayer circle').length;
|
||||
}
|
||||
window.__card = card;
|
||||
await forceGc();
|
||||
const cacheBefore = cacheSnapshot(card);
|
||||
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
|
||||
for (let index = 0; index < 5; index++) {
|
||||
card.hass = hassFor(statesFor(60, index % 2));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
await forceGc();
|
||||
const cacheEntries = cacheSnapshot(card);
|
||||
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
|
||||
result.cacheEntries = cacheEntries;
|
||||
result.cacheGrowth = Object.fromEntries(
|
||||
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
||||
);
|
||||
result.heapGrowthBytes = heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore;
|
||||
result.preciseGc = typeof globalThis.gc === 'function';
|
||||
result.renderedDevices = card._devices?.length ?? 0;
|
||||
result.screenBlend = card._glowScreenBlend === true;
|
||||
return result;
|
||||
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
|
||||
const captureStarted = performance.now();
|
||||
await page.screenshot({ type: 'png' });
|
||||
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
|
||||
if (sample >= 0) rows.push(row);
|
||||
}
|
||||
} finally {
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => undefined);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const metricNames = [
|
||||
...fixture.variants.map((count) => `stateUpdate${count}Ms`), 'screenshotCaptureMs',
|
||||
];
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
node: process.version, chromium, platform: process.platform, arch: process.arch,
|
||||
viewport, deviceScaleFactor: 1, cpuThrottleRate: 4, reducedMotion: true,
|
||||
},
|
||||
fixture: {
|
||||
id: fixture.fixture, variants: fixture.variants,
|
||||
rooms: fixture.roomCount, devices: fixture.deviceCount,
|
||||
},
|
||||
samples,
|
||||
warmups,
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
rows,
|
||||
};
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, text, 'utf8');
|
||||
console.log(output);
|
||||
} else process.stdout.write(text);
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const viewport = { width: 1440, height: 1000 };
|
||||
|
||||
const { page, browser } = await launch(
|
||||
viewport,
|
||||
1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{},
|
||||
resolve(targetRoot, 'demo/srv'),
|
||||
);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
|
||||
} catch (error) {
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({ fixture, sample, cardContract }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
|
||||
await new Promise((done) => setTimeout(done, 10));
|
||||
}
|
||||
};
|
||||
const startLongTaskWindow = () => {
|
||||
const entries = [];
|
||||
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
|
||||
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
|
||||
}
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return {
|
||||
stop: async () => {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
const durations = entries.map((entry) => entry.duration);
|
||||
return {
|
||||
supported: true,
|
||||
count: durations.length,
|
||||
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
|
||||
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
const duration = async (action) => {
|
||||
const longTasks = startLongTaskWindow();
|
||||
const started = performance.now();
|
||||
await action();
|
||||
await frame();
|
||||
return {
|
||||
ms: Number((performance.now() - started).toFixed(2)),
|
||||
longTasks: await longTasks.stop(),
|
||||
};
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc();
|
||||
await frame();
|
||||
globalThis.gc();
|
||||
await frame();
|
||||
return true;
|
||||
};
|
||||
const cacheSnapshot = (card) => ({
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({
|
||||
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
|
||||
});
|
||||
const connection = {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
|
||||
if (message.type === 'config_entries/get')
|
||||
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
|
||||
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
connection,
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(fixture.states);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
await until(() => card._booting === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
const spaceSwitch = await duration(async () => {
|
||||
card._pickSpace('perf-floor-2');
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const firstEntity = Object.keys(fixture.states)[0];
|
||||
const nextStates = {
|
||||
...fixture.states,
|
||||
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
|
||||
};
|
||||
const stateUpdate = await duration(async () => {
|
||||
card.hass = hassFor(nextStates);
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const resizePreview = await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
await card.updateComplete;
|
||||
const room = card._rszRooms()[0];
|
||||
const pointerId = 777;
|
||||
const quietEvent = {
|
||||
pointerId,
|
||||
stopPropagation: () => undefined,
|
||||
preventDefault: () => undefined,
|
||||
target: null,
|
||||
};
|
||||
card._rszEdgeDown(quietEvent, room.id, 1);
|
||||
const plan = card._rszDrag?.plan;
|
||||
if (!plan) throw new Error('large-house resize plan was not created');
|
||||
const target = [
|
||||
plan.a[0] + plan.n[0] * card._gridPitch,
|
||||
plan.a[1] + plan.n[1] * card._gridPitch,
|
||||
];
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._baseVb());
|
||||
card._rszMove({
|
||||
...quietEvent,
|
||||
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
|
||||
});
|
||||
await card.updateComplete;
|
||||
card._rszCancelDrag();
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const panZoom = await duration(async () => {
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const settingsDialog = await duration(async () => {
|
||||
card._openSettingsDialog();
|
||||
await card.updateComplete;
|
||||
});
|
||||
card._settingsDialog = null;
|
||||
await card.updateComplete;
|
||||
|
||||
const switchCycle = await duration(async () => {
|
||||
for (let index = 0; index < 12; index++) {
|
||||
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
||||
await card.updateComplete;
|
||||
// A user cannot produce twelve tab clicks in one JavaScript task.
|
||||
// Yield between interactions so Long Task entries describe one
|
||||
// switch, while switchCycleMs still measures the complete cycle.
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
}
|
||||
});
|
||||
|
||||
await forceGc();
|
||||
const cacheBefore = cacheSnapshot(card);
|
||||
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
|
||||
for (let round = 0; round < 4; round++) {
|
||||
for (let index = 0; index < 12; index++) {
|
||||
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
}
|
||||
await forceGc();
|
||||
}
|
||||
const cacheEntries = cacheSnapshot(card);
|
||||
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
|
||||
const cacheGrowth = Object.fromEntries(
|
||||
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
||||
);
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
panZoomMs: panZoom.ms,
|
||||
settingsDialogMs: settingsDialog.ms,
|
||||
switchCycleMs: switchCycle.ms,
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
panZoom: panZoom.longTasks,
|
||||
settingsDialog: settingsDialog.longTasks,
|
||||
switchCycle: switchCycle.longTasks,
|
||||
},
|
||||
cacheEntries,
|
||||
cacheGrowth,
|
||||
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
|
||||
preciseGc: typeof globalThis.gc === 'function',
|
||||
renderedDevices: card._devices?.length ?? 0,
|
||||
};
|
||||
card.remove();
|
||||
await frame();
|
||||
return result;
|
||||
}, { fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT });
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const metricNames = [
|
||||
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile: 'large-house-v1',
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
node: process.version,
|
||||
chromium,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
viewport,
|
||||
deviceScaleFactor: 1,
|
||||
reducedMotion: true,
|
||||
},
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
samples,
|
||||
warmups,
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
rows,
|
||||
note: 'Compare with a base-SHA report captured by the same runner and evaluate demo/performance/budgets.json.',
|
||||
};
|
||||
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, text, 'utf8');
|
||||
console.log(output);
|
||||
} else {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
@@ -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,238 @@
|
||||
/**
|
||||
* Deterministic, fictional high-load fixture shared by performance and future
|
||||
* visual-regression tooling. Nothing here depends on a real HA installation.
|
||||
*/
|
||||
|
||||
const FLOOR_COUNT = 3;
|
||||
const ROOMS_PER_FLOOR = 20;
|
||||
const DEVICE_COUNT = 200;
|
||||
const OPENING_COUNT = 100;
|
||||
const PARTITION_COUNT = 60;
|
||||
const COLUMN_COUNT = 40;
|
||||
const DECOR_COUNT = 500;
|
||||
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
const roomGrid = (floor) => {
|
||||
const rooms = [];
|
||||
const left = 0.04;
|
||||
const top = 0.04;
|
||||
const width = 0.92 / 5;
|
||||
const height = 0.92 / 4;
|
||||
for (let row = 0; row < 4; row++) {
|
||||
for (let column = 0; column < 5; column++) {
|
||||
const index = row * 5 + column;
|
||||
const x1 = round(left + column * width);
|
||||
const y1 = round(top + row * height);
|
||||
const x2 = round(x1 + width);
|
||||
const y2 = round(y1 + height);
|
||||
rooms.push({
|
||||
id: `perf-room-${floor}-${index}`,
|
||||
name: `Room ${floor + 1}.${index + 1}`,
|
||||
area: `perf_area_${floor}_${index}`,
|
||||
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
|
||||
});
|
||||
}
|
||||
}
|
||||
return rooms;
|
||||
};
|
||||
|
||||
const wallSegments = (rooms) => {
|
||||
const unique = new Map();
|
||||
for (const room of rooms) {
|
||||
room.poly.forEach((a, index) => {
|
||||
const b = room.poly[(index + 1) % room.poly.length];
|
||||
const forward = `${a.join(',')}/${b.join(',')}`;
|
||||
const reverse = `${b.join(',')}/${a.join(',')}`;
|
||||
if (!unique.has(reverse)) unique.set(forward, { a, b });
|
||||
});
|
||||
}
|
||||
return [...unique.values()];
|
||||
};
|
||||
|
||||
const makeOpenings = (floor, walls, count) => walls.slice(0, count).map((wall, index) => {
|
||||
const horizontal = Math.abs(wall.b[0] - wall.a[0]) >= Math.abs(wall.b[1] - wall.a[1]);
|
||||
return {
|
||||
id: `perf-opening-${floor}-${index}`,
|
||||
type: index % 7 === 0 ? 'window' : index % 11 === 0 ? 'gate' : 'door',
|
||||
x: round((wall.a[0] + wall.b[0]) / 2),
|
||||
y: round((wall.a[1] + wall.b[1]) / 2),
|
||||
angle: horizontal ? 0 : 90,
|
||||
length: horizontal ? 0.045 : 0.055,
|
||||
};
|
||||
});
|
||||
|
||||
const makePartitions = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
|
||||
const room = rooms[index % rooms.length];
|
||||
const [a, , c] = room.poly;
|
||||
const y = round(a[1] + (c[1] - a[1]) * (0.35 + (index % 3) * 0.12));
|
||||
return {
|
||||
id: `perf-partition-${floor}-${index}`,
|
||||
a: [round(a[0] + 0.035), y],
|
||||
b: [round(c[0] - 0.035), y],
|
||||
cm: 10 + (index % 3) * 5,
|
||||
};
|
||||
});
|
||||
|
||||
const makeColumns = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
|
||||
const room = rooms[(index * 3) % rooms.length];
|
||||
const [a, , c] = room.poly;
|
||||
return {
|
||||
id: `perf-column-${floor}-${index}`,
|
||||
shape: index % 3 === 0 ? 'circle' : 'square',
|
||||
center: [
|
||||
round(a[0] + (c[0] - a[0]) * (0.28 + (index % 2) * 0.44)),
|
||||
round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 1) % 2) * 0.44)),
|
||||
],
|
||||
cm: 25 + (index % 4) * 5,
|
||||
...(index % 3 === 0 ? {} : { angle: (index % 6) * 15 }),
|
||||
};
|
||||
});
|
||||
|
||||
const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) => {
|
||||
const column = index % 25;
|
||||
const row = Math.floor(index / 25);
|
||||
const x = round(0.02 + column * 0.039);
|
||||
const y = round(0.018 + (row % 20) * 0.048);
|
||||
if (index % 10 === 0) {
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'text', x, y, text: `F${floor + 1}-${index}`, size_cm: 14,
|
||||
color: '#59636e', opacity: 0.75,
|
||||
};
|
||||
}
|
||||
if (index % 3 === 0) {
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'rect', x, y, w: 0.022, h: 0.018, angle: (index % 12) * 5,
|
||||
color: '#687681', opacity: 0.55, width_cm: 1.5,
|
||||
fill: index % 2 === 0, fill_color: '#75838e', fill_opacity: 0.12,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: `perf-decor-${floor}-${index}`,
|
||||
kind: 'line', x1: x, y1: y, x2: round(x + 0.025), y2: round(y + (index % 2 ? 0.012 : 0)),
|
||||
color: '#687681', opacity: 0.6, width_cm: 1.2,
|
||||
...(index % 9 === 0 ? { line_style: 'dashed' } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const entityKinds = [
|
||||
['light', 'on'],
|
||||
['switch', 'off'],
|
||||
['sensor', '21.5'],
|
||||
['binary_sensor', 'off'],
|
||||
['climate', 'heat'],
|
||||
['media_player', 'playing'],
|
||||
['cover', 'closed'],
|
||||
['fan', 'on'],
|
||||
['lock', 'locked'],
|
||||
['vacuum', 'docked'],
|
||||
];
|
||||
|
||||
const makeRuntime = (spaces) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {};
|
||||
const areas = {};
|
||||
const layout = {};
|
||||
const roomRefs = spaces.flatMap((space) => space.rooms.map((room) => ({ space, room })));
|
||||
for (const { room } of roomRefs) areas[room.area] = { area_id: room.area, name: room.name };
|
||||
for (let index = 0; index < DEVICE_COUNT; index++) {
|
||||
const { space, room } = roomRefs[index % roomRefs.length];
|
||||
const [domain, baseState] = entityKinds[index % entityKinds.length];
|
||||
const deviceId = `perf-device-${index}`;
|
||||
const entityId = `${domain}.perf_${index}`;
|
||||
devices[deviceId] = {
|
||||
id: deviceId,
|
||||
name: `Synthetic ${domain} ${index + 1}`,
|
||||
model: `PERF-${String(index + 1).padStart(3, '0')}`,
|
||||
area_id: room.area,
|
||||
identifiers: [['houseplan_perf', deviceId]],
|
||||
config_entries: ['perf_entry'],
|
||||
entry_type: null,
|
||||
via_device_id: null,
|
||||
disabled_by: null,
|
||||
};
|
||||
entities[entityId] = {
|
||||
entity_id: entityId,
|
||||
device_id: deviceId,
|
||||
platform: 'houseplan_perf',
|
||||
config_entry_id: 'perf_entry',
|
||||
disabled_by: null,
|
||||
};
|
||||
const attributes = { friendly_name: devices[deviceId].name };
|
||||
if (domain === 'sensor') Object.assign(attributes, {
|
||||
device_class: 'temperature', unit_of_measurement: '°C', state_class: 'measurement',
|
||||
});
|
||||
if (domain === 'binary_sensor') attributes.device_class = index % 2 ? 'motion' : 'occupancy';
|
||||
if (domain === 'climate') Object.assign(attributes, { current_temperature: 21.5, temperature: 22 });
|
||||
states[entityId] = { entity_id: entityId, state: index % 4 === 0 && domain === 'light' ? 'off' : baseState, attributes };
|
||||
const [a, , c] = room.poly;
|
||||
layout[deviceId] = {
|
||||
s: space.id,
|
||||
x: round(a[0] + (c[0] - a[0]) * (0.2 + (index % 4) * 0.2)),
|
||||
y: round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 2) % 3) * 0.22)),
|
||||
};
|
||||
}
|
||||
return { devices, entities, states, areas, layout };
|
||||
};
|
||||
|
||||
export const LARGE_HOUSE_COUNTS = Object.freeze({
|
||||
floors: FLOOR_COUNT,
|
||||
rooms: FLOOR_COUNT * ROOMS_PER_FLOOR,
|
||||
devices: DEVICE_COUNT,
|
||||
openings: OPENING_COUNT,
|
||||
partitions: PARTITION_COUNT,
|
||||
columns: COLUMN_COUNT,
|
||||
decor: DECOR_COUNT,
|
||||
});
|
||||
|
||||
export const makeLargeHouseFixture = () => {
|
||||
let openingsLeft = OPENING_COUNT;
|
||||
let partitionsLeft = PARTITION_COUNT;
|
||||
let columnsLeft = COLUMN_COUNT;
|
||||
let decorLeft = DECOR_COUNT;
|
||||
const spaces = Array.from({ length: FLOOR_COUNT }, (_, floor) => {
|
||||
const rooms = roomGrid(floor);
|
||||
const segments = wallSegments(rooms);
|
||||
const floorsRemaining = FLOOR_COUNT - floor;
|
||||
const openingCount = Math.ceil(openingsLeft / floorsRemaining);
|
||||
const partitionCount = Math.ceil(partitionsLeft / floorsRemaining);
|
||||
const columnCount = Math.ceil(columnsLeft / floorsRemaining);
|
||||
const decorCount = Math.ceil(decorLeft / floorsRemaining);
|
||||
openingsLeft -= openingCount;
|
||||
partitionsLeft -= partitionCount;
|
||||
columnsLeft -= columnCount;
|
||||
decorLeft -= decorCount;
|
||||
return {
|
||||
id: `perf-floor-${floor + 1}`,
|
||||
title: `Performance floor ${floor + 1}`,
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
|
||||
rooms,
|
||||
walls: segments.map((wall, index) => ({
|
||||
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
|
||||
})),
|
||||
openings: makeOpenings(floor, segments, openingCount),
|
||||
partitions: makePartitions(floor, rooms, partitionCount),
|
||||
wall_columns: makeColumns(floor, rooms, columnCount),
|
||||
decor: makeDecor(floor, decorCount),
|
||||
};
|
||||
});
|
||||
const runtime = makeRuntime(spaces);
|
||||
const lightMarkers = Object.entries(runtime.entities)
|
||||
.filter(([entityId]) => entityId.startsWith('light.'))
|
||||
.map(([_entityId, entity]) => ({
|
||||
id: entity.device_id,
|
||||
binding: `device:${entity.device_id}`,
|
||||
is_light: true,
|
||||
}));
|
||||
return {
|
||||
config: { spaces, markers: lightMarkers, settings: { glow_radius_cm: 300 } },
|
||||
...runtime,
|
||||
counts: LARGE_HOUSE_COUNTS,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
|
||||
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
// Golden fixtures must use the same persisted wall-key contract as real plan
|
||||
// data. Arbitrary labels make every configured wall look virtual to the
|
||||
// renderer, which lets a visually ineffective baseline pass unnoticed.
|
||||
const WALL_KEY_PITCH = 1 / 240;
|
||||
export const fixtureWallKey = (a, b) => {
|
||||
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
|
||||
const mx = quantize((a[0] + b[0]) / 2);
|
||||
const my = quantize((a[1] + b[1]) / 2);
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) { dx = 1; dy = 0; }
|
||||
else { dx /= length; dy /= length; }
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const uniqueEdges = (rooms) => {
|
||||
const edges = new Map();
|
||||
for (const room of rooms) {
|
||||
room.poly.forEach((a, index) => {
|
||||
const b = room.poly[(index + 1) % room.poly.length];
|
||||
const forward = `${a.join(',')}/${b.join(',')}`;
|
||||
const reverse = `${b.join(',')}/${a.join(',')}`;
|
||||
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
|
||||
});
|
||||
}
|
||||
return [...edges.values()];
|
||||
};
|
||||
|
||||
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
|
||||
key: fixtureWallKey(edge.a, edge.b),
|
||||
a: edge.a,
|
||||
b: edge.b,
|
||||
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
|
||||
}));
|
||||
|
||||
const geometryRooms = [
|
||||
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
|
||||
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
|
||||
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
|
||||
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
|
||||
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
|
||||
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
|
||||
];
|
||||
|
||||
const lightingRooms = [
|
||||
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
|
||||
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
|
||||
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
|
||||
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
|
||||
];
|
||||
|
||||
const geometrySpace = {
|
||||
id: 'golden-geometry',
|
||||
title: 'Geometry matrix',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', show_borders: true, show_names: true,
|
||||
room_color: '#2d8fce', room_opacity: 0.16,
|
||||
},
|
||||
rooms: geometryRooms,
|
||||
walls: wallsFor('geo', geometryRooms, (edge, index) => {
|
||||
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
|
||||
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
|
||||
return index % 4 === 0 ? 10 : 15;
|
||||
}),
|
||||
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
|
||||
openings: [
|
||||
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
|
||||
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
|
||||
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
|
||||
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
|
||||
],
|
||||
partitions: [
|
||||
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
|
||||
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
|
||||
],
|
||||
wall_columns: [
|
||||
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
|
||||
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
|
||||
],
|
||||
decor: [
|
||||
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
|
||||
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
|
||||
],
|
||||
};
|
||||
|
||||
const lightingSpace = {
|
||||
id: 'golden-lighting',
|
||||
title: 'Lighting matrix',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', glow_enabled: true, show_borders: true, show_names: true,
|
||||
north_deg: 0, sun_rays: true, bg_mode: 'static',
|
||||
},
|
||||
rooms: lightingRooms,
|
||||
walls: wallsFor('light', lightingRooms, (edge) => (
|
||||
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
|
||||
)),
|
||||
openings: [
|
||||
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
|
||||
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
|
||||
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
|
||||
],
|
||||
partitions: [
|
||||
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
|
||||
],
|
||||
wall_columns: [
|
||||
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
|
||||
],
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = () => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 24 },
|
||||
},
|
||||
};
|
||||
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
|
||||
// entities without unique_id may have a live state without a registry row.
|
||||
// The production projection must preserve them.
|
||||
const layout = {};
|
||||
const areas = Object.fromEntries(
|
||||
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
);
|
||||
const add = (id, domain, area, x, y, state, attributes = {}) => {
|
||||
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
|
||||
devices[id] = {
|
||||
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
|
||||
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
|
||||
entry_type: null, via_device_id: null, disabled_by: null,
|
||||
};
|
||||
entities[entityId] = {
|
||||
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
|
||||
config_entry_id: 'golden_entry', disabled_by: null,
|
||||
};
|
||||
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
|
||||
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
|
||||
};
|
||||
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
|
||||
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
|
||||
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
|
||||
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
|
||||
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
|
||||
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
|
||||
});
|
||||
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
});
|
||||
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
|
||||
device_class: 'temperature', unit_of_measurement: '°C',
|
||||
});
|
||||
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
return { devices, entities, states, layout, areas };
|
||||
};
|
||||
|
||||
export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
spaces: 2,
|
||||
rooms: geometryRooms.length + lightingRooms.length,
|
||||
openings: geometrySpace.openings.length + lightingSpace.openings.length,
|
||||
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
config: {
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
// A persisted marker is part of the fixture contract for scenarios that
|
||||
// override per-source Glow controls. The device/layout alone are not a
|
||||
// saved marker configuration and must not be silently treated as one.
|
||||
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
|
||||
settings: {
|
||||
glow_radius_cm: 360,
|
||||
north_deg: 0,
|
||||
sun_rays: true,
|
||||
bg_mode: 'static',
|
||||
fill_colors: {
|
||||
glow_base: { c: '#1b2530', a: 0.78 },
|
||||
glow_light: { c: '#ffd27b', a: 0.70 },
|
||||
wall_fill: { c: '#d7d9dc', a: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(),
|
||||
counts: VISUAL_MATRIX_COUNTS,
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
# HP-QA-01 golden images
|
||||
|
||||
This layer catches visual regressions that DOM smokes cannot: wall seams and
|
||||
end caps, thick opening tunnels, Glow/sun clipping, hover contours, editor
|
||||
chrome, the open contextual tray at wide/medium/narrow widths in English and
|
||||
Russian (selection, tool options, group and palette), long dialog
|
||||
titles/footers, mobile clipping, themes and zoom/remount. The desktop and
|
||||
mobile device-dialog scenarios use a real light and make the complete
|
||||
source-role, Glow colour, brightness and radius controls visible; capturing
|
||||
only the top of that section fails the scenario before comparison.
|
||||
The Glow matrix also keeps one deliberately opaque custom-fill scene with a
|
||||
single source and two doorways: it makes hard spill wedges and fully unlit
|
||||
radial spokes visible instead of hiding them under a translucent room fill.
|
||||
|
||||
## Safety contract
|
||||
|
||||
- A build fingerprint embedded by Rollup must match `src/`, Rollup/TypeScript
|
||||
configuration and locked package inputs; stale committed
|
||||
demo bundles fail before the first screenshot.
|
||||
- Chromium, viewport, locale, timezone, colour profile, font rendering,
|
||||
animations and caret are controlled by the runner.
|
||||
- `capture` writes only to ignored `artifacts/golden/`; it never changes a
|
||||
baseline and never claims a missing baseline passed. Any scenario runtime
|
||||
error makes capture fail, including the initial no-baseline CI run.
|
||||
- `verify` requires every image plus a matching matrix manifest and fails on
|
||||
missing/different/error scenarios, browser mismatch or a baseline whose hash
|
||||
no longer matches the reviewed manifest.
|
||||
- `accept` requires `--reviewed`, a complete candidate report and current
|
||||
source fingerprint. It validates the whole set before copying anything and
|
||||
is the only command allowed to update baselines.
|
||||
|
||||
## Workflow
|
||||
|
||||
Build and copy the exact current source first:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run golden:capture
|
||||
```
|
||||
|
||||
Review `artifacts/golden/actual/` and, when existing references are present,
|
||||
`artifacts/golden/diff/`. If every image is intentional:
|
||||
|
||||
```bash
|
||||
npm run golden:accept -- --reviewed
|
||||
npm run golden:verify
|
||||
```
|
||||
|
||||
Never accept images merely to make CI green. A matrix/framing change increments
|
||||
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
|
||||
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
|
||||
Future updates must still use the `golden-images` artifact produced by the Linux
|
||||
CI job as the review set: desktop font rasterisation can differ from the CI
|
||||
environment even with the same pinned Chromium. Pass its unpacked root via
|
||||
`--from=...` when accepting it locally.
|
||||
|
||||
Scenarios may also declare a semantic pixel region (for example, a receiving
|
||||
room that must contain warm light). `golden:capture` and `golden:verify` reject
|
||||
the capture before baseline comparison when that visual precondition is empty;
|
||||
a reviewed but meaningless PNG therefore cannot become the contract.
|
||||
@@ -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 @@
|
||||
|
||||
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 15,
|
||||
"acceptedAt": "2026-08-12T18:55:55.794Z",
|
||||
"sourceFingerprint": "6f00b83152c35deb5c63a73ae35a22177199fb8bdaf178abe676a6dfb133d2a9",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"geometry-view-dark-fit": "a538deed6141b98b3e396d7da1024b18aee345312edd7954ad458751f08f48a3",
|
||||
"geometry-view-light-fit": "0c57dee930f30a1f9c16e4e704675156f57a7623a4edf839c14a4fecbf12881c",
|
||||
"geometry-plan-editor-dark": "6b06213324c5ff50c7176451307a33d8ac31ee636c96698f8efe61e8db763bae",
|
||||
"opening-placement-door-thick-wall-dark": "24f472818f2246eeaa6568648ca4a6c42d9d84723fbcdd879435253399e2995d",
|
||||
"geometry-devices-editor-dark": "c850e83f1af747f063b895109fe26d6a5804356fab505b0976073c28ad146104",
|
||||
"geometry-decor-editor-dark": "44a95fd0b397c2729fea65c750fedcb11df10042ae198f3690151ead077c84e1",
|
||||
"tray-wide-selection-en": "468ac6acfd7fcdcbfa947e032843ff117a32ae01b4ec30937a6cddecff534504",
|
||||
"tray-wide-tool-ru": "388e03d7bf7a2391d0581e8446d0692048b9792e80bf3b9dd9bca86222fe9418",
|
||||
"tray-medium-group-en": "190d5c356461ad2aa8b63b132416e8958a285047f8dea92301213678c7ac5a92",
|
||||
"tray-medium-selection-ru": "ab91e88583b2863305434ce2775e31328638414671ff0a4f8ee82c8274c04405",
|
||||
"tray-narrow-palette-en": "fb63483e7101d457d7fb1c83ae50435410ed797771adcbfde14dc2798a000ce1",
|
||||
"tray-narrow-tool-ru": "60ce3c75de88b72fdb5d3fe7a16790184a8265c74f0f97e5be4fd83dbe0259fd",
|
||||
"geometry-diagonal-45-opening-dark": "3736a75163d47a71f60c45cd554d604f27cfbc81119b5b55e8134da61a2a0f0a",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
|
||||
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
|
||||
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
|
||||
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
|
||||
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
|
||||
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
|
||||
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
|
||||
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
|
||||
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
|
||||
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
|
||||
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
|
||||
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "b70e385829a4fe45a77f5e1c3f8af4e18efec5282fb9594b26fab08c1f113d52",
|
||||
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
|
||||
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
|
||||
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
|
||||
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
|
||||
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "16d2859d1ed3715c1c4d3d451a8428c37e91ba00da17272c59cf83420a7b6c31",
|
||||
"backup-full-preview-desktop-en": "7f12943d1027c89f6fe46978fa1f4e1bcdbf85a1216281d850d467e68f52cbda",
|
||||
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 321 KiB |
@@ -0,0 +1,517 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
|
||||
const themeVars = {
|
||||
dark: {
|
||||
'--primary-color': '#3ea6ff', '--primary-text-color': '#e6e7eb',
|
||||
'--secondary-text-color': '#9aa4ad', '--card-background-color': '#202126',
|
||||
'--ha-card-background': '#202126', '--divider-color': '#3a3d45',
|
||||
},
|
||||
light: {
|
||||
'--primary-color': '#0b73b8', '--primary-text-color': '#202124',
|
||||
'--secondary-text-color': '#5f6368', '--card-background-color': '#ffffff',
|
||||
'--ha-card-background': '#ffffff', '--divider-color': '#d7d9de',
|
||||
},
|
||||
};
|
||||
|
||||
async function stableEnvironment(page, scenario) {
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
|
||||
await page.evaluate(({ variables, theme }) => {
|
||||
let style = document.getElementById('hp-golden-stability');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'hp-golden-stability';
|
||||
style.textContent = `
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
caret-color: transparent !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
html, body { width: 100%; min-height: 100%; overflow: hidden; }
|
||||
body { background: var(--hp-golden-page-bg) !important;
|
||||
font-family: Arial, sans-serif !important; }
|
||||
#host { width: min(100%, 1120px) !important; margin: 0 auto !important;
|
||||
padding: 8px !important; box-sizing: border-box !important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
for (const [name, value] of Object.entries(variables))
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
document.documentElement.style.setProperty(
|
||||
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
|
||||
);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
|
||||
}
|
||||
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
export function prepareGoldenFixture(scenario) {
|
||||
const fixture = fixtureFor(scenario.fixture);
|
||||
const requireSpace = () => {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
return space;
|
||||
};
|
||||
if (scenario.deviceName) {
|
||||
if (!scenario.deviceId || !fixture.devices?.[scenario.deviceId])
|
||||
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
|
||||
};
|
||||
}
|
||||
if (scenario.extraOpenings?.length) {
|
||||
const space = requireSpace();
|
||||
const known = new Set((space.openings || []).map((opening) => opening.id));
|
||||
for (const opening of scenario.extraOpenings) {
|
||||
if (!opening?.id || known.has(opening.id))
|
||||
throw new Error(`golden extraOpening has missing/duplicate id: ${opening?.id || '<empty>'}`);
|
||||
if (!['door', 'window', 'gate'].includes(opening.type))
|
||||
throw new Error(`golden extraOpening has unknown type: ${opening.type}`);
|
||||
known.add(opening.id);
|
||||
}
|
||||
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
const space = requireSpace();
|
||||
const opening = (space.openings || []).find(
|
||||
(item) => item.id === scenario.openingGeometry.id,
|
||||
);
|
||||
if (!opening || opening.type !== scenario.openingGeometry.type
|
||||
|| Math.abs(Number(opening.angle) - scenario.openingGeometry.angle) > 0.001) {
|
||||
throw new Error(
|
||||
`golden openingGeometry references a missing/mismatched opening: `
|
||||
+ `${scenario.openingGeometry.id}`,
|
||||
);
|
||||
}
|
||||
// This scenario must not remain byte-identical to the generic geometry
|
||||
// capture: isolate the intended diagonal symbol in the rendered fixture.
|
||||
space.openings = [opening];
|
||||
}
|
||||
if (scenario.wallReplacements?.length) {
|
||||
const space = requireSpace();
|
||||
const samePoint = (a, b) => Array.isArray(a) && Array.isArray(b)
|
||||
&& Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
|
||||
for (const replacement of scenario.wallReplacements) {
|
||||
const index = (space.walls || []).findIndex((wall) => (
|
||||
samePoint(wall.a, replacement.match?.a) && samePoint(wall.b, replacement.match?.b)
|
||||
) || (
|
||||
samePoint(wall.a, replacement.match?.b) && samePoint(wall.b, replacement.match?.a)
|
||||
));
|
||||
if (index < 0 || !replacement.segments?.length)
|
||||
throw new Error(`golden wallReplacement cannot find a valid wall in ${space.id}`);
|
||||
space.walls.splice(index, 1, ...structuredClone(replacement.segments));
|
||||
}
|
||||
}
|
||||
if (scenario.hideOpenings) {
|
||||
const space = requireSpace();
|
||||
space.settings = { ...(space.settings || {}), hide_openings: true };
|
||||
}
|
||||
if (scenario.roomGlow) {
|
||||
const space = requireSpace();
|
||||
const unknown = new Set(Object.keys(scenario.roomGlow));
|
||||
for (const room of space.rooms) {
|
||||
if (!(room.id in scenario.roomGlow)) continue;
|
||||
unknown.delete(room.id);
|
||||
room.settings = { ...(room.settings || {}), glow: scenario.roomGlow[room.id] };
|
||||
}
|
||||
if (unknown.size) throw new Error(`golden roomGlow references missing room(s): ${[...unknown].join(', ')}`);
|
||||
}
|
||||
if (scenario.roomCustomFill) {
|
||||
const space = requireSpace();
|
||||
const unknown = new Set(Object.keys(scenario.roomCustomFill));
|
||||
for (const room of space.rooms) {
|
||||
if (!(room.id in scenario.roomCustomFill)) continue;
|
||||
unknown.delete(room.id);
|
||||
room.settings = { ...(room.settings || {}), custom_fill: scenario.roomCustomFill[room.id] };
|
||||
}
|
||||
if (unknown.size)
|
||||
throw new Error(`golden roomCustomFill references missing room(s): ${[...unknown].join(', ')}`);
|
||||
}
|
||||
if (scenario.allLightsOff) {
|
||||
for (const [entityId, state] of Object.entries(fixture.states || {})) {
|
||||
if (!entityId.startsWith('light.')) continue;
|
||||
fixture.states[entityId] = { ...state, state: 'off' };
|
||||
}
|
||||
}
|
||||
if (scenario.stateOverrides) {
|
||||
for (const [entityId, override] of Object.entries(scenario.stateOverrides)) {
|
||||
const current = fixture.states?.[entityId];
|
||||
if (!current) throw new Error(`golden stateOverride references missing entity: ${entityId}`);
|
||||
fixture.states[entityId] = {
|
||||
...current,
|
||||
...structuredClone(override),
|
||||
attributes: { ...(current.attributes || {}), ...(override.attributes || {}) },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (scenario.markerOverrides) {
|
||||
const ids = new Set(scenario.markerOverrides.map((marker) => marker.id));
|
||||
// Runtime devices without explicit marker settings are still valid saved
|
||||
// marker targets. A visual scenario may materialize their first setting,
|
||||
// just like the real device dialog does on save.
|
||||
const known = new Set([
|
||||
...(fixture.config.markers || []).map((marker) => marker.id),
|
||||
...Object.keys(fixture.devices || {}),
|
||||
]);
|
||||
const missing = [...ids].filter((id) => !known.has(id));
|
||||
if (missing.length) throw new Error(`golden markerOverrides reference missing marker(s): ${missing.join(', ')}`);
|
||||
fixture.config.markers = [
|
||||
...(fixture.config.markers || []).filter((marker) => !ids.has(marker.id)),
|
||||
...structuredClone(scenario.markerOverrides),
|
||||
];
|
||||
}
|
||||
if (scenario.layoutOverrides) {
|
||||
const missing = Object.keys(scenario.layoutOverrides).filter((id) => !(id in (fixture.layout || {})));
|
||||
if (missing.length) throw new Error(`golden layoutOverrides reference missing item(s): ${missing.join(', ')}`);
|
||||
fixture.layout = { ...(fixture.layout || {}), ...structuredClone(scenario.layoutOverrides) };
|
||||
}
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
export async function prepareGoldenScenario(page, scenario) {
|
||||
await stableEnvironment(page, scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
return page.evaluate(async ({ fixture, scenario }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error(`golden scenario timed out: ${scenario.id}`);
|
||||
await wait(15);
|
||||
}
|
||||
};
|
||||
const settleMode = async (card) => {
|
||||
await until(() => !card._modeTransitionBusy);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
window.__goldenCard?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
const cardConfig = {
|
||||
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
|
||||
language: scenario.language || 'en',
|
||||
};
|
||||
const hassFor = () => ({
|
||||
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
|
||||
user: { id: 'golden', name: 'Golden fixture', is_admin: true },
|
||||
devices: fixture.devices || {}, entities: fixture.entities || {},
|
||||
areas: fixture.areas || {}, states: fixture.states || {},
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices || {});
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities || {});
|
||||
if (message.type === 'config_entries/get')
|
||||
return [{ entry_id: 'golden_entry', domain: 'houseplan_golden', title: 'Golden fixture' }];
|
||||
if (message.type === 'manifest/list')
|
||||
return [{ domain: 'houseplan_golden', name: 'House Plan Golden' }];
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
const mount = async () => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor();
|
||||
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
|
||||
await card.updateComplete;
|
||||
const expectedDevices = Object.keys(fixture.devices || {}).length;
|
||||
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
|
||||
await until(() => card._booting === false);
|
||||
await frame();
|
||||
return card;
|
||||
};
|
||||
|
||||
let card = await mount();
|
||||
if (scenario.warmRemount) {
|
||||
card.remove();
|
||||
await wait(0);
|
||||
card = await mount();
|
||||
}
|
||||
window.__goldenCard = card;
|
||||
if (scenario.space && card._space !== scenario.space) {
|
||||
card._pickSpace(scenario.space);
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.mode) {
|
||||
card._setMode(scenario.mode);
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
card._applyView(scenario.zoom, 500, 500);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.openingPreview) {
|
||||
const { type, pointer } = scenario.openingPreview;
|
||||
if (!['window', 'door', 'gate'].includes(type)
|
||||
|| !Array.isArray(pointer) || pointer.length !== 2
|
||||
|| !pointer.every(Number.isFinite)) {
|
||||
throw new Error(`invalid golden openingPreview: ${scenario.id}`);
|
||||
}
|
||||
card._activateOpeningPlacement(type);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
// Exercise the production pointer path after the toolbar update has
|
||||
// settled. Writing `_cursorPt` before that update is racy: replacing the
|
||||
// stage under Chromium's real pointer legitimately emits pointerleave
|
||||
// and clears the preview before capture.
|
||||
const svgRoot = card.renderRoot.querySelector('.stage svg');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
|
||||
.matrixTransform(svgRoot.getScreenCTM());
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse',
|
||||
clientX: screen.x, clientY: screen.y,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const preview = card.renderRoot.querySelector(`.opening-preview[data-kind="${type}"]`);
|
||||
if (!preview || !preview.querySelector('.op-leaf')) {
|
||||
const intervals = card._openingPlacementIntervalsCache?.value || [];
|
||||
const nearest = intervals.map((interval) => {
|
||||
const [px, py] = card._cursorPt || [0, 0];
|
||||
const [ax, ay] = interval.a, [bx, by] = interval.b;
|
||||
const dx = bx - ax, dy = by - ay, length2 = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2));
|
||||
return {
|
||||
a: interval.a, b: interval.b, cm: interval.cm, open: interval.open,
|
||||
kind: interval.kind,
|
||||
distance: Math.hypot(px - (ax + dx * t), py - (ay + dy * t)),
|
||||
};
|
||||
}).sort((a, b) => a.distance - b.distance).slice(0, 3);
|
||||
throw new Error(`golden opening preview did not render: ${scenario.id}; `
|
||||
+ `cursor=${JSON.stringify(card._cursorPt)} nearest=${JSON.stringify(nearest)}`);
|
||||
}
|
||||
}
|
||||
if (scenario.editorTray) {
|
||||
let expectedKind = '';
|
||||
if (scenario.editorTray === 'plan-selection') {
|
||||
card._physicalSel = { kind: 'partition', id: 'geo-partition-h' };
|
||||
expectedKind = 'selection';
|
||||
} else if (scenario.editorTray === 'plan-tool') {
|
||||
card._physicalSel = null;
|
||||
card._tool = 'draw';
|
||||
expectedKind = 'tool';
|
||||
} else if (scenario.editorTray === 'decor-selection') {
|
||||
card._decorTool = 'select';
|
||||
card._decorSel = 'geo-axis-h';
|
||||
expectedKind = 'selection';
|
||||
} else if (scenario.editorTray === 'decor-tool') {
|
||||
card._decorSel = null;
|
||||
card._decorTool = 'line';
|
||||
expectedKind = 'tool';
|
||||
} else if (scenario.editorTray === 'furniture-palette') {
|
||||
card._decorSel = null;
|
||||
card._furnPalette = null;
|
||||
card._editorSecondary.openPalette();
|
||||
card._decorTool = 'furniture';
|
||||
expectedKind = 'palette';
|
||||
} else if (scenario.editorTray === 'group') {
|
||||
const group = {
|
||||
id: 'golden-group', label: 'Arrange', icon: 'mdi:shape-outline', items: [
|
||||
{ id: 'align', label: 'Align', icon: 'mdi:format-align-center', role: 'command', invoke: () => undefined },
|
||||
{ id: 'distribute', label: 'Distribute', icon: 'mdi:format-horizontal-align-center', role: 'command', invoke: () => undefined },
|
||||
],
|
||||
};
|
||||
Object.defineProperty(card, '_editorToolbarGroups', {
|
||||
configurable: true,
|
||||
get: () => [group],
|
||||
});
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._editorSecondary.toggleGroup(card._editorToolbarGroups, group.id);
|
||||
expectedKind = 'group';
|
||||
} else {
|
||||
throw new Error(`unknown golden editor tray: ${scenario.editorTray}`);
|
||||
}
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const tray = card.renderRoot.querySelector(
|
||||
`.editor-secondary-host.open .editor-secondary.kind-${expectedKind}`,
|
||||
);
|
||||
if (!tray) throw new Error(`golden editor tray did not open: ${scenario.editorTray}`);
|
||||
}
|
||||
if (scenario.hoverRoom) {
|
||||
const room = card._spaceModel().rooms.find((item) => item.id === scenario.hoverRoom);
|
||||
if (!room) throw new Error(`golden hover room missing: ${scenario.hoverRoom}`);
|
||||
card._hoverRoom = { space: card._space, room };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.dialog === 'device') {
|
||||
card._setMode('devices');
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
const device = card._devices.find((item) => item.id === scenario.deviceId);
|
||||
if (!device) throw new Error(`golden device missing: ${scenario.deviceId}`);
|
||||
card._openMarkerDialog(device);
|
||||
await card.updateComplete;
|
||||
if (scenario.deviceLightControls) {
|
||||
card._setMarkerLightRole('always');
|
||||
await card.updateComplete;
|
||||
card._setMarkerGlowMode('fixed');
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const roleGroup = dialog?.querySelector('input[name="marker-light-role"]')?.closest('fieldset');
|
||||
const glowGroup = dialog?.querySelector('input[name="marker-glow-mode"]')?.closest('fieldset');
|
||||
const roleInputs = roleGroup?.querySelectorAll('input[name="marker-light-role"]');
|
||||
const glowInputs = glowGroup?.querySelectorAll('input[name="marker-glow-mode"]');
|
||||
const color = glowGroup?.querySelector('hp-color-opacity');
|
||||
const brightness = glowGroup?.querySelector('input[type="range"]');
|
||||
const radius = dialog?.querySelector('#marker-glow-radius');
|
||||
if (!body || !roleGroup || !glowGroup || roleInputs?.length !== 3 || glowInputs?.length !== 3
|
||||
|| !roleInputs[1]?.checked || !glowInputs[2]?.checked
|
||||
|| !color || color.disabled || !brightness || brightness.disabled || !radius || radius.disabled)
|
||||
throw new Error('golden device light-source controls are incomplete');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const roleRect = roleGroup.getBoundingClientRect();
|
||||
body.scrollTop += roleRect.top - bodyRect.top - 8;
|
||||
await frame();
|
||||
const visibleBody = body.getBoundingClientRect();
|
||||
const visibleRole = roleGroup.getBoundingClientRect();
|
||||
const visibleRadius = radius.getBoundingClientRect();
|
||||
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('golden viewport does not show the complete device light-source controls');
|
||||
}
|
||||
if (scenario.openHelp) {
|
||||
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
|
||||
await help?.updateComplete;
|
||||
const trigger = help?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error(`golden help trigger missing: ${scenario.openHelp}`);
|
||||
trigger.click();
|
||||
await help.updateComplete;
|
||||
await frame();
|
||||
const surface = help.renderRoot?.querySelector('.tooltip:popover-open')
|
||||
|| card.renderRoot.querySelector('hp-dialog')?.renderRoot
|
||||
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
||||
if (trigger.getAttribute('aria-expanded') !== 'true' || !surface?.getBoundingClientRect().width)
|
||||
throw new Error(`golden help surface did not open: ${scenario.openHelp}`);
|
||||
}
|
||||
if (scenario.focusDialogClose) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
await dialog?.updateComplete;
|
||||
dialog?.renderRoot?.querySelector('.close')?.focus();
|
||||
}
|
||||
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
|
||||
const full = scenario.dialog === 'backup-full';
|
||||
card._backupImportDialog = {
|
||||
filename: full ? 'houseplan-full-2026-08-11.json' : 'houseplan-space-ground.json',
|
||||
size: 12345,
|
||||
token: 'golden-token',
|
||||
preview: {
|
||||
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
|
||||
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
|
||||
duplicates: full ? 0 : 2,
|
||||
confirmation_required: full,
|
||||
content: full
|
||||
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
|
||||
: [{ url: 'https://example.test/ground.svg', state: 'external' }],
|
||||
},
|
||||
expectedConfigRev: 1, expectedLayoutRev: 1,
|
||||
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
|
||||
};
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
} else if (scenario.dialog === 'decor-color') {
|
||||
card._setMode('decor');
|
||||
card._decorTool = 'select';
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
const shape = card._decorList.find((item) => item.kind === 'line');
|
||||
if (!shape) throw new Error('golden decor line missing');
|
||||
card._decorShapeDbl(new MouseEvent('dblclick'), shape);
|
||||
await card.updateComplete;
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const picker = dialog?.querySelector('hp-color-opacity');
|
||||
await picker?.updateComplete;
|
||||
const trigger = picker?.renderRoot?.querySelector('.trigger');
|
||||
if (!trigger) throw new Error('golden decor color trigger missing');
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
return {
|
||||
space: card._space,
|
||||
mode: card._mode,
|
||||
devices: card._devices.length,
|
||||
dialog: !!card.renderRoot.querySelector('hp-dialog'),
|
||||
helpOpen: [...card.renderRoot.querySelectorAll('hp-help')]
|
||||
.some((help) => help.renderRoot?.querySelector('.trigger')?.getAttribute('aria-expanded') === 'true'),
|
||||
editorTray: card.renderRoot.querySelector('.editor-secondary-host.open .editor-secondary')
|
||||
?.className || '',
|
||||
...(scenario.sunRayPixels ? { sun: {
|
||||
raw: card.hass?.states?.['sun.sun']?.attributes || null,
|
||||
plan: card._planHass?.states?.['sun.sun']?.attributes || null,
|
||||
render: card._renderPlanHass?.states?.['sun.sun']?.attributes || null,
|
||||
north: card._effNorth(),
|
||||
enabled: card._effSunRays(),
|
||||
editing: card._editing,
|
||||
cachedRays: card._sunRaysCache?.rays?.length || 0,
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario });
|
||||
}
|
||||
|
||||
export async function goldenClip(page, capture) {
|
||||
if (capture === 'page') return null;
|
||||
return page.evaluate((captureKind) => {
|
||||
const card = window.__goldenCard;
|
||||
const target = card?.renderRoot?.querySelector('.stage');
|
||||
if (!target) throw new Error('golden stage capture target missing');
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (captureKind === 'sun-window') {
|
||||
// Stable crop around the exterior window and the first part of its ray:
|
||||
// deliberately excludes room labels and device markers, whose font/icon
|
||||
// rasterisation would add noise unrelated to the visual contract.
|
||||
return {
|
||||
x: Math.max(0, Math.floor(rect.left + rect.width * 0.10)),
|
||||
y: Math.max(0, Math.floor(rect.top + rect.height * 0.02)),
|
||||
width: Math.max(1, Math.ceil(rect.width * 0.35)),
|
||||
height: Math.max(1, Math.ceil(rect.height * 0.25)),
|
||||
};
|
||||
}
|
||||
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) };
|
||||
}, capture);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 15;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
|
||||
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: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
// complete 90 cm door preset while still proving rotation, inner-face
|
||||
// offset and ruler placement on a thick wall.
|
||||
mode: 'plan', openingPreview: { type: 'door', pointer: [0.48, 0.65] },
|
||||
openingPreviewPixels: { minPixels: 150, minChannelDelta: 4 },
|
||||
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',
|
||||
openingGeometry: { id: 'geo-diagonal-window', type: 'window', angle: 45 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-thick-wall-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-filled-tunnel-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#66717c', a: 0.55 }, glowEnabled: false,
|
||||
hideOpenings: true,
|
||||
wallReplacements: [{
|
||||
match: { a: [0.5, 0.1], b: [0.5, 0.88] },
|
||||
segments: [
|
||||
{ key: fixtureWallKey([0.5, 0.1], [0.5, 0.5]), a: [0.5, 0.1], b: [0.5, 0.5], cm: 18 },
|
||||
{ key: fixtureWallKey([0.5, 0.5], [0.5, 0.88]), a: [0.5, 0.5], b: [0.5, 0.88], cm: 32 },
|
||||
],
|
||||
}],
|
||||
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3, dpr2: true },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', glowEnabled: false, hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'device-value-badge-positions-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
glowEnabled: false, sunRays: false,
|
||||
markerOverrides: [
|
||||
{ id: 'golden-light-one', binding: 'device:golden-light-one', value_badge: {
|
||||
enabled: true, source: { kind: 'entity_state', entity_id: 'light.golden_light_one' }, position: 'right',
|
||||
} },
|
||||
{ id: 'golden-light-two', binding: 'device:golden-light-two', value_badge: {
|
||||
enabled: true, source: { kind: 'entity_state', entity_id: 'light.golden_light_two' }, position: 'left',
|
||||
} },
|
||||
{ id: 'golden-presence', binding: 'device:golden-presence', value_badge: {
|
||||
enabled: true, source: { kind: 'entity_state', entity_id: 'binary_sensor.golden_presence' }, position: 'top',
|
||||
} },
|
||||
{ id: 'golden-climate', binding: 'device:golden-climate', value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_attribute', entity_id: 'climate.golden_climate', attribute: 'current_temperature' },
|
||||
position: 'bottom',
|
||||
} },
|
||||
],
|
||||
stateOverrides: { 'climate.golden_climate': { attributes: { lqi: 190 } } },
|
||||
layoutOverrides: {
|
||||
'golden-light-one': { s: 'golden-lighting', x: 0.20, y: 0.32 },
|
||||
'golden-light-two': { s: 'golden-lighting', x: 0.20, y: 0.72 },
|
||||
'golden-presence': { s: 'golden-lighting', x: 0.80, y: 0.68 },
|
||||
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
// The golden screenshot is backed by a second, sun-layer-hidden capture.
|
||||
// A real painted ray must account for enough changed pixels; DOM-only
|
||||
// presence or an accidentally accepted empty baseline is not sufficient.
|
||||
glowEnabled: false, allLightsOff: true,
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: 24 } } },
|
||||
sunRayPixels: { minPixels: 500, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...sunWindow },
|
||||
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'light', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-fill-temp-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-fill-lqi-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'lqi', glowEnabled: false, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-custom-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-opaque-glow-two-doorways-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#3f4854', a: 1 }, glowEnabled: true, sunRays: false,
|
||||
allLightsOff: true,
|
||||
stateOverrides: {
|
||||
'light.golden_light_one': { state: 'on', attributes: { rgb_color: [255, 196, 112], brightness: 255 } },
|
||||
},
|
||||
extraOpenings: [
|
||||
{ id: 'light-door-second', type: 'door', x: 0.50, y: 0.32, angle: 90, length: 0.13 },
|
||||
],
|
||||
layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },
|
||||
// The golden is protection only if the receiving half actually contains
|
||||
// rendered light. A data-only check once let a visually empty baseline pass.
|
||||
warmPixelRegion: { x: 0.5, y: 0, w: 0.5, h: 1, minPixels: 2500, minRedBlueDelta: 25 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-custom-glow-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
roomCustomFill: { 'light-right': { c: '#8f5f48', a: 0.56 } },
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-no-sources-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, allLightsOff: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-temp-glow-room-override-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true, roomGlow: { 'light-left': true, 'light-right': false },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-manual-auto-spill-overlap-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'temp', glowEnabled: true,
|
||||
markerOverrides: [{
|
||||
id: 'golden-light-two', binding: 'device:golden-light-two',
|
||||
glow_color: { c: '#3a8fff', bri: 0.35 }, glow_radius_cm: 420,
|
||||
}],
|
||||
layoutOverrides: { 'golden-light-two': { s: 'golden-lighting', x: 0.39, y: 0.57 } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-over-glow-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 2.5, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
warmRemount: true, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'device-dialog-desktop-en', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Living room light controller with an intentionally long title',
|
||||
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
|
||||
focusDialogClose: true, language: 'en', theme: 'dark', viewport: { width: 1180, height: 1200 }, ...page },
|
||||
{ id: 'device-dialog-mobile-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
|
||||
language: 'ru', theme: 'light', viewport: { width: 760, height: 900 }, ...page },
|
||||
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'decor-color', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-full', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-space', language: 'ru', theme: 'light',
|
||||
viewport: { width: 390, height: 820 }, ...page },
|
||||
]);
|
||||
@@ -0,0 +1,25 @@
|
||||
// The name must NOT end with `manifest.json`: the HACS submission check globs
|
||||
// `*manifest.json` over the whole clone of the default branch and refuses a
|
||||
// repository with more than one match (test/repo-hygiene.test.mjs).
|
||||
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
|
||||
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
|
||||
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
||||
if (mode === 'verify' && scenarioFilter)
|
||||
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
||||
};
|
||||
|
||||
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
||||
* entry may survive after a scenario is removed or renamed. */
|
||||
export const goldenScenarioSetsMatch = (expected, indexed, baselineFiles) => {
|
||||
const normalized = (values) => [...new Set(values)].sort();
|
||||
const wanted = normalized(expected);
|
||||
return JSON.stringify(normalized(indexed)) === JSON.stringify(wanted)
|
||||
&& JSON.stringify(normalized(baselineFiles)) === JSON.stringify(wanted);
|
||||
};
|
||||
|
||||
export const goldenRunFailed = (mode, manifestValid, results) => {
|
||||
if (results.some((result) => result.status === 'error')) return true;
|
||||
return mode === 'verify'
|
||||
&& (!manifestValid || results.some((result) => result.status !== 'passed'));
|
||||
};
|
||||
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
|
||||
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
||||
import {
|
||||
assertGoldenInvocation,
|
||||
GOLDEN_BASELINE_MANIFEST,
|
||||
goldenRunFailed,
|
||||
goldenScenarioSetsMatch,
|
||||
} from './policy.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
|
||||
const scenarioFilter = process.argv.find((arg) => arg.startsWith('--scenario='))?.slice(11) || '';
|
||||
assertGoldenInvocation(mode, scenarioFilter);
|
||||
const scenarios = scenarioFilter
|
||||
? GOLDEN_SCENARIOS.filter((scenario) => scenario.id === scenarioFilter)
|
||||
: GOLDEN_SCENARIOS;
|
||||
if (!scenarios.length) throw new Error(`unknown golden scenario: ${scenarioFilter}`);
|
||||
|
||||
const artifactRoot = resolve(ROOT, 'artifacts/golden');
|
||||
const actualRoot = resolve(artifactRoot, 'actual');
|
||||
const diffRoot = resolve(artifactRoot, 'diff');
|
||||
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
||||
mkdirSync(actualRoot, { recursive: true });
|
||||
mkdirSync(diffRoot, { recursive: true });
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
async function comparePng(page, actual, baseline, threshold) {
|
||||
return page.evaluate(async ({ actual64, baseline64, threshold }) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const [actualImage, baselineImage] = await Promise.all([decode(actual64), decode(baseline64)]);
|
||||
if (actualImage.width !== baselineImage.width || actualImage.height !== baselineImage.height) {
|
||||
return {
|
||||
dimensionsMatch: false,
|
||||
actualSize: [actualImage.width, actualImage.height],
|
||||
baselineSize: [baselineImage.width, baselineImage.height],
|
||||
differingPixels: null,
|
||||
diffRatio: 1,
|
||||
maxObservedDelta: 255,
|
||||
passed: false,
|
||||
diffPngBase64: null,
|
||||
};
|
||||
}
|
||||
const width = actualImage.width;
|
||||
const height = actualImage.height;
|
||||
const canvas = document.createElement('canvas');
|
||||
const baselineCanvas = document.createElement('canvas');
|
||||
const diffCanvas = document.createElement('canvas');
|
||||
for (const item of [canvas, baselineCanvas, diffCanvas]) {
|
||||
item.width = width;
|
||||
item.height = height;
|
||||
}
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const baselineContext = baselineCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const diffContext = diffCanvas.getContext('2d');
|
||||
context.drawImage(actualImage, 0, 0);
|
||||
baselineContext.drawImage(baselineImage, 0, 0);
|
||||
const actualData = context.getImageData(0, 0, width, height).data;
|
||||
const baselineData = baselineContext.getImageData(0, 0, width, height).data;
|
||||
const diff = diffContext.createImageData(width, height);
|
||||
let differingPixels = 0;
|
||||
let maxObservedDelta = 0;
|
||||
for (let offset = 0; offset < actualData.length; offset += 4) {
|
||||
let delta = 0;
|
||||
for (let channel = 0; channel < 4; channel++)
|
||||
delta = Math.max(delta, Math.abs(actualData[offset + channel] - baselineData[offset + channel]));
|
||||
maxObservedDelta = Math.max(maxObservedDelta, delta);
|
||||
if (delta > threshold.maxChannelDelta) {
|
||||
differingPixels++;
|
||||
diff.data[offset] = 255;
|
||||
diff.data[offset + 1] = 0;
|
||||
diff.data[offset + 2] = 180;
|
||||
diff.data[offset + 3] = 255;
|
||||
} else {
|
||||
const gray = Math.round((actualData[offset] + actualData[offset + 1] + actualData[offset + 2]) / 9);
|
||||
diff.data[offset] = gray;
|
||||
diff.data[offset + 1] = gray;
|
||||
diff.data[offset + 2] = gray;
|
||||
diff.data[offset + 3] = 90;
|
||||
}
|
||||
}
|
||||
diffContext.putImageData(diff, 0, 0);
|
||||
const diffRatio = differingPixels / (width * height);
|
||||
return {
|
||||
dimensionsMatch: true,
|
||||
actualSize: [width, height],
|
||||
baselineSize: [width, height],
|
||||
differingPixels,
|
||||
diffRatio,
|
||||
maxObservedDelta,
|
||||
passed: diffRatio <= threshold.maxDiffRatio,
|
||||
diffPngBase64: differingPixels
|
||||
? diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length)
|
||||
: null,
|
||||
};
|
||||
}, {
|
||||
actual64: actual.toString('base64'),
|
||||
baseline64: baseline.toString('base64'),
|
||||
threshold,
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture the identical visual frame with only the rendered sun-ray SVG
|
||||
* hidden. Comparing this control frame with the reviewed golden proves that
|
||||
* the layer changes real browser pixels, not merely that its DOM exists. */
|
||||
async function captureWithoutSunRays(page, screenshotOptions) {
|
||||
const layerState = await page.evaluate(async () => {
|
||||
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
||||
if (!layer) throw new Error('semantic golden sun layer is missing');
|
||||
const shapes = layer.querySelectorAll('path, polygon').length;
|
||||
if (!shapes) throw new Error('semantic golden sun layer has no painted shapes');
|
||||
const previous = layer.style.visibility;
|
||||
layer.style.visibility = 'hidden';
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return { previous, shapes };
|
||||
});
|
||||
try {
|
||||
return { png: await page.screenshot(screenshotOptions), shapes: layerState.shapes };
|
||||
} finally {
|
||||
await page.evaluate(async (previous) => {
|
||||
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
||||
if (layer) layer.style.visibility = previous;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, layerState.previous);
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture a control frame with only the transient opening symbol hidden.
|
||||
* Comparing it with the actual frame proves the preview paints browser pixels;
|
||||
* the smoke test separately locks its DOM order above the wall body. */
|
||||
async function captureWithoutOpeningPreview(page, screenshotOptions) {
|
||||
const layerState = await page.evaluate(async () => {
|
||||
const card = window.__goldenCard;
|
||||
const parts = [...(card?.renderRoot?.querySelectorAll(
|
||||
'.opening-preview, .opening-preview-dot',
|
||||
) || [])];
|
||||
if (!parts.length) throw new Error('semantic golden opening preview is missing');
|
||||
const previous = parts.map((part) => part.style.visibility);
|
||||
for (const part of parts) part.style.visibility = 'hidden';
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return { previous, count: parts.length };
|
||||
});
|
||||
try {
|
||||
return { png: await page.screenshot(screenshotOptions), parts: layerState.count };
|
||||
} finally {
|
||||
await page.evaluate(async (previous) => {
|
||||
const card = window.__goldenCard;
|
||||
const parts = [...(card?.renderRoot?.querySelectorAll(
|
||||
'.opening-preview, .opening-preview-dot',
|
||||
) || [])];
|
||||
parts.forEach((part, index) => { part.style.visibility = previous[index] || ''; });
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, layerState.previous);
|
||||
}
|
||||
}
|
||||
|
||||
async function countChangedPixels(page, actual, control, spec) {
|
||||
return page.evaluate(async ({ actual64, control64, minChannelDelta }) => {
|
||||
const decode = async (base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
};
|
||||
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
|
||||
if (actualImage.width !== controlImage.width || actualImage.height !== controlImage.height)
|
||||
throw new Error('semantic golden control frame has different dimensions');
|
||||
const canvas = document.createElement('canvas');
|
||||
const controlCanvas = document.createElement('canvas');
|
||||
canvas.width = controlCanvas.width = actualImage.width;
|
||||
canvas.height = controlCanvas.height = actualImage.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(actualImage, 0, 0);
|
||||
controlContext.drawImage(controlImage, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const controlPixels = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let changed = 0;
|
||||
let maxDelta = 0;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
const delta = Math.max(
|
||||
Math.abs(pixels[offset] - controlPixels[offset]),
|
||||
Math.abs(pixels[offset + 1] - controlPixels[offset + 1]),
|
||||
Math.abs(pixels[offset + 2] - controlPixels[offset + 2]),
|
||||
);
|
||||
maxDelta = Math.max(maxDelta, delta);
|
||||
if (delta >= minChannelDelta) changed++;
|
||||
}
|
||||
return { changed, maxDelta, size: [canvas.width, canvas.height] };
|
||||
}, {
|
||||
actual64: actual.toString('base64'),
|
||||
control64: control.toString('base64'),
|
||||
minChannelDelta: spec.minChannelDelta,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert scenario semantics against the actual capture, not only its data.
|
||||
* This protects a reviewed-but-empty baseline from becoming the reference. */
|
||||
async function countWarmPixels(page, png, region) {
|
||||
return page.evaluate(async ({ png64, region }) => {
|
||||
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
||||
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const left = Math.max(0, Math.min(image.width, Math.floor(region.x * image.width)));
|
||||
const top = Math.max(0, Math.min(image.height, Math.floor(region.y * image.height)));
|
||||
const right = Math.max(left, Math.min(image.width, Math.ceil((region.x + region.w) * image.width)));
|
||||
const bottom = Math.max(top, Math.min(image.height, Math.ceil((region.y + region.h) * image.height)));
|
||||
const pixels = context.getImageData(left, top, right - left, bottom - top).data;
|
||||
let warm = 0;
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
if (pixels[offset] - pixels[offset + 2] > region.minRedBlueDelta) warm++;
|
||||
}
|
||||
return { warm, bounds: [left, top, right, bottom] };
|
||||
}, { png64: png.toString('base64'), region });
|
||||
}
|
||||
|
||||
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
|
||||
* glyph pixels, not just an empty surface or a stale open-state flag. */
|
||||
async function countHelpTextPixels(page, png, clip, spec) {
|
||||
return page.evaluate(async ({ png64, clip, spec }) => {
|
||||
const card = window.__goldenCard;
|
||||
const help = card?.renderRoot?.querySelector(`hp-help[data-help-key="${spec.key}"]`);
|
||||
const surface = help?.renderRoot?.querySelector('.tooltip:popover-open')
|
||||
|| card?.renderRoot?.querySelector('hp-dialog')?.renderRoot
|
||||
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
||||
if (!surface) throw new Error(`semantic golden help missing: ${spec.key}`);
|
||||
const bounds = surface.getBoundingClientRect();
|
||||
const match = getComputedStyle(surface).color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
if (!match || match.length !== 3) throw new Error(`semantic golden help has invalid text color: ${spec.key}`);
|
||||
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
||||
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
||||
const originX = clip?.x || 0, originY = clip?.y || 0;
|
||||
const left = Math.max(0, Math.ceil(bounds.left - originX) + 5);
|
||||
const top = Math.max(0, Math.ceil(bounds.top - originY) + 5);
|
||||
const right = Math.min(image.width - 1, Math.floor(bounds.right - originX) - 5);
|
||||
const bottom = Math.min(image.height - 1, Math.floor(bounds.bottom - originY) - 5);
|
||||
let textPixels = 0;
|
||||
for (let y = top; y <= bottom; y++) {
|
||||
for (let x = left; x <= right; x++) {
|
||||
const offset = (y * image.width + x) * 4;
|
||||
const distance = Math.abs(pixels[offset] - match[0])
|
||||
+ Math.abs(pixels[offset + 1] - match[1])
|
||||
+ Math.abs(pixels[offset + 2] - match[2]);
|
||||
if (pixels[offset + 3] > 240 && distance <= 90) textPixels++;
|
||||
}
|
||||
}
|
||||
return { textPixels, bounds: [left, top, right, bottom] };
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. Sample a
|
||||
* narrow strip around local y=0: this crosses the join between both tunnel
|
||||
* half-faces at any opening angle while excluding legitimate outer-profile
|
||||
* steps where adjacent wall intervals have different physical thicknesses. */
|
||||
async function inspectTunnelContinuity(page, png, clip, spec) {
|
||||
return page.evaluate(async ({ png64, clip, spec }) => {
|
||||
const card = window.__goldenCard;
|
||||
const tunnel = card?.renderRoot?.querySelector(
|
||||
`.opening-tunnels[data-layer="data"] [data-hp="opening-tunnel"][data-id="${spec.openingId}"]`,
|
||||
);
|
||||
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
|
||||
const rect = tunnel.getBoundingClientRect();
|
||||
const matrix = tunnel.getScreenCTM();
|
||||
if (!matrix) throw new Error(`semantic golden tunnel has no screen transform: ${spec.openingId}`);
|
||||
const inverse = matrix.inverse();
|
||||
const localBounds = tunnel.getBBox();
|
||||
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
||||
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const originX = clip?.x || 0, originY = clip?.y || 0;
|
||||
// Screenshots may be captured at DPR > 1. DOMRect/clip are CSS pixels,
|
||||
// image coordinates are device pixels, so derive the scale instead of
|
||||
// silently sampling the wrong strip at high DPI.
|
||||
const cssWidth = clip?.width || document.documentElement.clientWidth;
|
||||
const cssHeight = clip?.height || document.documentElement.clientHeight;
|
||||
const scaleX = image.width / Math.max(1, cssWidth);
|
||||
const scaleY = image.height / Math.max(1, cssHeight);
|
||||
const insetX = Math.max(1, Math.round(spec.insetPx * scaleX));
|
||||
const insetY = Math.max(1, Math.round(spec.insetPx * scaleY));
|
||||
const left = Math.max(0, Math.ceil((rect.left - originX) * scaleX) + insetX);
|
||||
const top = Math.max(0, Math.ceil((rect.top - originY) * scaleY) + insetY);
|
||||
const right = Math.min(image.width - 1, Math.floor((rect.right - originX) * scaleX) - insetX);
|
||||
const bottom = Math.min(image.height - 1, Math.floor((rect.bottom - originY) * scaleY) - insetY);
|
||||
if (right - left < 3 || bottom - top < 3)
|
||||
throw new Error(`semantic golden tunnel is too small: ${left},${top},${right},${bottom}`);
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
||||
const rgb = (x, y) => {
|
||||
const offset = (y * image.width + x) * 4;
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
||||
};
|
||||
let maxJump = 0, maxPair = null, samplePairs = 0;
|
||||
const compare = (a, b, x, y, direction) => {
|
||||
const jump = Math.max(
|
||||
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
|
||||
if (jump > maxJump) {
|
||||
maxJump = jump;
|
||||
maxPair = { x, y, direction, a, b };
|
||||
}
|
||||
samplePairs++;
|
||||
};
|
||||
const localXScale = Math.max(1e-6, Math.hypot(matrix.a, matrix.b));
|
||||
const localYScale = Math.max(1e-6, Math.hypot(matrix.c, matrix.d));
|
||||
const endInset = Math.max(1, spec.insetPx) / localXScale;
|
||||
const axisBand = Math.max(1.5, spec.axisBandPx || 2.5) / localYScale;
|
||||
const insideAxisBand = (x, y) => {
|
||||
const cssX = originX + (x + 0.5) / scaleX;
|
||||
const cssY = originY + (y + 0.5) / scaleY;
|
||||
const local = new DOMPoint(cssX, cssY).matrixTransform(inverse);
|
||||
return local.x >= localBounds.x + endInset
|
||||
&& local.x <= localBounds.x + localBounds.width - endInset
|
||||
&& Math.abs(local.y) <= axisBand;
|
||||
};
|
||||
for (let y = top; y <= bottom; y++) {
|
||||
for (let x = left + 1; x <= right; x++) {
|
||||
if (insideAxisBand(x - 1, y) && insideAxisBand(x, y))
|
||||
compare(rgb(x - 1, y), rgb(x, y), x, y, 'horizontal');
|
||||
}
|
||||
}
|
||||
for (let x = left; x <= right; x++) {
|
||||
for (let y = top + 1; y <= bottom; y++) {
|
||||
if (insideAxisBand(x, y - 1) && insideAxisBand(x, y))
|
||||
compare(rgb(x, y - 1), rgb(x, y), x, y, 'vertical');
|
||||
}
|
||||
}
|
||||
if (!samplePairs) throw new Error(`semantic golden tunnel axis strip is empty: ${spec.openingId}`);
|
||||
return {
|
||||
maxJump, maxPair, samplePairs, bounds: [left, top, right, bottom],
|
||||
scale: [scaleX, scaleY],
|
||||
};
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
let baselineManifest = null;
|
||||
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
||||
if (existsSync(baselineManifestPath)) {
|
||||
try { baselineManifest = JSON.parse(readFileSync(baselineManifestPath, 'utf8')); }
|
||||
catch { baselineManifest = { invalid: true }; }
|
||||
}
|
||||
|
||||
const browserArgs = ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'];
|
||||
const browserContext = { locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' };
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1000, height: 900 },
|
||||
1,
|
||||
browserArgs,
|
||||
browserContext,
|
||||
);
|
||||
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(' | ')}`);
|
||||
if (scenario.openingGeometry) {
|
||||
result.openingGeometry = await page.evaluate((expected) => {
|
||||
const card = window.__goldenCard;
|
||||
const opening = card?.renderRoot?.querySelector(
|
||||
`.opening[data-id="${CSS.escape(expected.id)}"]`,
|
||||
);
|
||||
if (!opening) return null;
|
||||
const transform = opening.getAttribute('transform') || '';
|
||||
const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]);
|
||||
const bounds = opening.getBoundingClientRect();
|
||||
return {
|
||||
type: opening.getAttribute('data-kind'), angle,
|
||||
width: bounds.width, height: bounds.height,
|
||||
visibleParts: opening.querySelectorAll('.op-leaf, .op-arc, .op-glass').length,
|
||||
};
|
||||
}, scenario.openingGeometry);
|
||||
const expected = scenario.openingGeometry;
|
||||
const actualOpening = result.openingGeometry;
|
||||
if (!actualOpening || actualOpening.type !== expected.type
|
||||
|| Math.abs(actualOpening.angle - expected.angle) > 0.001
|
||||
|| actualOpening.width <= 0 || actualOpening.height <= 0
|
||||
|| actualOpening.visibleParts <= 0) {
|
||||
throw new Error(
|
||||
`semantic golden opening geometry failed for ${expected.id}: `
|
||||
+ JSON.stringify(actualOpening),
|
||||
);
|
||||
}
|
||||
}
|
||||
const clip = await goldenClip(page, scenario.capture);
|
||||
const screenshotOptions = {
|
||||
...(clip ? { clip } : {}),
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
scale: 'css',
|
||||
};
|
||||
const actual = await page.screenshot(screenshotOptions);
|
||||
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
|
||||
writeFileSync(actualPath, actual);
|
||||
result.actualSha256 = sha256(actual);
|
||||
result.actual = actualPath;
|
||||
if (scenario.sunRayPixels) {
|
||||
const control = await captureWithoutSunRays(page, screenshotOptions);
|
||||
const sample = await countChangedPixels(page, actual, control.png, scenario.sunRayPixels);
|
||||
result.sunRayShapes = control.shapes;
|
||||
result.sunRayChangedPixels = sample.changed;
|
||||
result.sunRayMaxChannelDelta = sample.maxDelta;
|
||||
if (sample.changed < scenario.sunRayPixels.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: sun rays paint ${sample.changed} pixels, expected at least `
|
||||
+ `${scenario.sunRayPixels.minPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.openingPreviewPixels) {
|
||||
const control = await captureWithoutOpeningPreview(page, screenshotOptions);
|
||||
const sample = await countChangedPixels(
|
||||
page, actual, control.png, scenario.openingPreviewPixels,
|
||||
);
|
||||
result.openingPreviewParts = control.parts;
|
||||
result.openingPreviewChangedPixels = sample.changed;
|
||||
result.openingPreviewMaxChannelDelta = sample.maxDelta;
|
||||
if (control.parts < 2) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
|
||||
);
|
||||
}
|
||||
if (sample.changed < scenario.openingPreviewPixels.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening preview paints ${sample.changed} pixels, `
|
||||
+ `expected at least ${scenario.openingPreviewPixels.minPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.warmPixelRegion) {
|
||||
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
|
||||
result.warmPixels = sample.warm;
|
||||
result.warmPixelBounds = sample.bounds;
|
||||
if (sample.warm < scenario.warmPixelRegion.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: ${sample.warm} warm pixels, expected at least `
|
||||
+ `${scenario.warmPixelRegion.minPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.helpTextRegion) {
|
||||
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
|
||||
result.helpTextPixels = sample.textPixels;
|
||||
result.helpPixelBounds = sample.bounds;
|
||||
if (sample.textPixels < scenario.helpTextRegion.minPixels) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: help ${scenario.helpTextRegion.key} contains `
|
||||
+ `${sample.textPixels} text pixels, expected at least ${scenario.helpTextRegion.minPixels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.tunnelContinuity) {
|
||||
const sample = await inspectTunnelContinuity(
|
||||
page, actual, clip, scenario.tunnelContinuity,
|
||||
);
|
||||
result.tunnelMaxChannelJump = sample.maxJump;
|
||||
result.tunnelMaxJumpPair = sample.maxPair;
|
||||
result.tunnelSamplePairs = sample.samplePairs;
|
||||
result.tunnelPixelBounds = sample.bounds;
|
||||
result.tunnelImageScale = sample.scale;
|
||||
if (sample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: opening ${scenario.tunnelContinuity.openingId} `
|
||||
+ `has a ${sample.maxJump}-channel local jump, expected at most `
|
||||
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
||||
);
|
||||
}
|
||||
if (scenario.tunnelContinuity.dpr2) {
|
||||
// A CSS-pixel capture cannot prove that half-device-pixel joins are
|
||||
// clean. Run the same semantic assertion once at DPR 2 without
|
||||
// adding a second reviewed baseline to the matrix.
|
||||
const highDpi = await launch(
|
||||
scenario.viewport, 2, browserArgs, browserContext,
|
||||
);
|
||||
try {
|
||||
await assertFreshDemoBundle(highDpi.page, ROOT);
|
||||
await prepareGoldenScenario(highDpi.page, scenario);
|
||||
const highDpiClip = await goldenClip(highDpi.page, scenario.capture);
|
||||
const highDpiPng = await highDpi.page.screenshot({
|
||||
...(highDpiClip ? { clip: highDpiClip } : {}),
|
||||
animations: 'disabled', caret: 'hide', scale: 'device',
|
||||
});
|
||||
const highDpiSample = await inspectTunnelContinuity(
|
||||
highDpi.page, highDpiPng, highDpiClip, scenario.tunnelContinuity,
|
||||
);
|
||||
result.tunnelDpr2MaxChannelJump = highDpiSample.maxJump;
|
||||
result.tunnelDpr2SamplePairs = highDpiSample.samplePairs;
|
||||
result.tunnelDpr2PixelBounds = highDpiSample.bounds;
|
||||
if (highDpiSample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed at DPR 2: opening ${scenario.tunnelContinuity.openingId} `
|
||||
+ `has a ${highDpiSample.maxJump}-channel local jump, expected at most `
|
||||
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await highDpi.browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
|
||||
result.baseline = baselinePath;
|
||||
if (!existsSync(baselinePath)) {
|
||||
result.status = 'missing-baseline';
|
||||
} else {
|
||||
const baseline = readFileSync(baselinePath);
|
||||
const expectedBaselineSha256 = baselineManifest?.scenarios?.[scenario.id];
|
||||
result.baselineSha256 = sha256(baseline);
|
||||
if (!expectedBaselineSha256 || result.baselineSha256 !== expectedBaselineSha256) {
|
||||
result.status = 'invalid-baseline';
|
||||
result.error = expectedBaselineSha256
|
||||
? 'baseline PNG does not match its reviewed manifest hash'
|
||||
: 'baseline PNG is not listed in the reviewed manifest';
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
continue;
|
||||
}
|
||||
const comparison = await comparePng(page, actual, baseline, scenario.threshold);
|
||||
Object.assign(result, comparison);
|
||||
result.status = comparison.passed ? 'passed' : 'different';
|
||||
if (comparison.diffPngBase64) {
|
||||
const diffPath = resolve(diffRoot, `${scenario.id}.png`);
|
||||
writeFileSync(diffPath, Buffer.from(comparison.diffPngBase64, 'base64'));
|
||||
result.diff = diffPath;
|
||||
}
|
||||
delete result.diffPngBase64;
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
|
||||
const indexedScenarioIds = Object.keys(baselineManifest?.scenarios || {});
|
||||
const baselineScenarioIds = readdirSync(baselineRoot)
|
||||
.filter((name) => name.endsWith('.png'))
|
||||
.map((name) => name.slice(0, -'.png'.length));
|
||||
const manifestValid = !!baselineManifest
|
||||
&& !baselineManifest.invalid
|
||||
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
|
||||
&& baselineManifest.chromium === chromium
|
||||
&& expectedScenarioIds.every((id) => typeof baselineManifest.scenarios?.[id] === 'string')
|
||||
&& goldenScenarioSetsMatch(expectedScenarioIds, indexedScenarioIds, baselineScenarioIds);
|
||||
const report = {
|
||||
schema: 1,
|
||||
mode,
|
||||
generatedAt: new Date().toISOString(),
|
||||
matrixVersion: GOLDEN_MATRIX_VERSION,
|
||||
buildFingerprint,
|
||||
chromium,
|
||||
baselineManifest: baselineManifest ? {
|
||||
present: true,
|
||||
valid: manifestValid,
|
||||
matrixVersion: baselineManifest.matrixVersion ?? null,
|
||||
chromium: baselineManifest.chromium ?? null,
|
||||
} : { present: false, valid: false, matrixVersion: null, chromium: null },
|
||||
results,
|
||||
};
|
||||
writeFileSync(resolve(artifactRoot, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (goldenRunFailed(mode, manifestValid, results)) process.exitCode = 1;
|
||||
@@ -0,0 +1,159 @@
|
||||
# Large-house performance gate
|
||||
|
||||
`benchmark_large_house.mjs` exercises the deterministic `large-house-v1`
|
||||
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
|
||||
40 columns and 500 decor objects on three floors.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
high-tail guard rather than a population estimate:
|
||||
|
||||
- model readiness and first stable render;
|
||||
- space switch, HA state update, pan/zoom and opening the settings dialog;
|
||||
- a shared-wall room-resize preview which is cancelled before persistence;
|
||||
- a twelve-switch navigation cycle;
|
||||
- Long Tasks for every measured window;
|
||||
- heap growth after four additional navigation rounds with forced GC;
|
||||
- hot-cache size and growth after the same warmed cycles.
|
||||
|
||||
Every report is tied to the source fingerprint embedded by Rollup. A stale
|
||||
bundle is a hard failure.
|
||||
|
||||
## CI contracts
|
||||
|
||||
Ordinary pushes, pull requests and prereleases use the blocking
|
||||
`performance_smoke` job in `validate.yml`. It builds only the candidate and
|
||||
measures the heaviest 60-source `large-house-glow-overlay-v1` state after one
|
||||
warm-up, with three recorded samples. `compare.mjs --absolute-only` enforces the
|
||||
reviewed hard timing, Long Task, heap, cache and rendered-device ceilings from
|
||||
`budgets-glow-smoke.json`; it deliberately makes no noisy base-relative claim.
|
||||
This is a catastrophic-regression guard, not a performance trend detector.
|
||||
|
||||
The dedicated `performance.yml` workflow is the full comparison. It runs on
|
||||
every `main` promotion, weekly and on manual dispatch for an important beta or
|
||||
performance-sensitive change. It checks out the candidate and its base SHA,
|
||||
builds both, and runs them sequentially with the same Node.js 22 process
|
||||
family, pinned Playwright Chromium and hosted runner. `compare.mjs` then
|
||||
applies two limits:
|
||||
|
||||
1. a relative regression allowance against the base-SHA report;
|
||||
2. an absolute safety ceiling from `budgets.json`.
|
||||
|
||||
The tighter limit wins. The absolute values are catastrophic safety ceilings,
|
||||
not normal-performance targets; the base-relative comparison catches smaller
|
||||
regressions. Small fast operations receive an absolute noise
|
||||
allowance so normal scheduler jitter does not become a false regression. Heap,
|
||||
Long Tasks, warmed-cache growth and the expected rendered-device count are
|
||||
gated separately. Long-Task maximum/count/total checks use the same
|
||||
relative-plus-absolute policy as timings. Both raw reports and the comparison
|
||||
are always uploaded as the `full-performance` artifact, and the table is
|
||||
written to the GitHub job summary. Stable release assets require both exact-SHA
|
||||
`Validate` and exact-SHA `Full Performance`; prereleases require only
|
||||
`Validate`.
|
||||
|
||||
This base-vs-candidate design intentionally does not compare timings captured
|
||||
on different machines or different Chromium builds. A runtime/profile mismatch
|
||||
fails closed.
|
||||
|
||||
Before the base checkout, CI fetches the complete commit graph and verifies the
|
||||
requested comparison revision. A `main` push uses `github.event.before`, which
|
||||
must both exist and remain an ancestor of the candidate; this catches the
|
||||
unreachable SHA left by a force-push. A manual run may name an explicit tag,
|
||||
branch or SHA, while an empty manual input and the weekly run use the candidate
|
||||
parent. An unusable requested revision falls back with a warning to the direct
|
||||
parent, then to the newest reachable semver release. If no safe comparison
|
||||
exists, the job fails closed instead of comparing against an arbitrary commit.
|
||||
|
||||
## Private card contract
|
||||
|
||||
The candidate benchmark runner is also executed against the base bundle, so
|
||||
every private `houseplan-card` field or method it reads is an explicit API of
|
||||
the performance harness. `card-contract.mjs` lists that surface for the
|
||||
large-house and Glow profiles. Each runner verifies it immediately after card
|
||||
creation and fails with the exact missing names or invalid runtime types before
|
||||
waiting for readiness or recording timings. Required caches must be real
|
||||
`Map` instances and must never be converted from missing/invalid values to
|
||||
plausible zeroes.
|
||||
|
||||
`fields` are required in every supported comparison base. `optionalFields` are
|
||||
newer members whose absence has an explicit safe fallback in the runner; if an
|
||||
optional member exists, its declared `fieldTypes` contract still applies. Add a
|
||||
new safely degradable field to `optionalFields` until every supported base has
|
||||
it, then promote it to `fields`. A member without a truthful fallback must be
|
||||
introduced through a compatibility revision before the benchmark consumes it.
|
||||
|
||||
Rename a consumed private member in two revisions:
|
||||
|
||||
1. teach the contract and every reader to understand both the old and proposed
|
||||
name while production still exposes the old name; land that compatibility
|
||||
revision so it can become a comparison base;
|
||||
2. rename the production member and prefer the new name while retaining the
|
||||
old reader fallback. Remove the fallback only after all supported comparison
|
||||
bases expose the new member.
|
||||
|
||||
This sequencing keeps the current harness capable of profiling both source
|
||||
trees. A one-step rename that merely edits the candidate reader is forbidden:
|
||||
it would make the same runner incompatible with its base bundle.
|
||||
|
||||
## Local diagnostics
|
||||
|
||||
Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
To reproduce the comparison against another checkout using one harness and one
|
||||
browser installation:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --target-root=../base --samples=7 --output=artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --output=artifacts/performance/candidate.json
|
||||
npm run benchmark:compare
|
||||
```
|
||||
|
||||
## Changing budgets
|
||||
|
||||
Budget changes require an explicit review of recent CI artifacts and a written
|
||||
rationale in the change. Do not loosen a threshold merely to make a single red
|
||||
run pass. A new fixture profile gets a new profile id instead of silently
|
||||
changing the meaning of `large-house-v1`.
|
||||
|
||||
The `cleanFloor` entry ceiling is 160: the reviewed fixture currently warms
|
||||
120 deterministic room/physical-body entries, and the extra 40 slots allow a
|
||||
legitimate fixture extension without weakening the separate zero-growth gate.
|
||||
|
||||
## Glow profiles
|
||||
|
||||
Both Glow profiles run deterministic 1/10/30/60-pool variants at DPR 1 and
|
||||
Chromium CPU throttling x4, but deliberately exercise different fixtures:
|
||||
|
||||
- `large-light-blend-v1` compares the isolated screen group with the previous
|
||||
normal-layer implementation on the shared frontend/backend schema fixture
|
||||
`test/fixtures/glow/additive-pools.json`;
|
||||
- `large-house-glow-overlay-v1` measures simultaneous temperature fill and
|
||||
independent Glow on the existing 60-room/200-device large-house fixture,
|
||||
without changing `large-house-v1`.
|
||||
|
||||
```bash
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --output=artifacts/performance/glow.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --output=artifacts/performance/overlay.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||||
```
|
||||
|
||||
Reports include per-variant state-update timings, render/pool counts, Long
|
||||
Tasks, screenshot time, heap and cache growth. The first CI comparison against
|
||||
a base SHA that predates `glow_enabled` bootstraps only the overlay profile's
|
||||
relative baseline from the candidate; its absolute ceilings still gate that
|
||||
introduction. Every subsequent revision compares both profiles to the real
|
||||
base SHA.
|
||||
|
||||
The initial absolute ceilings are intentionally conservative bootstrap limits;
|
||||
they must be reviewed against the first paired Ubuntu artifacts before the
|
||||
feature is promoted from beta. Same-runner relative checks in the full workflow
|
||||
remain the primary regression signal; the candidate-only smoke only guards
|
||||
against catastrophic failures.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-glow-overlay-v1",
|
||||
"minimumSamples": 3,
|
||||
"timings": {
|
||||
"stateUpdate60Ms": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 2200
|
||||
},
|
||||
"screenshotCaptureMs": {
|
||||
"stat": "median",
|
||||
"hardMaxMs": 3200
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2700,
|
||||
"maxCountP95": 12,
|
||||
"maxTotalP95Ms": 5500
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 80,
|
||||
"glowClip": 128,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-glow-overlay-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 750 },
|
||||
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1100 },
|
||||
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3200 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2700,
|
||||
"maxSingleRegressionRatio": 0.5,
|
||||
"maxSingleNoiseAllowanceMs": 200,
|
||||
"maxCountP95": 12,
|
||||
"maxCountRegressionRatio": 0.5,
|
||||
"countNoiseAllowance": 2,
|
||||
"maxTotalP95Ms": 5500,
|
||||
"maxTotalRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250
|
||||
},
|
||||
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
|
||||
"cacheEntries": { "cleanFloor": 80, "glowClip": 128, "wallUnion": 1, "openingTunnel": 1, "openingWallIndex": 1 },
|
||||
"cacheGrowth": { "cleanFloor": 0, "glowClip": 0, "wallUnion": 0, "openingTunnel": 0, "openingWallIndex": 0 },
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-light-blend-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"stateUpdate1Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 50, "hardMaxMs": 700 },
|
||||
"stateUpdate10Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"stateUpdate30Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 100, "hardMaxMs": 1400 },
|
||||
"stateUpdate60Ms": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 150, "hardMaxMs": 2000 },
|
||||
"screenshotCaptureMs": { "stat": "median", "maxRegressionRatio": 0.5, "noiseAllowanceMs": 250, "hardMaxMs": 3000 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 2500,
|
||||
"maxSingleRegressionRatio": 0.5,
|
||||
"maxSingleNoiseAllowanceMs": 200,
|
||||
"maxCountP95": 12,
|
||||
"maxCountRegressionRatio": 0.5,
|
||||
"countNoiseAllowance": 2,
|
||||
"maxTotalP95Ms": 5000,
|
||||
"maxTotalRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250
|
||||
},
|
||||
"heap": { "required": true, "hardMaxGrowthBytes": 67108864, "maxRegressionRatio": 0.75, "noiseAllowanceBytes": 16777216 },
|
||||
"cacheEntries": { "cleanFloor": 8, "glowClip": 200, "wallUnion": 1, "openingTunnel": 1, "openingWallIndex": 1 },
|
||||
"cacheGrowth": { "cleanFloor": 0, "glowClip": 0, "wallUnion": 0, "openingTunnel": 0, "openingWallIndex": 0 },
|
||||
"renderedDevices": 60
|
||||
}
|
||||
@@ -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,101 @@
|
||||
/**
|
||||
* Private houseplan-card surface consumed by the performance runners.
|
||||
*
|
||||
* The candidate runner profiles both the candidate bundle and a bundle built
|
||||
* from the comparison SHA. Keep these lists explicit so a private rename in
|
||||
* either tree fails before measurements instead of silently reporting zeroes.
|
||||
*/
|
||||
const CACHE_FIELDS = Object.freeze([
|
||||
'_cleanFloorCache',
|
||||
'_glowClipCache',
|
||||
'_wallUnionCache',
|
||||
'_openingTunnelCache',
|
||||
'_openingWallIndexCache',
|
||||
]);
|
||||
|
||||
export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
label: 'large-house-v1',
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_openSettingsDialog',
|
||||
'_pickSpace',
|
||||
'_rszCancelDrag',
|
||||
'_rszEdgeDown',
|
||||
'_rszMove',
|
||||
'_rszRooms',
|
||||
'_setMode',
|
||||
'_viewOr',
|
||||
]),
|
||||
fields: Object.freeze([
|
||||
'_booting',
|
||||
...CACHE_FIELDS,
|
||||
'_devices',
|
||||
'_gridPitch',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_rszDrag',
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
optionalFields: Object.freeze([]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_model: 'array',
|
||||
_tool: 'string',
|
||||
}),
|
||||
});
|
||||
|
||||
export const GLOW_CARD_CONTRACT = Object.freeze({
|
||||
label: 'Glow performance profiles',
|
||||
methods: Object.freeze([]),
|
||||
fields: Object.freeze([
|
||||
...CACHE_FIELDS,
|
||||
'_devices',
|
||||
'_loadOk',
|
||||
]),
|
||||
// Additive blending was introduced after the first supported performance
|
||||
// bases. Its absence is safe: the runner keeps the historical normal blend.
|
||||
optionalFields: Object.freeze(['_glowScreenBlend']),
|
||||
fieldTypes: Object.freeze({
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_glowScreenBlend: 'boolean',
|
||||
_loadOk: 'boolean',
|
||||
}),
|
||||
});
|
||||
|
||||
/** Single fail-fast implementation injected into both browser runners. Keep
|
||||
* this function self-contained: runners serialize it with `toString()`. */
|
||||
export function assertCardContract(card, contract) {
|
||||
const matches = (value, expected) => {
|
||||
if (expected === 'array') return Array.isArray(value);
|
||||
if (expected === 'map') return value instanceof Map;
|
||||
return typeof value === expected;
|
||||
};
|
||||
const missingMethods = contract.methods
|
||||
.filter((name) => typeof card[name] !== 'function')
|
||||
.map((name) => `${name}()`);
|
||||
const missingFields = contract.fields
|
||||
.filter((name) => !(name in card) || card[name] === undefined);
|
||||
const invalidFields = [...contract.fields, ...(contract.optionalFields || [])]
|
||||
.filter((name) => name in card && contract.fieldTypes?.[name]
|
||||
&& !matches(card[name], contract.fieldTypes[name]))
|
||||
.map((name) => `${name}:${contract.fieldTypes[name]}`);
|
||||
const missing = [...missingMethods, ...missingFields];
|
||||
if (missing.length || invalidFields.length) {
|
||||
const details = [
|
||||
missing.length ? `missing private API: ${missing.join(', ')}` : '',
|
||||
invalidFields.length ? `invalid private API types: ${invalidFields.join(', ')}` : '',
|
||||
].filter(Boolean).join('; ');
|
||||
throw new Error(
|
||||
`${contract.label} harness is incompatible with this houseplan-card bundle; ${details}. `
|
||||
+ 'Update the explicit candidate/base compatibility contract before profiling.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { evaluatePerformanceBudget, performanceSummaryMarkdown } from './evaluate.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const absoluteOnly = process.argv.includes('--absolute-only');
|
||||
const candidatePath = resolve(valueArg('candidate') ?? 'artifacts/performance/candidate.json');
|
||||
const baselinePath = resolve(valueArg('baseline') ?? 'artifacts/performance/baseline.json');
|
||||
const budgetsPath = resolve(valueArg('budgets') ?? 'demo/performance/budgets.json');
|
||||
const outputPath = resolve(valueArg('output') ?? 'artifacts/performance/comparison.json');
|
||||
|
||||
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
|
||||
const evaluation = evaluatePerformanceBudget({
|
||||
candidate: readJson(candidatePath),
|
||||
baseline: absoluteOnly ? null : readJson(baselinePath),
|
||||
budgets: readJson(budgetsPath),
|
||||
absoluteOnly,
|
||||
});
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, `${JSON.stringify(evaluation, null, 2)}\n`, 'utf8');
|
||||
const markdown = performanceSummaryMarkdown(evaluation);
|
||||
process.stdout.write(markdown);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown, 'utf8');
|
||||
if (!evaluation.pass) {
|
||||
console.error(`Performance budget failed: ${evaluation.failures.map((item) => item.id).join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
const percentile = (values, p) => {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))];
|
||||
};
|
||||
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
const round = (value, digits = 2) => finite(value) ? Number(value.toFixed(digits)) : value;
|
||||
|
||||
export const summarizeTimings = (rows, metricNames) => Object.fromEntries(
|
||||
metricNames.map((metric) => {
|
||||
const values = rows.map((row) => row[metric]).filter(finite);
|
||||
return [metric, {
|
||||
median: percentile(values, 0.5),
|
||||
p95: percentile(values, 0.95),
|
||||
min: values.length ? Math.min(...values) : null,
|
||||
max: values.length ? Math.max(...values) : null,
|
||||
}];
|
||||
}),
|
||||
);
|
||||
|
||||
export const summarizeLongTasks = (rows) => {
|
||||
const totals = [];
|
||||
const counts = [];
|
||||
const singles = [];
|
||||
for (const row of rows) {
|
||||
const windows = Object.values(row.longTasks ?? {});
|
||||
totals.push(windows.reduce((sum, item) => sum + (item?.totalMs ?? 0), 0));
|
||||
counts.push(windows.reduce((sum, item) => sum + (item?.count ?? 0), 0));
|
||||
singles.push(...windows.map((item) => item?.maxMs ?? 0));
|
||||
}
|
||||
return {
|
||||
maxSingleMs: round(singles.length ? Math.max(...singles) : 0),
|
||||
countP95: percentile(counts, 0.95) ?? 0,
|
||||
totalP95Ms: round(percentile(totals, 0.95) ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const requireReport = (report, budgets, label) => {
|
||||
if (!report || report.schema !== 2) throw new Error(`${label}: unsupported report schema`);
|
||||
if (report.profile !== budgets.profile) throw new Error(`${label}: unexpected benchmark profile`);
|
||||
if (!Array.isArray(report.rows) || report.rows.length < budgets.minimumSamples) {
|
||||
throw new Error(`${label}: expected at least ${budgets.minimumSamples} measured samples`);
|
||||
}
|
||||
};
|
||||
|
||||
const relativeLimit = (baseline, ratio, allowance) => Math.max(
|
||||
baseline * (1 + ratio),
|
||||
baseline + allowance,
|
||||
);
|
||||
|
||||
const makeCheck = (id, actual, limit, details = {}) => ({
|
||||
id,
|
||||
actual: round(actual),
|
||||
limit: round(limit),
|
||||
pass: finite(actual) && actual <= limit,
|
||||
...details,
|
||||
});
|
||||
|
||||
/**
|
||||
* Evaluate a candidate report against stable absolute ceilings. Full captures
|
||||
* additionally compare a base-SHA report from the same runner; fast smoke
|
||||
* captures deliberately enforce only the hard candidate limits.
|
||||
*/
|
||||
export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absoluteOnly = false }) => {
|
||||
requireReport(candidate, budgets, 'candidate');
|
||||
if (!absoluteOnly) {
|
||||
requireReport(baseline, budgets, 'baseline');
|
||||
if (!sameJson(candidate.fixture, baseline.fixture)) throw new Error('fixture mismatch');
|
||||
for (const key of ['node', 'chromium', 'platform', 'arch']) {
|
||||
if (candidate.runtime?.[key] !== baseline.runtime?.[key]) {
|
||||
throw new Error(`runtime mismatch for ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
for (const [metric, budget] of Object.entries(budgets.timings)) {
|
||||
const stat = budget.stat ?? 'median';
|
||||
const actual = candidate.summary?.[metric]?.[stat];
|
||||
const base = absoluteOnly ? null : baseline.summary?.[metric]?.[stat];
|
||||
if (!finite(actual) || (!absoluteOnly && !finite(base))) throw new Error(`missing ${stat} for ${metric}`);
|
||||
const regressionLimit = absoluteOnly
|
||||
? Number.POSITIVE_INFINITY
|
||||
: relativeLimit(base, budget.maxRegressionRatio, budget.noiseAllowanceMs);
|
||||
checks.push(makeCheck(
|
||||
`timing.${metric}.${stat}`,
|
||||
actual,
|
||||
Math.min(budget.hardMaxMs, regressionLimit),
|
||||
{
|
||||
...(absoluteOnly ? {} : { baseline: round(base), regressionLimit: round(regressionLimit) }),
|
||||
hardLimit: budget.hardMaxMs,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
const candidateLong = candidate.longTasks ?? summarizeLongTasks(candidate.rows);
|
||||
const baselineLong = absoluteOnly ? null : (baseline.longTasks ?? summarizeLongTasks(baseline.rows));
|
||||
const longTasksAvailable = candidate.rows.every((row) => {
|
||||
const windows = Object.values(row.longTasks ?? {});
|
||||
return windows.length > 0 && windows.every((item) => item?.supported === true);
|
||||
});
|
||||
checks.push({ id: 'longTask.available', actual: longTasksAvailable ? 1 : 0, limit: 1, pass: longTasksAvailable });
|
||||
const singleRegressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
|
||||
baselineLong.maxSingleMs, budgets.longTasks.maxSingleRegressionRatio,
|
||||
budgets.longTasks.maxSingleNoiseAllowanceMs,
|
||||
);
|
||||
checks.push(makeCheck(
|
||||
'longTask.maxSingleMs',
|
||||
candidateLong.maxSingleMs,
|
||||
Math.min(budgets.longTasks.maxSingleMs, singleRegressionLimit),
|
||||
{
|
||||
...(absoluteOnly ? {} : { baseline: baselineLong.maxSingleMs }),
|
||||
hardLimit: budgets.longTasks.maxSingleMs,
|
||||
},
|
||||
));
|
||||
const countRegressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
|
||||
baselineLong.countP95, budgets.longTasks.maxCountRegressionRatio,
|
||||
budgets.longTasks.countNoiseAllowance,
|
||||
);
|
||||
checks.push(makeCheck(
|
||||
'longTask.countP95',
|
||||
candidateLong.countP95,
|
||||
Math.min(budgets.longTasks.maxCountP95, countRegressionLimit),
|
||||
{
|
||||
...(absoluteOnly ? {} : { baseline: baselineLong.countP95 }),
|
||||
hardLimit: budgets.longTasks.maxCountP95,
|
||||
},
|
||||
));
|
||||
const longRegressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
|
||||
baselineLong.totalP95Ms, budgets.longTasks.maxTotalRegressionRatio,
|
||||
budgets.longTasks.noiseAllowanceMs,
|
||||
);
|
||||
checks.push(makeCheck(
|
||||
'longTask.totalP95Ms',
|
||||
candidateLong.totalP95Ms,
|
||||
Math.min(budgets.longTasks.maxTotalP95Ms, longRegressionLimit),
|
||||
{
|
||||
...(absoluteOnly ? {} : { baseline: baselineLong.totalP95Ms }),
|
||||
hardLimit: budgets.longTasks.maxTotalP95Ms,
|
||||
},
|
||||
));
|
||||
|
||||
const candidateHeap = candidate.rows
|
||||
.map((row) => row.heapGrowthBytes)
|
||||
.filter(finite)
|
||||
.map((value) => Math.max(0, value));
|
||||
const baselineHeap = absoluteOnly ? [] : baseline.rows
|
||||
.map((row) => row.heapGrowthBytes)
|
||||
.filter(finite)
|
||||
.map((value) => Math.max(0, value));
|
||||
const preciseGc = candidate.rows.every((row) => row.preciseGc === true);
|
||||
checks.push({
|
||||
id: 'heap.preciseGc', actual: preciseGc ? 1 : 0, limit: budgets.heap.required ? 1 : 0,
|
||||
pass: !budgets.heap.required || preciseGc,
|
||||
});
|
||||
if (budgets.heap.required && (!candidateHeap.length || (!absoluteOnly && !baselineHeap.length))) {
|
||||
checks.push({ id: 'heap.available', actual: candidateHeap.length, limit: 1, pass: false });
|
||||
} else if (candidateHeap.length && (absoluteOnly || baselineHeap.length)) {
|
||||
const actual = percentile(candidateHeap, 0.95);
|
||||
const base = absoluteOnly ? null : percentile(baselineHeap, 0.95);
|
||||
const regressionLimit = absoluteOnly ? Number.POSITIVE_INFINITY : relativeLimit(
|
||||
base, budgets.heap.maxRegressionRatio, budgets.heap.noiseAllowanceBytes,
|
||||
);
|
||||
checks.push(makeCheck(
|
||||
'heap.growthP95Bytes',
|
||||
actual,
|
||||
Math.min(budgets.heap.hardMaxGrowthBytes, regressionLimit),
|
||||
{
|
||||
...(absoluteOnly ? {} : { baseline: base }),
|
||||
hardLimit: budgets.heap.hardMaxGrowthBytes,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
for (const [cache, limit] of Object.entries(budgets.cacheEntries)) {
|
||||
const actual = Math.max(...candidate.rows.map((row) => row.cacheEntries?.[cache] ?? Number.POSITIVE_INFINITY));
|
||||
checks.push(makeCheck(`cache.entries.${cache}`, actual, limit));
|
||||
}
|
||||
for (const [cache, limit] of Object.entries(budgets.cacheGrowth)) {
|
||||
const actual = Math.max(...candidate.rows.map((row) => row.cacheGrowth?.[cache] ?? Number.POSITIVE_INFINITY));
|
||||
checks.push(makeCheck(`cache.growth.${cache}`, actual, limit));
|
||||
}
|
||||
|
||||
const renderedDevices = Math.min(...candidate.rows.map((row) => row.renderedDevices ?? -1));
|
||||
checks.push({
|
||||
id: 'renderedDevices',
|
||||
actual: renderedDevices,
|
||||
limit: budgets.renderedDevices,
|
||||
pass: renderedDevices === budgets.renderedDevices,
|
||||
});
|
||||
|
||||
const failures = checks.filter((check) => !check.pass);
|
||||
return {
|
||||
schema: 1,
|
||||
profile: budgets.profile,
|
||||
pass: failures.length === 0,
|
||||
mode: absoluteOnly ? 'absolute' : 'relative',
|
||||
candidateFingerprint: candidate.buildFingerprint,
|
||||
baselineFingerprint: baseline?.buildFingerprint ?? null,
|
||||
checks,
|
||||
failures,
|
||||
};
|
||||
};
|
||||
|
||||
export const performanceSummaryMarkdown = (evaluation) => {
|
||||
const icon = evaluation.pass ? '✅' : '❌';
|
||||
const lines = [
|
||||
`### ${icon} House Plan large-house performance`,
|
||||
'',
|
||||
'| Check | Candidate | Limit | Base |',
|
||||
'|---|---:|---:|---:|',
|
||||
];
|
||||
for (const check of evaluation.checks) {
|
||||
lines.push(`| ${check.pass ? '✅' : '❌'} ${check.id} | ${check.actual} | ${check.limit} | ${check.baseline ?? '—'} |`);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
// #73 stable-release pixel gate. Unlike a loop of Playwright screenshots,
|
||||
// CDP screencast observes compositor-presented frames and can therefore catch
|
||||
// the single black/empty frame that motivated the visual-continuity contract.
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const frames = [];
|
||||
let stopped = false;
|
||||
|
||||
session.on('Page.screencastFrame', (event) => {
|
||||
if (frames.length < 120) frames.push(event.data);
|
||||
void session.send('Page.screencastFrameAck', { sessionId: event.sessionId });
|
||||
});
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const card = window.__card;
|
||||
const root = card?.shadowRoot || card?.renderRoot;
|
||||
const host = root?.querySelector('ha-card');
|
||||
return host?.dataset.continuityState === 'steady' && !!host?.dataset.frameFingerprint;
|
||||
});
|
||||
const stage = await page.locator('houseplan-card').evaluate((card) => {
|
||||
const node = (card.shadowRoot || card.renderRoot).querySelector('.stage');
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
});
|
||||
// The reference is an explicitly settled DOM screenshot, never the first
|
||||
// compositor frame emitted by the screencast itself. A degraded first frame
|
||||
// must fail against this baseline rather than widening its own tolerance.
|
||||
const baselinePng = await page.screenshot({ clip: stage });
|
||||
|
||||
await session.send('Page.startScreencast', {
|
||||
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
|
||||
});
|
||||
await wait(120);
|
||||
await page.evaluate(() => window.__card._pageVisibility({
|
||||
kind: 'visible', token: 73, at: Date.now(), hiddenFor: 20_000, long: true,
|
||||
}));
|
||||
// A harmless real paint gives the compositor enough damage to emit the
|
||||
// candidate sequence even when the fresh server response equals the stale
|
||||
// frame pixel-for-pixel.
|
||||
await page.mouse.move(stage.x + stage.width * 0.42, stage.y + stage.height * 0.52);
|
||||
await wait(850);
|
||||
await page.mouse.move(2, 2);
|
||||
await wait(180);
|
||||
await session.send('Page.stopScreencast');
|
||||
stopped = true;
|
||||
|
||||
const allMetrics = await page.evaluate(async ({ encoded, crop }) => {
|
||||
const decode = (data) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = reject;
|
||||
image.src = `data:image/png;base64,${data}`;
|
||||
});
|
||||
const out = [];
|
||||
for (const data of encoded) {
|
||||
const image = await decode(data);
|
||||
const scaleX = image.width / innerWidth;
|
||||
const scaleY = image.height / innerHeight;
|
||||
const x = Math.max(0, Math.floor(crop.x * scaleX));
|
||||
const y = Math.max(0, Math.floor(crop.y * scaleY));
|
||||
const width = Math.max(1, Math.min(image.width - x, Math.floor(crop.width * scaleX)));
|
||||
const height = Math.max(1, Math.min(image.height - y, Math.floor(crop.height * scaleY)));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, x, y, width, height, 0, 0, width, height);
|
||||
const pixels = context.getImageData(0, 0, width, height).data;
|
||||
let count = 0;
|
||||
let sum = 0;
|
||||
let sum2 = 0;
|
||||
let dark = 0;
|
||||
for (let row = 0; row < height; row += 4) {
|
||||
for (let column = 0; column < width; column += 4) {
|
||||
const offset = (row * width + column) * 4;
|
||||
const luma = pixels[offset] * 0.2126
|
||||
+ pixels[offset + 1] * 0.7152 + pixels[offset + 2] * 0.0722;
|
||||
count++;
|
||||
sum += luma;
|
||||
sum2 += luma * luma;
|
||||
if (luma < 18) dark++;
|
||||
}
|
||||
}
|
||||
const mean = sum / Math.max(1, count);
|
||||
out.push({
|
||||
mean,
|
||||
variance: sum2 / Math.max(1, count) - mean * mean,
|
||||
darkRatio: dark / Math.max(1, count),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, { encoded: [baselinePng.toString('base64'), ...frames], crop: stage });
|
||||
|
||||
const baseline = allMetrics[0] || { mean: 0, variance: 0, darkRatio: 1 };
|
||||
const metrics = allMetrics.slice(1);
|
||||
const forbidden = metrics.filter((frame) => (
|
||||
frame.variance < Math.max(12, baseline.variance * 0.08)
|
||||
|| frame.mean < Math.max(4, baseline.mean * 0.35)
|
||||
|| (baseline.darkRatio < 0.85 && frame.darkRatio > 0.96)
|
||||
));
|
||||
|
||||
mkdirSync('artifacts/continuity-screencast', { recursive: true });
|
||||
frames.forEach((data, index) => writeFileSync(
|
||||
`artifacts/continuity-screencast/frame-${String(index).padStart(3, '0')}.png`,
|
||||
Buffer.from(data, 'base64'),
|
||||
));
|
||||
writeFileSync('artifacts/continuity-screencast/metrics.json', JSON.stringify({
|
||||
stage, frames: metrics, forbidden: forbidden.length,
|
||||
}, null, 2));
|
||||
|
||||
const result = {
|
||||
capturedPresentedFrames: frames.length >= 2,
|
||||
baselineContainsPlanDetail: baseline.variance >= 12 && baseline.mean >= 4,
|
||||
noEmptyOrBlackPresentedFrame: forbidden.length === 0,
|
||||
};
|
||||
for (const [name, value] of Object.entries(result)) check(name, value);
|
||||
await finish(browser, result);
|
||||
} finally {
|
||||
if (!stopped) await session.send('Page.stopScreencast').catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
@@ -40,16 +40,24 @@ export async function finish(browser, out) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function launch(viewport = { width: 820, height: 760 }, scale = 1) {
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext({ viewport, deviceScaleFactor: scale })).newPage();
|
||||
export async function launch(
|
||||
viewport = { width: 820, height: 760 },
|
||||
scale = 1,
|
||||
browserArgs = [],
|
||||
contextOptions = {},
|
||||
serveRoot = ROOT,
|
||||
) {
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox', ...browserArgs] });
|
||||
const page = await (await browser.newContext({
|
||||
viewport, deviceScaleFactor: scale, ...contextOptions,
|
||||
})).newPage();
|
||||
// audit T1: an exception inside the card used to be logged and ignored
|
||||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.message); });
|
||||
await page.route('**/*', (r) => {
|
||||
const u = new URL(r.request().url());
|
||||
let p = decodeURIComponent(u.pathname);
|
||||
if (p === '/') p = '/demo.html';
|
||||
const f = ROOT + p;
|
||||
const f = serveRoot + p;
|
||||
existsSync(f)
|
||||
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
|
||||
: r.fulfill({ status: 404, body: 'nf' });
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Capture: a curtain on the move — the breathing ring (.covermove) around a
|
||||
// Capture: a curtain on the move — the semantic transition ring around a
|
||||
// NEUTRAL plate (owner 2026-08-03). The pulse is frozen at a visible frame so
|
||||
// the shot is deterministic.
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'm_mower', binding: 'device:d_mower', hidden: true }] };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
{ id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover', display: 'icon_ripple' },
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
] };
|
||||
c._cfgEpoch++; c._regSignature = ''; c._maybeRebuildDevices();
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'cover.gate': { entity_id: 'cover.gate', state: 'opening',
|
||||
@@ -14,13 +17,13 @@ await page.evaluate(async () => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
st.textContent = '.device-pulse.continuous.transition i:first-child{animation-delay:-1.1s!important;animation-play-state:paused!important;}';
|
||||
(c.shadowRoot || c.renderRoot).appendChild(st);
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
const box = await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.covermove').getBoundingClientRect();
|
||||
const r = (c.shadowRoot || c.renderRoot).querySelector('.dev.activity-transition').getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
||||
});
|
||||
const pad = 70;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Capture: one curtain in the four states an owner sees — closed, open,
|
||||
// opening, closing (owner's contract 2026-08-04). The plate is the plain
|
||||
// neutral badge in ALL of them; open/closed is told by the icon morph alone,
|
||||
// and the two travelling ones add the breathing .covermove ring (frozen at a
|
||||
// and the two travelling ones add the breathing transition ring (frozen at a
|
||||
// visible frame so the shot is deterministic).
|
||||
import { launch } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 520 }, 2);
|
||||
@@ -22,7 +22,8 @@ await page.evaluate(async (STATES) => {
|
||||
entities: { ...c.hass.entities, ...entities },
|
||||
states: { ...c.hass.states, ...states } };
|
||||
c._serverCfg = { ...c._serverCfg, markers: [
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i, tap_action: 'cover' })),
|
||||
...STATES.map((s, i) => ({ id: 'm_cur' + i, binding: 'device:d_cur' + i,
|
||||
tap_action: 'cover', display: 'icon_ripple' })),
|
||||
{ id: 'm_mower', binding: 'device:d_mower', hidden: true },
|
||||
{ id: 'm_gate', binding: 'device:d_gate', hidden: true },
|
||||
] };
|
||||
@@ -37,7 +38,7 @@ await page.evaluate(async (STATES) => {
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const st = document.createElement('style');
|
||||
st.textContent = '.dev.covermove::after{animation-delay:-1.1s;animation-play-state:paused;}';
|
||||
st.textContent = '.device-pulse.continuous.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);
|
||||
|
||||