Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
586494b726 | ||
|
|
63ceef6618 | ||
|
|
f87d3dc545 | ||
|
|
b371b68309 | ||
|
|
89976c85ed | ||
|
|
336d8f30ba | ||
|
|
af44114992 | ||
|
|
3c1a9f8ff7 | ||
|
|
584a154099 | ||
|
|
96e07b9a02 | ||
|
|
7dbb6c8e72 | ||
|
|
14597ccdf9 | ||
|
|
2a7d1dd206 | ||
|
|
4a1aac16fe | ||
|
|
ed9ee026dc | ||
|
|
3a702b3124 | ||
|
|
68462b81e0 | ||
|
|
89deee0fb2 | ||
|
|
ea12650f33 | ||
|
|
e2751062dc | ||
|
|
d2d9b1ac5d | ||
|
|
42b44c7564 | ||
|
|
b13a63f587 | ||
|
|
2b2fc94479 | ||
|
|
e97e568111 | ||
|
|
2877213396 | ||
|
|
471527c474 | ||
|
|
9750330d77 | ||
|
|
1747d1f05a | ||
|
|
603ee3584b | ||
|
|
8bf30947fa | ||
|
|
597488f77e | ||
|
|
8a97d4e30d | ||
|
|
bf4c1bb674 | ||
|
|
2821c35963 | ||
|
|
072bd0c3f0 | ||
|
|
904b8b2251 | ||
|
|
7b826c19a7 | ||
|
|
66a2996a65 | ||
|
|
b4c1da6c0d | ||
|
|
a51aada4f0 | ||
|
|
e88ef93c3c | ||
|
|
66f7bdd0aa | ||
|
|
d53e4f70d3 | ||
|
|
d53064f65e | ||
|
|
f39bc7f7ae | ||
|
|
e67244c2a1 | ||
|
|
862cf151a5 | ||
|
|
e9a1954506 | ||
|
|
9130c4fc59 | ||
|
|
dfac7cb8d3 | ||
|
|
ae03ad4219 | ||
|
|
63d519e385 | ||
|
|
7e7c9f3b49 | ||
|
|
15b060ac38 | ||
|
|
91331041d3 | ||
|
|
5c1175cebc | ||
|
|
609a9bef2d | ||
|
|
bc93babdf8 | ||
|
|
08aad891fe | ||
|
|
00fa61a213 | ||
|
|
6562d48204 | ||
|
|
3be3e46a5d | ||
|
|
63646f069c | ||
|
|
b88233cfac | ||
|
|
137616fa62 | ||
|
|
008efbe920 | ||
|
|
42decbef53 | ||
|
|
66bce93b28 | ||
|
|
c226a0adcd | ||
|
|
b6811ba7c7 | ||
|
|
e86b689d65 | ||
|
|
8504f1b7e3 | ||
|
|
d7d2e61884 | ||
|
|
47951e85db | ||
|
|
d277eeb73b | ||
|
|
1c59e16514 | ||
|
|
299629d2d2 | ||
|
|
ee72e393a7 | ||
|
|
cc93e93da9 | ||
|
|
48a17f5dc9 | ||
|
|
afbee9d328 | ||
|
|
c1e6199b46 | ||
|
|
df4104ab38 | ||
|
|
f8ae546fb0 | ||
|
|
c2913b2866 | ||
|
|
52aa332311 | ||
|
|
97aabddce7 | ||
|
|
2f65a184fd | ||
|
|
5d281171c8 | ||
|
|
f2e38e4815 | ||
|
|
8e5667b9ec | ||
|
|
04f4cd10e1 | ||
|
|
93c9cfae22 | ||
|
|
ed63e4784f | ||
|
|
7f464deb37 | ||
|
|
28f8ea8f21 | ||
|
|
3f3b9ad723 | ||
|
|
a979699a57 | ||
|
|
80ff541cef | ||
|
|
4693d97d2f | ||
|
|
052549fa20 | ||
|
|
55d832ec8b | ||
|
|
a4bcf834e6 | ||
|
|
0145f9bafe | ||
|
|
d2d145427b | ||
|
|
a6077ac922 | ||
|
|
d12b1f20b0 | ||
|
|
4beb5c8829 | ||
|
|
c9194bfbb7 | ||
|
|
0c670a9862 | ||
|
|
38abe78c9a | ||
|
|
b754d362ca | ||
|
|
1cadd520c0 | ||
|
|
0b577be59b | ||
|
|
04d6c5cddb | ||
|
|
5ab41a7299 | ||
|
|
f1f05198a5 | ||
|
|
9dabf84652 | ||
|
|
c82ba84cc7 | ||
|
|
605a9a1c78 | ||
|
|
74eca9bdd1 | ||
|
|
9b4e1efb1d | ||
|
|
a31d8981a7 | ||
|
|
e37285c756 | ||
|
|
9f572b85b2 | ||
|
|
c0426dadd8 | ||
|
|
f35c054eaf | ||
|
|
c3716b6930 | ||
|
|
ab01586b32 | ||
|
|
b5ce94a7e2 | ||
|
|
28c653991a | ||
|
|
5d84511a9b | ||
|
|
194df5251a | ||
|
|
cb298b10f9 | ||
|
|
98bdd46a52 | ||
|
|
ddb2d5e07b | ||
|
|
9ce994452d | ||
|
|
7fb15fcf43 | ||
|
|
3fbf312bbc | ||
|
|
7195ad1914 | ||
|
|
88e4cf50e3 | ||
|
|
57a4a0ed69 | ||
|
|
d77d1953c7 | ||
|
|
e76f3909d9 | ||
|
|
a3396a6c99 | ||
|
|
d95255ada2 | ||
|
|
0c85307bf4 | ||
|
|
90a5b3f6e6 | ||
|
|
0cf6cf4016 | ||
|
|
49d4854221 | ||
|
|
b6afa6e501 | ||
|
|
5fbb9339c6 | ||
|
|
7a662d93ed | ||
|
|
dffe77e32d | ||
|
|
fc5973c223 | ||
|
|
2b80641db8 | ||
|
|
be5eeb41c3 | ||
|
|
73a2fc66d3 | ||
|
|
5d6c1992e4 | ||
|
|
90b185f603 | ||
|
|
2a7e09ee39 | ||
|
|
f52b2449ea | ||
|
|
a91a231e61 | ||
|
|
615045cb18 | ||
|
|
1ee302d9e5 | ||
|
|
b3ca0ca2dd | ||
|
|
196fa09696 | ||
|
|
9d2256c090 | ||
|
|
b69743c0bd | ||
|
|
c41292d49f | ||
|
|
f1b9bbf33e | ||
|
|
de2155787d | ||
|
|
1f28734875 | ||
|
|
c49611967d | ||
|
|
9cf5cdf3bd | ||
|
|
8c12474531 | ||
|
|
bd60edd238 | ||
|
|
77c28076a2 | ||
|
|
ee7d486924 | ||
|
|
3bf9e2d6d3 | ||
|
|
e0c27fb750 | ||
|
|
672570330b | ||
|
|
afba49a3fa | ||
|
|
87cab133ac | ||
|
|
0f7b0dff14 | ||
|
|
b7f82d54dd | ||
|
|
276bf193a2 | ||
|
|
565acb6046 | ||
|
|
a6004fc5f3 | ||
|
|
aae9efb9ff | ||
|
|
ce07db748e | ||
|
|
5ff97858f8 | ||
|
|
855b62729d | ||
|
|
0a955f5df5 | ||
|
|
198484f762 | ||
|
|
2558d68f06 | ||
|
|
7ab12294fc | ||
|
|
23c65e449e | ||
|
|
1856302cd3 | ||
|
|
523bd4569d | ||
|
|
f6956fa472 | ||
|
|
fc0c616698 | ||
|
|
daccaf7ea5 | ||
|
|
12e5728a17 | ||
|
|
65d38b2403 | ||
|
|
21a1993015 | ||
|
|
dc032dcb3e | ||
|
|
152a6ca5cc | ||
|
|
fcf0ce80eb | ||
|
|
a4fe939f63 | ||
|
|
248c926a17 | ||
|
|
0f5c3d8967 | ||
|
|
ea2ec19b71 | ||
|
|
282e52cc73 | ||
|
|
46d43a29be | ||
|
|
0466a02dfd | ||
|
|
69207f5757 | ||
|
|
6b77d598fd | ||
|
|
fcc4125c6e | ||
|
|
41e6eef941 | ||
|
|
13ab4b12b7 | ||
|
|
34d53bf2d1 | ||
|
|
04a1de091c | ||
|
|
9bdf531c1a | ||
|
|
1ef21037a8 | ||
|
|
16b4c2d6d3 | ||
|
|
9ee60993ff | ||
|
|
dfb4efaa1c | ||
|
|
5d09e722e4 | ||
|
|
5763c6aec0 | ||
|
|
570da3dd1a | ||
|
|
ca2a00231b | ||
|
|
833c9a2a33 | ||
|
|
50bc1d6955 | ||
|
|
ba47395337 | ||
|
|
33f16dd3cc | ||
|
|
ef5bfa4bcd | ||
|
|
b4898583d7 | ||
|
|
7320937f62 | ||
|
|
d80805acec | ||
|
|
896ceecf3a | ||
|
|
7cb1eee386 | ||
|
|
da22b87fe1 | ||
|
|
41b8e71248 | ||
|
|
58b1651570 | ||
|
|
793d06c75e | ||
|
|
d13ecf1237 | ||
|
|
a63aef0239 | ||
|
|
b04e4aacce | ||
|
|
c81d1d9775 | ||
|
|
6af6d7b388 | ||
|
|
7dd5b4798a | ||
|
|
5108202419 | ||
|
|
2c7879131f | ||
|
|
6dd9fb03c3 | ||
|
|
b286d7ba43 | ||
|
|
fe0c2e0c83 | ||
|
|
aac9232bbc | ||
|
|
de30066be7 | ||
|
|
6ef0804bc4 | ||
|
|
356b260035 | ||
|
|
3d0fe7d756 | ||
|
|
5adada070f | ||
|
|
0cd5145df0 |
@@ -31,8 +31,13 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
|
||||
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
|
||||
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
|
||||
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
|
||||
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
|
||||
concurrency:
|
||||
group: mutation-gate
|
||||
group: mutation-gate-${{ github.event_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -88,5 +93,104 @@ jobs:
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
# Вывод шарда сохраняется артефактом (#472): строки
|
||||
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
|
||||
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
- name: Сохранить лог шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-shard-${{ matrix.shard }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
|
||||
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
# validate.yml, job с actions: read): перечислены все три.
|
||||
report:
|
||||
name: "Отказ расписания: issue и Telegram"
|
||||
needs: mutants
|
||||
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать логи шардов
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Собрать отчёт
|
||||
id: report
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# SHA — того дерева, которое чекаутили и гоняли (dev), а не
|
||||
# github.sha: для расписания это вершина default-ветки main, и отчёт
|
||||
# называл бы «dev @ <SHA main>» (ревью r1). Образец — process.yml.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--logs=artifacts/mutation-logs --shards=4 \
|
||||
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
|
||||
--body-out=artifacts/mutation-report.md \
|
||||
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
|
||||
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
|
||||
# получает комментарий, новое заводится только если открытого нет.
|
||||
- name: Issue — создать или дописать
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
MARKER: ${{ steps.report.outputs.marker }}
|
||||
TITLE: ${{ steps.report.outputs.title }}
|
||||
run: |
|
||||
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
|
||||
url="${{ github.server_url }}/$REPO/issues/$existing"
|
||||
else
|
||||
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
|
||||
--label infra --label process --label tests \
|
||||
--body-file artifacts/mutation-report.md)
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "issue: $url"
|
||||
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
|
||||
# issue уже заведено, а Telegram — второй адресат, не единственный.
|
||||
- name: Telegram
|
||||
if: always() && steps.issue.outcome == 'success'
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
ISSUE_URL: ${{ steps.issue.outputs.url }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
|
||||
exit 0
|
||||
fi
|
||||
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
|
||||
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,38 @@
|
||||
# Ночной полный прогон (#479).
|
||||
#
|
||||
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
|
||||
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
|
||||
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
|
||||
#
|
||||
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
|
||||
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
|
||||
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
|
||||
# при неизменённом дереве ночной прогон обойдётся маркерами.
|
||||
#
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
name: Ночной полный прогон dev
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 2 * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
|
||||
@@ -36,6 +36,7 @@ jobs:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- isometric-stage3
|
||||
- plan-snap
|
||||
- interaction
|
||||
- blend
|
||||
@@ -187,6 +188,10 @@ jobs:
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json
|
||||
npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
@@ -235,7 +240,10 @@ jobs:
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
;;
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json
|
||||
;;
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
|
||||
@@ -54,6 +54,23 @@ jobs:
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
||||
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`.
|
||||
# Зелёный Validate без трейлера означал бы прогон без смоков и golden —
|
||||
# класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно.
|
||||
- name: Require the Release trailer on the candidate commit
|
||||
env:
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
|
||||
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
|
||||
echo "::error::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
|
||||
exit 1
|
||||
fi
|
||||
# #479: свежесть скриншотов на обычном пуше — предупреждение; на
|
||||
# кандидате она обязана быть доказана строгим режимом.
|
||||
- name: Documentation screenshots are fresh for the candidate
|
||||
run: node scripts/check-docs.mjs --screenshots=strict
|
||||
- name: Require green Validate for this exact SHA
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -90,6 +107,7 @@ jobs:
|
||||
node scripts/verify-houseplan-zip.mjs houseplan.zip \
|
||||
custom_components/houseplan/frontend "$VERSION"
|
||||
test -s dist/houseplan-card.js
|
||||
test -s dist/houseplan-panel.js
|
||||
test -s houseplan.zip
|
||||
- name: Create or verify the annotated tag
|
||||
env:
|
||||
|
||||
@@ -34,6 +34,12 @@ jobs:
|
||||
# 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"
|
||||
# #479: тяжёлые job Validate идут только на коммите с трейлером
|
||||
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
|
||||
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
|
||||
exit 1
|
||||
fi
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
- name: Require full performance for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
@@ -68,7 +74,10 @@ jobs:
|
||||
name: continuity-screencast
|
||||
path: artifacts/continuity-screencast
|
||||
- name: Verify the complete committed frontend tree
|
||||
run: node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
run: |
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
test -s dist/houseplan-card.js
|
||||
test -s dist/houseplan-panel.js
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
|
||||
@@ -13,11 +13,22 @@ on:
|
||||
paths-ignore:
|
||||
- 'docs/reviews/**'
|
||||
pull_request:
|
||||
# Полный набор тяжёлых job по кнопке и по ночам (#479): nightly.yml делает
|
||||
# `gh workflow run validate.yml --ref dev -f full=true`.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full:
|
||||
description: 'Полный набор: смоки, golden, performance_smoke'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
# Полный прогон по кнопке/ночью (#479) живёт в своей группе: первый ручной
|
||||
# запуск отменил очередной push конвейера через полторы минуты — ночной
|
||||
# результат обнулялся бы любым коммитом, пришедшим в то же окно.
|
||||
concurrency:
|
||||
group: validate-${{ github.event.pull_request.number || github.ref }}
|
||||
group: validate-${{ github.event_name == 'workflow_dispatch' && 'dispatch-' || '' }}${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -53,28 +64,47 @@ jobs:
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
|
||||
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные
|
||||
# проверки документации красят всегда. Режим считает тот же скрипт, что и
|
||||
# выход `heavy` job `changes`, чтобы два места не разошлись.
|
||||
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
run: node scripts/check-docs.mjs --external
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
run: |
|
||||
heavy=$(node scripts/classify-changes.mjs --heavy)
|
||||
mode=warn
|
||||
[ "$heavy" = "heavy=true" ] && mode=strict
|
||||
echo "скриншоты документации: режим $mode ($heavy)"
|
||||
node scripts/check-docs.mjs --external --screenshots=$mode
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml идентичен в main и dev"
|
||||
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
<(git show origin/dev:.github/workflows/process.yml); then
|
||||
echo "main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
exit 1
|
||||
fi
|
||||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||||
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
|
||||
echo "Файл исполняется из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit $status
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||||
@@ -158,7 +188,7 @@ jobs:
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
exit $fail
|
||||
@@ -182,6 +212,11 @@ jobs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
|
||||
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
|
||||
mutants: ${{ steps.classify.outputs.mutants }}
|
||||
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
|
||||
heavy: ${{ steps.heavy.outputs.heavy }}
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||||
@@ -198,6 +233,15 @@ jobs:
|
||||
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
|
||||
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
|
||||
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
|
||||
# её тест — scripts/classify-changes.mjs.
|
||||
- id: heavy
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
@@ -237,7 +281,7 @@ jobs:
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
@@ -261,7 +305,7 @@ jobs:
|
||||
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
|
||||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||||
@@ -271,17 +315,14 @@ jobs:
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "база не определена — полный прогон без классификации"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
|
||||
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
@@ -298,6 +339,10 @@ jobs:
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
name: "Переиспользование: это дерево уже проверено"
|
||||
# `changes` нужна ради набора профилей перф-смока (#473): ключ
|
||||
# performance_smoke включает набор, иначе glow-only результат засчитался бы
|
||||
# прогону, которому нужен изометрический профиль.
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
@@ -307,6 +352,7 @@ jobs:
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -314,12 +360,21 @@ jobs:
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
env:
|
||||
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
|
||||
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# Набор профилей перф-смока — часть ключа (#473 AC5).
|
||||
set=glow
|
||||
[ "$PERF_ISO" = "true" ] && set="$set-iso"
|
||||
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
|
||||
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
|
||||
echo "performance_smoke set: $set"
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
@@ -341,7 +396,7 @@ jobs:
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
@@ -398,6 +453,97 @@ jobs:
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
|
||||
# файл, который он патчит, либо изменился его гард. `--check` этого не
|
||||
# видит — он проверяет существование якоря, а не достижимость ветки.
|
||||
# Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466,
|
||||
# #467). Здесь гоняются только затронутые мутанты с той же базы диапазона,
|
||||
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
|
||||
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
|
||||
# детерминированных непересекающихся шарда вместо ослабления проверки.
|
||||
changed_mutants:
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
|
||||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||||
# сохраняется при любом исходе шага, а следующий пуш начинает с того
|
||||
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||||
# (файлы патча и гарда, без строки версии) повторно не гоняется.
|
||||
- name: Журнал свидетелей (последний по шарду)
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||||
restore-keys: mutation-ledger-${{ matrix.shard }}-
|
||||
- name: Затронутые мутанты ловятся
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
zero=$(printf '%040d' 0)
|
||||
base=""
|
||||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||||
fi
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
|
||||
exit 0
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||||
# пока job не убита целиком. Ключ уникален на прогон: cache не
|
||||
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
|
||||
- name: Сохранить журнал свидетелей
|
||||
if: always()
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
needs: changes
|
||||
@@ -486,8 +632,8 @@ jobs:
|
||||
smoke:
|
||||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
@@ -590,8 +736,8 @@ jobs:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
name: "Смоки: все шарды зелёные"
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
needs: [changes, smoke, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Записать маркер успеха
|
||||
@@ -613,8 +759,8 @@ jobs:
|
||||
name: Golden-кадры против принятых эталонов
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -711,13 +857,14 @@ jobs:
|
||||
name: "Перф-смок: бюджет времени кадра"
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
|
||||
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
@@ -757,10 +904,26 @@ jobs:
|
||||
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
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
|
||||
# Диффозависимые профили (#473 §5): изометрия — когда дифф задел
|
||||
# `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра.
|
||||
# Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160
|
||||
# (9 870 мс против 3 500) ловится с одного образца, шум — нет.
|
||||
- name: Изометрический профиль по диффу (#473)
|
||||
if: needs.changes.outputs.perf_iso == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json
|
||||
- name: Профиль взаимодействия по диффу (#473)
|
||||
if: needs.changes.outputs.perf_interaction == 'true'
|
||||
run: |
|
||||
npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json
|
||||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json
|
||||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Быстрый commit точки стены (#461)
|
||||
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -782,7 +945,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
|
||||
@@ -12,5 +12,6 @@ artifacts/
|
||||
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
|
||||
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
|
||||
demo/srv/assets/houseplan-card.js
|
||||
demo/srv/assets/houseplan-panel.js
|
||||
demo/srv/assets/houseplan-assets.json
|
||||
demo/srv/assets/houseplan-assets/
|
||||
|
||||
@@ -153,8 +153,12 @@ in between.
|
||||
|
||||
If the rebase conflicts the pipeline says so in the issue and sends the task back
|
||||
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
|
||||
remaining work is the rebase. Resolve it, push the branch, re-apply
|
||||
`S7-code-review`. The second review run is not a formality — after a rebase onto a
|
||||
remaining work is the rebase. When the conflict is only in the committed bundle
|
||||
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
|
||||
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
|
||||
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
|
||||
the result into your last commit; a conflict anywhere else aborts and leaves the
|
||||
tree as it was. Then push the branch and re-apply `S7-code-review`. The second review run is not a formality — after a rebase onto a
|
||||
moved `dev` this is different code, and accepting it unchecked is how regressions
|
||||
arrive. Cycles are counted per stage, so a code review spends its own budget.
|
||||
|
||||
@@ -334,6 +338,23 @@ npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479):
|
||||
unit tests, build with typecheck, `no-new-any` and `smoke-select` in parallel,
|
||||
then the bundle-tree comparison and the bundle budget. It prints the smokes the
|
||||
diff selects but does not run them — those, `golden`, `pytest` and `check-docs
|
||||
--screenshots=strict` remain the author's call by diff and AC.
|
||||
|
||||
**Heavy CI gates run on the beta candidate, nightly and on demand — not on every
|
||||
push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
|
||||
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
|
||||
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
|
||||
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
|
||||
units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot
|
||||
freshness in `check-docs` is likewise a warning on a plain push and an error on
|
||||
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
|
||||
without the `Release:` trailer, so a green Validate without the heavy jobs can
|
||||
never pass for a release.
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
|
||||
environment, which changes one thing (#151): **before moving an issue to
|
||||
|
||||
@@ -661,6 +661,17 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
|
||||
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
|
||||
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
|
||||
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
|
||||
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
|
||||
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
|
||||
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
|
||||
ключ переиспользования `performance_smoke`. Ревьюер по-прежнему принимает
|
||||
зелёный Validate на SHA как подтверждение дешёвых гейтов — смок его часть.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
|
||||
## Your whole home at a glance
|
||||
|
||||
House Plan turns Home Assistant into a live map of your home. Upload a plan or
|
||||
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
|
||||
House Plan turns Home Assistant into a live map of your home. Open the dedicated
|
||||
**House Plan** item in the Home Assistant sidebar, upload a plan or draw rooms,
|
||||
bind them to Home Assistant areas, and
|
||||
the area's devices appear automatically. You can immediately see where a light
|
||||
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
|
||||
sensor has fired.
|
||||
@@ -54,7 +55,7 @@ sync across screens.
|
||||
|
||||
## Your first working room
|
||||
|
||||
1. Install the integration and add the card to a dashboard.
|
||||
1. Install the integration and open **House Plan** in the Home Assistant sidebar.
|
||||
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
|
||||
or choose no image and draw the plan by hand.
|
||||
3. In Plan, select **Room outline**, place vertices, and click the first point to
|
||||
@@ -94,17 +95,47 @@ House Plan is in the HACS default catalog — no custom repository needed.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
The sidebar page and optional dashboard cards are registered automatically.
|
||||
After installing or updating House Plan,
|
||||
restart Home Assistant and fully reload the page: `Ctrl+F5` on Windows/Linux or
|
||||
`Cmd+Shift+R` on macOS.
|
||||
|
||||
#### Storage mode (Home Assistant default)
|
||||
|
||||
No YAML is normally needed. If automatic registration did not make the card
|
||||
available, open **Settings → Dashboards → menu ⋮ → Resources → Add
|
||||
resource**, enter `/houseplan_files/houseplan-card.js`, and select **JavaScript
|
||||
module**.
|
||||
|
||||
#### YAML resources mode (Home Assistant 2026.2+)
|
||||
|
||||
To manage resources in `configuration.yaml` independently of the dashboard
|
||||
mode, use:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
#### Legacy Home Assistant 2024.6–2026.1
|
||||
|
||||
Only for a full-YAML dashboard that is already managed in YAML, use:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` changes the dashboard itself to YAML mode. Do not switch a storage
|
||||
dashboard to legacy YAML just for House Plan; use the Storage mode instructions
|
||||
above instead. Do not use the on-disk path inside `custom_components`; Home
|
||||
Assistant does not serve that path as a JavaScript module.
|
||||
|
||||
### Manual installation
|
||||
|
||||
@@ -113,9 +144,23 @@ Copy the complete `custom_components/houseplan` release folder to
|
||||
integration. Do not copy only `houseplan-card.js`: the card also uses an
|
||||
internal manifest and content-hashed modules from the same release.
|
||||
|
||||
### Add the card
|
||||
### Open House Plan
|
||||
|
||||
Create a dashboard view (Panel works best) and add the card in the UI or as:
|
||||
After the integration is added, open **House Plan** in the Home Assistant
|
||||
sidebar. This full-page view is the primary entry point and needs no dashboard
|
||||
or YAML setup. It remembers the last space but always returns from another HA
|
||||
page in View rather than reopening an editor. Users without editing permission
|
||||
see the same live plan without editor controls.
|
||||
|
||||
If the sidebar entry cannot be registered, the integration and existing
|
||||
dashboard cards continue to work; check **Settings → System → Repairs → System
|
||||
information → House Plan** after restarting and hard-refreshing HA.
|
||||
|
||||
### Optional dashboard card
|
||||
|
||||
Add the card only when House Plan must be embedded in a dashboard. In a Sections
|
||||
view it requests full width by default, while a manual size chosen in HA remains
|
||||
authoritative. Add it in the UI or as:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
|
||||
## Дом целиком — одним взглядом
|
||||
|
||||
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
|
||||
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
|
||||
House Plan превращает Home Assistant в живую карту дома. Откройте отдельный
|
||||
пункт **House Plan** в боковом меню Home Assistant, загрузите изображение плана
|
||||
или нарисуйте комнаты, свяжите их с зонами Home
|
||||
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
|
||||
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
|
||||
датчик протечки.
|
||||
@@ -55,7 +56,7 @@ Assistant — и устройства появятся на плане авто
|
||||
|
||||
## Первая рабочая комната
|
||||
|
||||
1. Установите интеграцию и добавьте карточку на дашборд.
|
||||
1. Установите интеграцию и откройте **House Plan** в боковом меню Home Assistant.
|
||||
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
|
||||
вариант без изображения, чтобы нарисовать план вручную.
|
||||
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
|
||||
@@ -98,17 +99,47 @@ House Plan входит в основной каталог HACS — пользо
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
Страница в боковом меню и дополнительные карточки для дашборда регистрируются
|
||||
автоматически. После установки или обновления
|
||||
House Plan перезапустите Home Assistant и полностью перезагрузите
|
||||
страницу: `Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS.
|
||||
|
||||
#### Режим Storage (по умолчанию в Home Assistant)
|
||||
|
||||
Обычно YAML не нужен. Если авторегистрация не сделала карточку доступной,
|
||||
откройте **Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить
|
||||
ресурс**, укажите `/houseplan_files/houseplan-card.js` и выберите тип
|
||||
**JavaScript-модуль**.
|
||||
|
||||
#### YAML-ресурсы в Home Assistant 2026.2+
|
||||
|
||||
Чтобы управлять ресурсами в `configuration.yaml` независимо от режима
|
||||
самой панели, используйте:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
#### Home Assistant 2024.6–2026.1: устаревший режим
|
||||
|
||||
Только для панели, которая уже полностью управляется через YAML:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` переводит в YAML-режим саму панель. Не переключайте
|
||||
storage-панель в устаревший YAML только ради House Plan; используйте
|
||||
инструкцию для Storage выше. Не используйте путь к файлу внутри
|
||||
`custom_components`: Home Assistant не публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
@@ -117,9 +148,23 @@ resources:
|
||||
House Plan. Одного `houseplan-card.js` недостаточно: карточке также нужны
|
||||
внутренний манифест и хешированные модули из того же релиза.
|
||||
|
||||
### Добавление карточки
|
||||
### Открытие House Plan
|
||||
|
||||
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
|
||||
После добавления интеграции откройте **House Plan** в боковом меню Home
|
||||
Assistant. Это основной полноэкранный способ работы: отдельный дашборд и YAML
|
||||
не нужны. Запоминается последнее пространство, но после перехода на другую
|
||||
страницу HA панель всегда возвращается в режим просмотра, а не в редактор.
|
||||
Пользователь без права редактирования видит тот же живой план без редакторов.
|
||||
|
||||
Если пункт бокового меню зарегистрировать не удалось, интеграция и уже
|
||||
добавленные карточки на дашбордах продолжают работать. После перезапуска и
|
||||
жёсткого обновления HA проверьте состояние House Plan в системной информации.
|
||||
|
||||
### Необязательная карточка для дашборда
|
||||
|
||||
Добавляйте карточку, только если план нужно встроить в дашборд. В представлении
|
||||
Sections она по умолчанию занимает всю ширину; вручную выбранный в HA размер
|
||||
остаётся главным. Добавьте карточку через UI либо:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Third-party notices
|
||||
|
||||
## VMware Clarity Assets - `compass-line.svg`
|
||||
|
||||
Source: <https://github.com/vmware-archive/clarity-assets/blob/bf6bdd0dd3f247f1a320d44d13fecdeda18c071c/icons/travel/compass-line.svg>
|
||||
|
||||
Copyright (c) 2018 VMware, Inc.
|
||||
|
||||
The MIT license (the "License") set forth below applies to all parts of the
|
||||
clarity-assets project. You may not use this file except in compliance with the
|
||||
License.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Third-party notices
|
||||
|
||||
## VMware Clarity Assets - `compass-line.svg`
|
||||
|
||||
Source: <https://github.com/vmware-archive/clarity-assets/blob/bf6bdd0dd3f247f1a320d44d13fecdeda18c071c/icons/travel/compass-line.svg>
|
||||
|
||||
Copyright (c) 2018 VMware, Inc.
|
||||
|
||||
The MIT license (the "License") set forth below applies to all parts of the
|
||||
clarity-assets project. You may not use this file except in compliance with the
|
||||
License.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -16,11 +15,17 @@ from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
VERSION,
|
||||
)
|
||||
from .frontend_registration import (
|
||||
async_remove_frontend_registration,
|
||||
async_setup_frontend_registration,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .panel_registration import (
|
||||
async_setup_panel_registration,
|
||||
remove_panel_registration,
|
||||
)
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import (
|
||||
@@ -99,49 +104,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
)
|
||||
)
|
||||
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
if not hass.data[DOMAIN].get("static_registered"):
|
||||
hass.data[DOMAIN]["static_registered"] = True
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
return True
|
||||
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
||||
# resources before rendering dashboards, so the card is available even on a cold
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
registered = await _register_lovelace_resource(hass, module_url)
|
||||
if not registered:
|
||||
add_extra_js_url(hass, module_url)
|
||||
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
||||
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
||||
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
||||
if registered:
|
||||
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
||||
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
||||
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
||||
module_url, module_url,
|
||||
)
|
||||
# NOTE (audit B1): plans and marker files are no longer static. They are
|
||||
# served by HouseplanContentView, which requires auth. Only the manifest-
|
||||
# gated frontend bundle stays public so Lovelace can load it. Registration
|
||||
# failures are observable and recoverable, but never skip storage migration,
|
||||
# repairs or housekeeping below.
|
||||
await async_setup_frontend_registration(hass, entry, card_path)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
@@ -303,77 +271,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
|
||||
# The application panel is intentionally the final setup step. It is a
|
||||
# discoverability surface over the already-working stores and APIs, so a
|
||||
# missing/colliding/broken panel remains fail-soft and cannot strand a
|
||||
# sidebar entry after a migration, repair or initial housekeeping failure.
|
||||
panel_path = Path(__file__).parent / "frontend" / "houseplan-panel.js"
|
||||
await async_setup_panel_registration(hass, entry, panel_path)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
"""Unload the entry.
|
||||
|
||||
WS commands and the HTTP view are global (async_setup) and stay registered —
|
||||
their handlers resolve runtime data per call and answer `not_ready` while no
|
||||
entry is loaded. Static paths cannot be unregistered by design.
|
||||
"""
|
||||
remove_panel_registration(hass)
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
||||
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
||||
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
||||
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
||||
"""Register (or update) the card in the Lovelace resource registry.
|
||||
|
||||
Returns True on success. Idempotent: if a resource with our path exists —
|
||||
update the URL on version change; otherwise create it. Any exception → False
|
||||
(fall back to extra_module_url).
|
||||
"""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return False
|
||||
# the resource registry must be loaded
|
||||
if hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
elif hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
# only storage mode allows creating items
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return False
|
||||
base = FRONTEND_URL
|
||||
existing = [
|
||||
item for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == base
|
||||
]
|
||||
if existing:
|
||||
item = existing[0]
|
||||
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
|
||||
await resources.async_update_item(item["id"], {"url": module_url})
|
||||
return True
|
||||
await resources.async_create_item({"res_type": "module", "url": module_url})
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 — any failure → fallback
|
||||
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
return False
|
||||
async def async_remove_entry(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Clean up frontend registration and notice on integration removal."""
|
||||
remove_panel_registration(hass)
|
||||
await async_remove_frontend_registration(hass, entry)
|
||||
|
||||
@@ -14,7 +14,12 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="House Plan", data={}, options=user_input)
|
||||
return self.async_create_entry(
|
||||
title="House Plan",
|
||||
data={},
|
||||
options=user_input,
|
||||
description="panel_ready",
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
|
||||
|
||||
@@ -8,6 +8,12 @@ STORAGE_VERSION = 1
|
||||
STORAGE_MINOR_VERSION = 2
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
FRONTEND_ASSETS_URL = "/houseplan_files/houseplan-assets"
|
||||
PANEL_FRONTEND_URL = "/houseplan_files/houseplan-panel.js"
|
||||
PANEL_URL_PATH = "houseplan"
|
||||
PANEL_URL = f"/{PANEL_URL_PATH}"
|
||||
PANEL_COMPONENT_NAME = "houseplan-panel"
|
||||
PANEL_TITLE = "House Plan"
|
||||
PANEL_ICON = "mdi:floor-plan"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
FILES_URL = "/houseplan_files/files"
|
||||
@@ -51,8 +57,9 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.72.0-beta.1"
|
||||
VERSION = "1.73.0-beta.6"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
# URL would turn the integration into an SSRF proxy and make the privacy notice
|
||||
@@ -70,7 +77,7 @@ MAX_SUPPORT_CONTACT_CODEPOINTS = 320
|
||||
# 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 = 9
|
||||
PLAN_MODEL_VERSION = 10
|
||||
EXPORT_VERSION = 2
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
|
||||
@@ -158,6 +158,8 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
# Legacy-only read boundary: v9 drafts are canonicalized before the
|
||||
# wall-model migration turns their edges into current partitions.
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_lattice_points(draft.get("points"))
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ 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", [])),
|
||||
}
|
||||
|
||||
@@ -1,182 +1,312 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2",
|
||||
"fingerprint": "b3dfbc6044360937e25a58a0e8c1a811bbad4b7d856ecaed44e4d6863af362e6",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 296719,
|
||||
"initialViewGzipBytes": 291480,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js",
|
||||
"houseplan-card.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 293765,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2285,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
"houseplan-assets/backdrop-pick-Rkww7LMa.js",
|
||||
"houseplan-assets/de-B277_e_3.js",
|
||||
"houseplan-assets/editor-CCWMgf_V.js",
|
||||
"houseplan-assets/fr-DdQDYCcl.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CF07oeqG.js",
|
||||
"houseplan-assets/guard-C-Q669dE.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-ClJaasns.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DUKlJqdc.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CttmlNwx.js",
|
||||
"houseplan-assets/iso-scene-render-CaVbce1q.js",
|
||||
"houseplan-assets/live-interaction-runtime-DRckQYsN.js",
|
||||
"houseplan-assets/pdf-export-BebleO7L.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DBKL9MWN.js",
|
||||
"houseplan-assets/zigbee-topology-613GNmA8.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-z9P9ufV_.js"
|
||||
],
|
||||
"lazyGzipBytes": 240651,
|
||||
"lazyGzipBytes": 397025,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
"houseplan-assets/backdrop-pick-Rkww7LMa.js",
|
||||
"houseplan-assets/editor-CCWMgf_V.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CF07oeqG.js",
|
||||
"houseplan-assets/guard-C-Q669dE.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-ClJaasns.js",
|
||||
"houseplan-assets/zigbee-topology-613GNmA8.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 178324,
|
||||
"lazyEditorGzipBytes": 195407,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js"
|
||||
"houseplan-assets/backdrop-pick-Rkww7LMa.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DUKlJqdc.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14239,
|
||||
"lazyOnboardingGzipBytes": 14075,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js"
|
||||
"houseplan-assets/de-B277_e_3.js",
|
||||
"houseplan-assets/fr-DdQDYCcl.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49104,
|
||||
"lazyLocaleGzipBytes": 50562,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-C-Q669dE.js",
|
||||
"houseplan-assets/iso-scene-render-CaVbce1q.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13452,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-CF07oeqG.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 10254,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-CF07oeqG.js",
|
||||
"houseplan-assets/pdf-export-BebleO7L.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 120334,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"sha256": "473ca1094a2f837fffc71026ba5f9c5787a6a1e18db7b37328f586bf7bdeb73a",
|
||||
"rawBytes": 21417,
|
||||
"gzipBytes": 7280,
|
||||
"path": "houseplan-assets/backdrop-pick-Rkww7LMa.js",
|
||||
"sha256": "7c60845fa2b07c93126cf79cddfd5769d3e778ebf462179acc751dfea1414d89",
|
||||
"rawBytes": 21012,
|
||||
"gzipBytes": 7108,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-B9ewWWX2.js",
|
||||
"sha256": "3c291653c70e131993810e78b7ace06a10758b9fbdfa54636f890b36aa8cfc1a",
|
||||
"rawBytes": 82182,
|
||||
"gzipBytes": 24822,
|
||||
"path": "houseplan-assets/de-B277_e_3.js",
|
||||
"sha256": "71d1c32b9f50d037907fae1b26832d0dc69a6717d794eebae8120d980de3cde3",
|
||||
"rawBytes": 84771,
|
||||
"gzipBytes": 25535,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CDcQqv3k.js",
|
||||
"sha256": "76a014106e36d05f727ecc2bfb654708df0d5d3db54d498bb58254eb9ebec15b",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"path": "houseplan-assets/editor-CCWMgf_V.js",
|
||||
"sha256": "52d620892f4f31151287eb5629e7b6fcbd89701fd0fec78a75f66a442a6a201c",
|
||||
"rawBytes": 3837,
|
||||
"gzipBytes": 1582,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-4rsWX0CK.js",
|
||||
"sha256": "b0508cefcba06761e14436fd8ab8bef916c0bdcb70a71e1d338ba7722057d6f1",
|
||||
"rawBytes": 84312,
|
||||
"gzipBytes": 24282,
|
||||
"path": "houseplan-assets/fr-DdQDYCcl.js",
|
||||
"sha256": "4dae51f46f7ec439745b57fe00eec4f66e0456e746064394b985128d731d1f25",
|
||||
"rawBytes": 86828,
|
||||
"gzipBytes": 25027,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"sha256": "24597a66c6c0b7ddb3859a5bc2e8b58e08ef57d4612d0cf9222895122edfe92b",
|
||||
"rawBytes": 1053904,
|
||||
"gzipBytes": 295923,
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-CF07oeqG.js",
|
||||
"sha256": "586307f8362dee6ecc6348554555618a2f294aeb6ac75ea8f6d00964d7b38de3",
|
||||
"rawBytes": 33655,
|
||||
"gzipBytes": 10254,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/guard-C-Q669dE.js",
|
||||
"sha256": "10f716eb1912b59f3d6b0319c95525af93c8e3c1bfa3c4fb0cd00845b8424f3f",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 366,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-vehLOemf.js",
|
||||
"sha256": "7f20fd22057a763281929df2f3e1e23f2c146318cbc19cb82d8915506501e448",
|
||||
"rawBytes": 1047485,
|
||||
"gzipBytes": 290680,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-B9ewWWX2.js",
|
||||
"houseplan-assets/editor-CDcQqv3k.js",
|
||||
"houseplan-assets/fr-4rsWX0CK.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"houseplan-assets/live-interaction-runtime-ClI0mgY3.js"
|
||||
"houseplan-assets/de-B277_e_3.js",
|
||||
"houseplan-assets/editor-CCWMgf_V.js",
|
||||
"houseplan-assets/fr-DdQDYCcl.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CF07oeqG.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-ClJaasns.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DUKlJqdc.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CttmlNwx.js",
|
||||
"houseplan-assets/iso-scene-render-CaVbce1q.js",
|
||||
"houseplan-assets/live-interaction-runtime-DRckQYsN.js",
|
||||
"houseplan-assets/pdf-export-BebleO7L.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DBKL9MWN.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CJkSc-rz.js",
|
||||
"sha256": "fe6417012721db8c1124e8d1a1b9f037db6edd8edb1ccf328bbc1c9697186cbd",
|
||||
"rawBytes": 622491,
|
||||
"gzipBytes": 164615,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-ClJaasns.js",
|
||||
"sha256": "b06ac8fe6ddbf71de0981826f8e238a7ea3d39dcb75c375568a994ee319b20f1",
|
||||
"rawBytes": 633744,
|
||||
"gzipBytes": 168671,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
"houseplan-assets/backdrop-pick-Rkww7LMa.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CF07oeqG.js",
|
||||
"houseplan-assets/guard-C-Q669dE.js",
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js",
|
||||
"houseplan-assets/zigbee-topology-613GNmA8.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
"houseplan-assets/zigbee-topology-runtime-z9P9ufV_.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-E3VnYMqf.js",
|
||||
"sha256": "69cf99a15ce74f74a309241f88f2cf0eade072507f2ec463560956367549d5d2",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6959,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-DUKlJqdc.js",
|
||||
"sha256": "906d2989fcfdc4d2830bedbf488fbcfd3da59be52df1734ccf2fc452c006b822",
|
||||
"rawBytes": 28075,
|
||||
"gzipBytes": 6967,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Br9hp4fM.js",
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
"houseplan-assets/backdrop-pick-Rkww7LMa.js",
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CYOHCggO.js",
|
||||
"sha256": "e64d8d0dae12904eba4aa3f87fb7f046962091eb7284016e0442c838be3cdfb2",
|
||||
"rawBytes": 6006,
|
||||
"gzipBytes": 2376,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CttmlNwx.js",
|
||||
"sha256": "75432479b67f73bd4a3db441ced2864b2407ff1ae3f532f3e5fbf66962b2f2e2",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3728,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CVolox2I.js"
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js",
|
||||
"houseplan-assets/zigbee-topology-613GNmA8.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-z9P9ufV_.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-ClI0mgY3.js",
|
||||
"sha256": "05f378c2eef76f35ea98d05821b4680cae1bd599e410d27fbe69ad5ad76eb0d6",
|
||||
"path": "houseplan-assets/iso-scene-render-CaVbce1q.js",
|
||||
"sha256": "d1135e5202aaa3a003bbb4f1cd4ca4458fdb10a05ed05ce41f62e6f99276e94f",
|
||||
"rawBytes": 38562,
|
||||
"gzipBytes": 13086,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-C-Q669dE.js",
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-DRckQYsN.js",
|
||||
"sha256": "3b317f48c09609ffda1b7de92803629d1c7636fef36c4d8d9029b5ad27fead64",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2127,
|
||||
"gzipBytes": 2124,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-Q4h3gz_F.js",
|
||||
"sha256": "c6f7dc8aad18071bd7766901c382201b0252725fae2c77993483030b3f73ca67",
|
||||
"rawBytes": 12382,
|
||||
"gzipBytes": 4846,
|
||||
"path": "houseplan-assets/pdf-export-BebleO7L.js",
|
||||
"sha256": "323a419c7ebebd87f222d98ebf7bb51ad45a5ca6e20bad1f890b88501884479f",
|
||||
"rawBytes": 237163,
|
||||
"gzipBytes": 110080,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-CF07oeqG.js",
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CVolox2I.js",
|
||||
"sha256": "e4dcb60eb07d78c23782ac134e498fff6b348f4f35a2a7820a13582ceb1887fa",
|
||||
"path": "houseplan-assets/summary-panel-editor-zMb5zlgo.js",
|
||||
"sha256": "2c8ff63f317896bfae043299a044ad730a46fb16dbe8dde75fc3fd8725d961bd",
|
||||
"rawBytes": 10904,
|
||||
"gzipBytes": 2837,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-DBKL9MWN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-DPcZGmKo.js",
|
||||
"sha256": "86b57745c8ebd5972ac3d663c4f7f223737879bcfd16be0af7d1e6b687b0ca20",
|
||||
"rawBytes": 2195,
|
||||
"gzipBytes": 1120,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-DBKL9MWN.js",
|
||||
"sha256": "538f237c3af173880fa85bab04fd687f5c9cd23c226649c6de6975aed3d8bd75",
|
||||
"rawBytes": 43533,
|
||||
"gzipBytes": 13311,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-zMb5zlgo.js",
|
||||
"houseplan-assets/summary-panel-metrics-DPcZGmKo.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-613GNmA8.js",
|
||||
"sha256": "324d5e8fb422c8c8ea0367ddc9319a0fa2edbd0ac3f636d03313bd4dcc3abac1",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7426,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-z9P9ufV_.js",
|
||||
"sha256": "f3cec6eb522f6ecdf32ffda446db46cb828c40b53de606e17d62693da109f0b2",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1761,
|
||||
"gzipBytes": 1760,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js",
|
||||
"houseplan-assets/zigbee-topology-Q4h3gz_F.js"
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js",
|
||||
"houseplan-assets/zigbee-topology-613GNmA8.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "03b15da3629659eba3779885ccf8f0206455ba95378dd1fa246c8ea4bb372a8e",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 796,
|
||||
"sha256": "c03169ee553a83ee2060e3edb0642fb380a5f79bb7ebb2ca7f7cdff714cafa73",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 800,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CeQyO4TX.js"
|
||||
"houseplan-assets/houseplan-card-vehLOemf.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "acd8d42aed5448ceca8ac1a3d9a68a0d027064dcf9ae31ff1ff54b84022f27a1",
|
||||
"rawBytes": 6071,
|
||||
"gzipBytes": 2285,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b3dfbc6044360937e25a58a0e8c1a811bbad4b7d856ecaed44e4d6863af362e6";import{t as e,l as o,x as t,e4 as s,A as a,e5 as i,e6 as l,e7 as n,dU as r,b as c}from"./houseplan-card-vehLOemf.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-CeQyO4TX.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b3dfbc6044360937e25a58a0e8c1a811bbad4b7d856ecaed44e4d6863af362e6";import{dS as r,dT as e,dU as t}from"./houseplan-card-vehLOemf.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
|
||||
@@ -1,36 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{v as e,A as t,dx as i,w as s,c as o,l as r,u as n}from"./houseplan-card-CeQyO4TX.js";import{a,r as h,t as d}from"./zigbee-topology-Q4h3gz_F.js";import{zigbeeTopologyRuntimeSnapshot as p,subscribeZigbeeTopology as c}from"./zigbee-topology-runtime-CVolox2I.js";const l={revision:0,topologies:[],states:{}};class v extends e{constructor(){super(...arguments),this.devices=[],this.currentSpace="",this._runtime=l,this._hovered="",this._pointerOver=e=>{if(!this._mouseAllowed(e))return void this._clear();const t=this._deviceFromEvent(e),i=t?.dataset.id||"";i&&i!==this._hovered&&(this._hovered=i,this.requestUpdate())},this._pointerOut=e=>{const t=this._deviceFromEvent(e);if(!t||t.dataset.id!==this._hovered)return;const i=e.relatedTarget instanceof Element?e.relatedTarget.closest('[data-hp="device"]'):null;i?.dataset.id!==this._hovered&&this._clear()},this._pointerDown=e=>{"mouse"!==e.pointerType&&this._clear()}}connectedCallback(){super.connectedCallback();const e=this.getRootNode();e.host&&"undefined"!=typeof MutationObserver&&(this._hoverGateObserver=new MutationObserver(()=>{e.host.hasAttribute("data-pointer-hover")||this._clear()}),this._hoverGateObserver.observe(e.host,{attributes:!0,attributeFilter:["data-pointer-hover"]})),queueMicrotask(()=>this._connectParent())}disconnectedCallback(){this._release?.(),this._release=void 0,this._disconnectParent(),this._hoverGateObserver?.disconnect(),this._hoverGateObserver=void 0,this._hovered="",super.disconnectedCallback()}updated(e){e.has("hass")&&(this._release?.(),this._runtime=p(this.hass),this._mappedMemo=void 0,this._release=c(this.hass,()=>{this._runtime=p(this.hass),this._mappedMemo=void 0,this.requestUpdate()})),(e.has("currentSpace")||e.has("devices")||e.has("registry"))&&(this._mappedMemo=void 0,this.devices.some(e=>e.id===this._hovered&&e.space===this.currentSpace)||(this._hovered=""))}_connectParent(){const e=this.parentElement;e&&e!==this._parent&&(this._disconnectParent(),this._parent=e,e.addEventListener("pointerover",this._pointerOver),e.addEventListener("pointerout",this._pointerOut),e.addEventListener("pointerdown",this._pointerDown,!0))}_disconnectParent(){this._parent?.removeEventListener("pointerover",this._pointerOver),this._parent?.removeEventListener("pointerout",this._pointerOut),this._parent?.removeEventListener("pointerdown",this._pointerDown,!0),this._parent=void 0}_deviceFromEvent(e){for(const t of e.composedPath())if(t instanceof HTMLElement&&"device"===t.dataset.hp)return t;return null}_mouseAllowed(e){const t=this.getRootNode();return"mouse"===e.pointerType&&t.host?.hasAttribute?.("data-pointer-hover")}_clear(){this._hovered&&(this._hovered="",this.requestUpdate())}_position(e){const t=[...this._parent?.querySelectorAll(".dev[data-id]")||[]].find(t=>t.dataset.id===e);if(!t)return null;const i=Number.parseFloat(t.style.left),s=Number.parseFloat(t.style.top),o=t.getBoundingClientRect();return Number.isFinite(i)&&Number.isFinite(s)?{x:i,y:s,width:o.width,height:o.height}:null}render(){if(!this._hovered||!this.registry||!this._runtime.topologies.length)return t;const e=this._mappedMemo,n=e&&e.runtime===this._runtime&&e.devices===this.devices&&e.registry===this.registry?e.mapped:a(this._runtime.topologies,this.devices,this.registry);e&&n===e.mapped||(this._mappedMemo={runtime:this._runtime,devices:this.devices,registry:this.registry,mapped:n});const p=h(n,this.currentSpace,this._hovered),c=this._position(this._hovered);if(!c)return t;const l=p.lines.map(e=>({...e,point:this._position(e.neighborMarkerId)})).filter(e=>!!e.point);return o`
|
||||
${l.length?s`<svg viewBox="0 0 100 100" preserveAspectRatio="none"
|
||||
aria-hidden="true" data-hp="zigbee-topology-lines">
|
||||
${l.map(e=>s`<line x1=${c.x} y1=${c.y}
|
||||
x2=${e.point.x} y2=${e.point.y}
|
||||
stroke=${void 0===e.lqi?"rgba(145,155,165,.85)":i(e.lqi)}
|
||||
stroke-width=${void 0===e.lqi?2:2.2}
|
||||
stroke-dasharray=${void 0===e.lqi?"5 5":t} opacity=".9"></line>`)}
|
||||
</svg>`:t}
|
||||
${l.map(e=>o`<div class="halo" data-hp="zigbee-topology-neighbor"
|
||||
data-id=${e.neighborMarkerId} style="left:${e.point.x}%;top:${e.point.y}%;width:${1.22*e.point.width}px;height:${1.22*e.point.height}px"></div>`)}
|
||||
${p.remoteCount?o`<div class="remote" data-hp="zigbee-topology-remote"
|
||||
style="left:${c.x}%;top:${c.y}%">${d(r(this.hass),"remote_count",{n:p.remoteCount})}</div>`:t}
|
||||
`}}v.properties={hass:{attribute:!1},devices:{attribute:!1},registry:{attribute:!1},currentSpace:{type:String,attribute:"current-space"},viewKey:{attribute:!1}},v.styles=n`
|
||||
:host { position: absolute; inset: 0; z-index: 5; display: block; pointer-events: none; }
|
||||
svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
line { vector-effect: non-scaling-stroke; stroke-linecap: round; }
|
||||
.halo {
|
||||
position: absolute; width: calc(var(--device-base-size, 4cqw) * 1.22);
|
||||
height: calc(var(--device-base-size, 4cqw) * 1.22); transform: translate(-50%, -50%);
|
||||
box-sizing: border-box; border: 2px solid rgba(120, 190, 220, .82); border-radius: 50%;
|
||||
box-shadow: 0 0 0 4px rgba(120, 190, 220, .16);
|
||||
}
|
||||
.remote {
|
||||
position: absolute; transform: translate(12px, calc(-100% - 12px));
|
||||
border: 1px solid rgba(255,255,255,.7); border-radius: 999px;
|
||||
padding: 3px 7px; color: #fff; background: rgba(28,31,36,.88);
|
||||
box-shadow: 0 2px 7px rgba(0,0,0,.28); white-space: nowrap;
|
||||
font: 600 11px/1.2 system-ui, sans-serif;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
line { stroke: Highlight !important; }
|
||||
.halo { border-color: Highlight; box-shadow: none; }
|
||||
.remote { color: CanvasText; background: Canvas; border-color: CanvasText; }
|
||||
}
|
||||
`,customElements.get("hp-zigbee-topology-overlay")||customElements.define("hp-zigbee-topology-overlay",v);export{v as HpZigbeeTopologyOverlay};
|
||||
@@ -0,0 +1,144 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b3dfbc6044360937e25a58a0e8c1a811bbad4b7d856ecaed44e4d6863af362e6";import{b as e,A as t}from"./houseplan-card-vehLOemf.js";import{s as a,n as s,m as l,a as o,b as i}from"./summary-panel-runtime-loaded-DBKL9MWN.js";const r=r=>{const{host:c,dialog:u,local:n,problems:m,t:d}=r,p=m.find(e=>"error"===e.kind),$=u.localShow!==u.baseLocalShow||!u.localOnly&&!a(s(u.draft),u.base),b=$&&!u.busy&&!p,y=e=>m.find(t=>t.path===e),v=e=>e?d(`summary.problem.${e.code}`):"",h=Object.entries(c.hass?.states||{}).sort((e,t)=>{const a=String(e[1]?.attributes?.friendly_name||e[0]),s=String(t[1]?.attributes?.friendly_name||t[0]);return a.localeCompare(s)}),g=u.entityFilter.trim().toLocaleLowerCase(),_=g?h.filter(([e,t])=>`${e} ${t.attributes?.friendly_name||""}`.toLocaleLowerCase().includes(g)):h,k=()=>r.close(),f=e`<label class="summary-switch"><input type="checkbox"
|
||||
.checked=${u.localShow}
|
||||
@change=${e=>r.setDialog({...u,localShow:e.target.checked})} />
|
||||
<span>${d("summary.show_local")}</span></label>`,w=e`<h3 class="summary-sizes-title">${d("summary.sizes_title")}</h3>
|
||||
<div class="summary-local-sizes">
|
||||
<label>${d("kiosk.icon_scale")}</label>
|
||||
<input type="range" min="50" max="300" step="5"
|
||||
.value=${String(Math.round(100*n.icon_scale))}
|
||||
@input=${e=>r.saveLocal({icon_scale:Number(e.target.value)/100})} />
|
||||
<span>${Math.round(100*n.icon_scale)}%</span>
|
||||
<label>${d("kiosk.font_scale")}</label>
|
||||
<input type="range" min="50" max="300" step="5"
|
||||
.value=${String(Math.round(100*n.font_scale))}
|
||||
@input=${e=>r.saveLocal({font_scale:Number(e.target.value)/100})} />
|
||||
<span>${Math.round(100*n.font_scale)}%</span>
|
||||
<button class="btn ghost summary-size-reset" type="button"
|
||||
@click=${()=>r.saveLocal({icon_scale:1,font_scale:1})}>${d("gs.reset")}</button>
|
||||
</div>
|
||||
${r.storageUnavailable?e`<div class="summary-problem warning">${d("summary.storage_unavailable")}</div>`:t}`;return e`<hp-dialog .hass=${c.hass} .title=${d("summary.settings")}
|
||||
icon="mdi:view-dashboard-outline" dismiss-on-scrim aria-busy=${String(u.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor">
|
||||
${u.localOnly?e`<p class="rhint">${d(u.localOnlyHint)}</p>`:t}
|
||||
${u.localOnly?e`${f}${w}`:e`
|
||||
<label>${d("summary.panel_title")}</label>
|
||||
<input type="text" .value=${u.draft.title}
|
||||
data-summary-error=${String(u.attempted&&"title"===p?.path)}
|
||||
aria-invalid=${"error"===y("title")?.kind?"true":"false"}
|
||||
@input=${e=>r.mutate(t=>{t.title=e.target.value})} />
|
||||
${y("title")?e`<div class="summary-problem error">${v(y("title"))}</div>`:t}
|
||||
${f}
|
||||
<label class="summary-switch"><input type="checkbox" .checked=${u.draft.show_on_mobile}
|
||||
@change=${e=>r.mutate(t=>{t.show_on_mobile=e.target.checked})} />
|
||||
<span>${d("summary.show_mobile")}</span></label>
|
||||
${w}
|
||||
<label>${d("summary.search_entities")}</label>
|
||||
<input type="search" .value=${u.entityFilter}
|
||||
@input=${e=>r.setDialog({...u,entityFilter:e.target.value})} />
|
||||
${_.length?t:e`<div class="summary-empty">${d("summary.no_search_results")}</div>`}
|
||||
<div class="summary-editor-blocks">
|
||||
${u.draft.blocks.map((a,s)=>{const i=`blocks.${s}`,n=y(`${i}.scope`);return e`<article class="summary-editor-block"
|
||||
@dragover=${e=>e.preventDefault()}
|
||||
@drop=${e=>r.drop(e,`block:${s}`)}>
|
||||
<div class="summary-editor-row summary-block-head">
|
||||
<span class="summary-drag" draggable="true" title=${d("summary.drag")}
|
||||
@dragstart=${e=>r.dragStart(e,`block:${s}`)}>⋮⋮</span>
|
||||
<input type="text" .value=${a.title}
|
||||
placeholder=${d("summary.block_title")}
|
||||
data-summary-error=${String(u.attempted&&p?.path===`${i}.title`)}
|
||||
@input=${e=>r.mutate(t=>{t.blocks[s].title=e.target.value})} />
|
||||
<button type="button" title=${d("summary.up")} ?disabled=${0===s}
|
||||
@click=${()=>r.mutate(e=>{e.blocks=l(e.blocks,s,s-1)})}>↑</button>
|
||||
<button type="button" title=${d("summary.down")}
|
||||
?disabled=${s===u.draft.blocks.length-1}
|
||||
@click=${()=>r.mutate(e=>{e.blocks=l(e.blocks,s,s+1)})}>↓</button>
|
||||
<button type="button" title=${d("btn.delete")}
|
||||
@click=${()=>r.deleteBlock(s)}><ha-icon icon="mdi:delete-outline"></ha-icon></button>
|
||||
</div>
|
||||
${y(`${i}.title`)?e`<div class="summary-problem error">${v(y(`${i}.title`))}</div>`:t}
|
||||
<div class="summary-editor-row">
|
||||
<label class="summary-switch"><input type="checkbox" .checked=${a.visible}
|
||||
@change=${e=>r.mutate(t=>{t.blocks[s].visible=e.target.checked})} />
|
||||
<span>${d("summary.block_visible")}</span></label>
|
||||
<select .value=${a.scope.type}
|
||||
@change=${e=>r.mutate(t=>{t.blocks[s].scope="space"===e.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
|
||||
<option value="all" ?selected=${"all"===a.scope.type}>${d("summary.scope_all")}</option>
|
||||
<option value="space" ?selected=${"space"===a.scope.type}>${d("summary.scope_space")}</option>
|
||||
</select>
|
||||
${"space"===a.scope.type?e`<select .value=${a.scope.space_id}
|
||||
data-summary-error=${String(u.attempted&&p?.path===`${i}.scope`)}
|
||||
@change=${e=>r.mutate(t=>{const a=t.blocks[s].scope;"space"===a.type&&(a.space_id=e.target.value)})}>
|
||||
${c._model.some(e=>e.id===a.scope.space_id)?t:e`<option value=${a.scope.space_id} selected>${a.scope.space_id}</option>`}
|
||||
${c._model.map(t=>e`<option value=${t.id}
|
||||
?selected=${t.id===a.scope.space_id}>${t.title}</option>`)}
|
||||
</select>`:t}
|
||||
</div>
|
||||
${n?e`<div class="summary-problem ${n.kind}">${v(n)}</div>`:t}
|
||||
<div class="summary-editor-values">
|
||||
${a.values.map((o,n)=>{const m=`${i}.values.${n}`,$=y(`${m}.source`),b=r.sourceToken(o.source);return e`<div class="summary-editor-value"
|
||||
@dragover=${e=>{e.preventDefault(),e.stopPropagation()}}
|
||||
@drop=${e=>r.drop(e,`value:${s}:${n}`)}>
|
||||
<div class="summary-editor-row">
|
||||
<span class="summary-drag" draggable="true" title=${d("summary.drag")}
|
||||
@dragstart=${e=>r.dragStart(e,`value:${s}:${n}`)}>⋮⋮</span>
|
||||
<input type="text" .value=${o.label}
|
||||
placeholder=${d("summary.value_name")}
|
||||
data-summary-error=${String(u.attempted&&p?.path===`${m}.label`)}
|
||||
@input=${e=>r.mutate(t=>{t.blocks[s].values[n].label=e.target.value})} />
|
||||
<button type="button" title=${d("summary.up")} ?disabled=${0===n}
|
||||
@click=${()=>r.mutate(e=>{e.blocks[s].values=l(e.blocks[s].values,n,n-1)})}>↑</button>
|
||||
<button type="button" title=${d("summary.down")} ?disabled=${n===a.values.length-1}
|
||||
@click=${()=>r.mutate(e=>{e.blocks[s].values=l(e.blocks[s].values,n,n+1)})}>↓</button>
|
||||
<button type="button" title=${d("btn.delete")}
|
||||
@click=${()=>r.mutate(e=>{e.blocks[s].values.splice(n,1)})}><ha-icon icon="mdi:delete-outline"></ha-icon></button>
|
||||
</div>
|
||||
${y(`${m}.label`)?e`<div class="summary-problem error">${v(y(`${m}.label`))}</div>`:t}
|
||||
<select class="summary-source" .value=${b}
|
||||
data-summary-error=${String(u.attempted&&p?.path===`${m}.source`)}
|
||||
@change=${e=>r.setSource(s,n,e.target.value)}>
|
||||
<optgroup label=${d("summary.system_group")}>
|
||||
<option value="system:device_count" ?selected=${"system:device_count"===b}>${d("summary.system.device_count")}</option>
|
||||
<option value="system:total_area" ?selected=${"system:total_area"===b}>${d("summary.system.total_area")}</option>
|
||||
<option value="system:datetime" ?selected=${"system:datetime"===b}>${d("summary.system.datetime")}</option>
|
||||
</optgroup>
|
||||
<optgroup label=${d("summary.entities_group")}>
|
||||
${"entity"!==o.source.type||o.source.entity_id?t:e`<option value="entity:" selected>${d("summary.select_source")}</option>`}
|
||||
${"entity"!==o.source.type||_.some(([e])=>e===o.source.entity_id)?t:e`<option value=${`entity:${o.source.entity_id}`} selected>${c.hass?.states?.[o.source.entity_id]?.attributes?.friendly_name||o.source.entity_id} — ${o.source.entity_id}</option>`}
|
||||
${_.map(([t,a])=>e`<option value=${`entity:${t}`}
|
||||
?selected=${b===`entity:${t}`}>
|
||||
${a.attributes?.friendly_name||t} — ${t}
|
||||
</option>`)}
|
||||
</optgroup>
|
||||
</select>
|
||||
${$?e`<div class="summary-problem ${$.kind}">${v($)}</div>`:t}
|
||||
</div>`})}
|
||||
<button class="btn ghost summary-add" type="button"
|
||||
?disabled=${a.values.length>=20}
|
||||
title=${a.values.length>=20?d("summary.limit_values"):""}
|
||||
@click=${()=>r.mutate(e=>{e.blocks[s].values.push(o())})}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>${d("summary.add_value")}
|
||||
</button>
|
||||
</div>
|
||||
</article>`})}
|
||||
</div>
|
||||
<button class="btn ghost summary-add" type="button"
|
||||
?disabled=${u.draft.blocks.length>=10}
|
||||
title=${u.draft.blocks.length>=10?d("summary.limit_blocks"):""}
|
||||
@click=${()=>r.mutate(e=>{e.blocks.push(i(d))})}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>${d("summary.add_block")}
|
||||
</button>
|
||||
${u.error?e`<div class="summary-problem error" role="alert">${u.error}</div>`:t}
|
||||
${u.conflict?e`<button class="btn ghost summary-reload" type="button"
|
||||
?disabled=${u.busy} @click=${()=>r.reload()}>
|
||||
<ha-icon icon="mdi:reload"></ha-icon>${d("summary.reload_current")}
|
||||
</button>`:t}
|
||||
`}
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
<button class="btn ghost" ?disabled=${u.busy} @click=${k}>${d("btn.cancel")}</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn on" ?disabled=${u.localOnly?!$||u.busy:!b}
|
||||
@click=${()=>r.save()}><ha-icon icon="mdi:check"></ha-icon>${d("btn.save")}</button>
|
||||
</div>
|
||||
</hp-dialog>`};export{r as renderSummaryPanelEditor};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b3dfbc6044360937e25a58a0e8c1a811bbad4b7d856ecaed44e4d6863af362e6";import{az as e,e9 as t,ea as n,br as i,eb as r,P as a,a5 as s,M as o,aq as c,N as l,bp as d,bo as u,ec as f}from"./houseplan-card-vehLOemf.js";function g(t){if(!t.registry.authoritative)return null;const n=new Set,i=e(t.markers);for(const e of Object.values(t.registry.devices||{}))e?.id&&"service"!==e.entry_type&&!i.devices.has(e.id)&&e.area_id&&t.areaToSpace[e.area_id]&&n.add(e.id);for(const e of t.markers||[]){if(e.removed||"virtual"===e.binding)continue;const r=String(e.binding||"").indexOf(":");if(r<1)continue;const a=e.binding.slice(0,r),s=e.binding.slice(r+1),o="device"===a?t.registry.devices?.[s]:null,c="entity"===a?t.registry.entities?.[s]:null,l="device"===a?s:c?.device_id,d="device"===a?o?.area_id:c?.area_id||l&&t.registry.devices?.[l]?.area_id,u="string"==typeof e.room_id&&e.room_id.length>0&&null===e.area?"":e.area||d||"",f=u&&t.areaToSpace[u]||e.space||t.firstSpaceId;l&&t.registry.devices?.[l]&&t.spaceIds.has(f)&&(!i.devices.has(l)||"entity"===a&&i.liveEntities.has(s))&&n.add(l)}return n}function m(e,t){return e?.length?t?.length?f(e,t):e:t}function v(e,t){try{let n=0;for(const i of t){const t=e.spaces.find(e=>String(e?.id)===i.id);if(!t)continue;const f=r(t,i);let g=null;for(const e of i.rooms){if(!e.id)continue;const t=a(e);if(!t)continue;const n=s(i.rooms,e.id,f.walls,f.openCuts,o,f.cellCm,c,l)||t;g=m(g,d(n,f.physicalBodies))}const v=f.cellCm/c;n+=u(g)*v*v/1e4}return n}catch{return null}}function y(e,t,n,r){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:i(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(r||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function _(e,i){const r=e?.states?.[i];if(!r)return null;const a=t(e,i);return a?n(a,String(r.attributes?.unit_of_measurement||"")):null}export{g as representedHaDeviceIds,_ as summaryEntityValue,y as summarySystemValue,v as totalCleanFloorAreaM2};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";import{n as t,b as n}from"./zigbee-topology-Q4h3gz_F.js";import{I as e}from"./houseplan-card-CeQyO4TX.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b3dfbc6044360937e25a58a0e8c1a811bbad4b7d856ecaed44e4d6863af362e6";import{n as t,b as n}from"./zigbee-topology-613GNmA8.js";import{F as e}from"./houseplan-card-vehLOemf.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="3e0ecfb3bc05420eb8105715d3b3b0f39cf57a6a52bd5c853ba2d2904c2f5ca2";try{await import("./houseplan-assets/houseplan-card-CeQyO4TX.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b3dfbc6044360937e25a58a0e8c1a811bbad4b7d856ecaed44e4d6863af362e6";try{await import("./houseplan-assets/houseplan-card-vehLOemf.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
"""Register the House Plan card with the Home Assistant frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import frontend, persistent_notification
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import DOMAIN, FRONTEND_URL, VERSION
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGISTRATION_KEY = "frontend_registration"
|
||||
FRONTEND_STATIC_REGISTERED_KEY = "static_registered"
|
||||
FRONTEND_FALLBACK_URLS_KEY = "frontend_fallback_urls"
|
||||
FRONTEND_RELOAD_NOTICE_DATA_KEY = "frontend_reload_notice_created"
|
||||
FRONTEND_RELOAD_NOTICE_ID = "houseplan_frontend_reload_notice"
|
||||
FRONTEND_RETRY_DELAY_SECONDS = 1.0
|
||||
|
||||
_NOTICE_TITLE_KEY = "component.houseplan.issues.frontend_reload_notice.title"
|
||||
_NOTICE_DESCRIPTION_KEY = (
|
||||
"component.houseplan.issues.frontend_reload_notice.description"
|
||||
)
|
||||
_NOTICE_TITLE_FALLBACK = "House Plan is ready"
|
||||
_NOTICE_DESCRIPTION_FALLBACK = (
|
||||
"Open House Plan from the sidebar. After an update, fully reload the page "
|
||||
"to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` "
|
||||
"on macOS). The dashboard card remains available; if you manage its "
|
||||
"resource manually in storage mode, open Settings → Dashboards → "
|
||||
"Resources. If the sidebar item is missing, check House Plan in System Health."
|
||||
)
|
||||
|
||||
RegistrationStatus = Literal[
|
||||
"not_attempted",
|
||||
"created",
|
||||
"updated",
|
||||
"existing",
|
||||
"registry_pending",
|
||||
"yaml_fallback",
|
||||
"transient_error",
|
||||
"error_fallback",
|
||||
]
|
||||
ResourceLoader = Literal[
|
||||
"none",
|
||||
"lovelace_resource",
|
||||
"extra_module_url",
|
||||
"lovelace_resource_with_session_fallback",
|
||||
]
|
||||
ReloadNoticeStatus = Literal[
|
||||
"created",
|
||||
"already_created",
|
||||
"pending_frontend",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegistrationOutcome:
|
||||
"""Result of one Lovelace resource registry attempt."""
|
||||
|
||||
status: RegistrationStatus
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StaticPathRegistrationOutcome:
|
||||
"""Result of registering one exact public frontend URL."""
|
||||
|
||||
registered: bool
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FrontendRegistrationState:
|
||||
"""Observable state and lifecycle handles for the current config-entry run."""
|
||||
|
||||
card_file_present: bool
|
||||
static_path_registered: bool = False
|
||||
resource_status: RegistrationStatus = "not_attempted"
|
||||
loader: ResourceLoader = "none"
|
||||
module_url: str | None = None
|
||||
retry_pending: bool = False
|
||||
retry_attempted: bool = False
|
||||
last_error: str | None = None
|
||||
first_reload_notice: ReloadNoticeStatus = "pending_frontend"
|
||||
active: bool = True
|
||||
fallback_added: bool = False
|
||||
_cancel_started: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_cancel_timer: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_retry_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
||||
_notice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
||||
|
||||
@callback
|
||||
def cancel(self) -> None:
|
||||
"""Prevent all delayed work for this setup generation."""
|
||||
self.active = False
|
||||
self.retry_pending = False
|
||||
if self._cancel_started is not None:
|
||||
self._cancel_started()
|
||||
self._cancel_started = None
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
if self._retry_task is not None and not self._retry_task.done():
|
||||
self._retry_task.cancel()
|
||||
self._retry_task = None
|
||||
|
||||
|
||||
def _safe_error(phase: str, err: BaseException) -> str:
|
||||
"""Return a support-safe failure fingerprint without message or path data."""
|
||||
return f"{phase}:{type(err).__name__}"
|
||||
|
||||
|
||||
def _static_registered_urls(hass: HomeAssistant) -> set[str]:
|
||||
"""Return the exact static URLs registered for this Core process.
|
||||
|
||||
Before the panel entry existed this state was a boolean. A legacy ``True``
|
||||
proves only that the card URL was registered; treating it as a generic
|
||||
truthy flag would incorrectly skip the panel route after an integration
|
||||
reload.
|
||||
"""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
value = domain_data.get(FRONTEND_STATIC_REGISTERED_KEY)
|
||||
if value is True:
|
||||
urls = {FRONTEND_URL}
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls
|
||||
return urls
|
||||
if isinstance(value, set):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
urls = {url for url, registered in value.items() if registered is True}
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls
|
||||
return urls
|
||||
return set()
|
||||
|
||||
|
||||
async def async_register_frontend_static_path(
|
||||
hass: HomeAssistant,
|
||||
frontend_url: str,
|
||||
file_path: Path,
|
||||
) -> StaticPathRegistrationOutcome:
|
||||
"""Register one exact bundle entry across supported Home Assistant APIs."""
|
||||
registered_urls = _static_registered_urls(hass)
|
||||
if frontend_url in registered_urls:
|
||||
return StaticPathRegistrationOutcome(True)
|
||||
|
||||
try:
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
except ImportError: # Home Assistant versions before the async API
|
||||
hass.http.register_static_path(
|
||||
frontend_url, str(file_path), cache_headers=False
|
||||
)
|
||||
else:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(frontend_url, str(file_path), cache_headers=False)]
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - callers intentionally fail soft
|
||||
return StaticPathRegistrationOutcome(
|
||||
False, _safe_error("static_path", err)
|
||||
)
|
||||
|
||||
registered_urls.add(frontend_url)
|
||||
hass.data.setdefault(DOMAIN, {})[
|
||||
FRONTEND_STATIC_REGISTERED_KEY
|
||||
] = registered_urls
|
||||
return StaticPathRegistrationOutcome(True)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
"""Return the Lovelace resource collection across supported HA versions."""
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
def _is_current(hass: HomeAssistant, state: FrontendRegistrationState) -> bool:
|
||||
"""Return whether a delayed operation still belongs to the loaded entry."""
|
||||
return (
|
||||
state.active
|
||||
and hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY) is state
|
||||
)
|
||||
|
||||
|
||||
def _owned_fallback_urls(hass: HomeAssistant) -> set[str]:
|
||||
"""Return exact extra-module URLs owned for this Home Assistant run."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
urls = domain_data.get(FRONTEND_FALLBACK_URLS_KEY)
|
||||
if not isinstance(urls, set):
|
||||
urls = set()
|
||||
domain_data[FRONTEND_FALLBACK_URLS_KEY] = urls
|
||||
return urls
|
||||
|
||||
|
||||
def _active_extra_module_urls(hass: HomeAssistant) -> frozenset[str] | None:
|
||||
"""Return HA's current module URLs, or None when the API is unknowable.
|
||||
|
||||
``add_extra_js_url`` is intentionally idempotent and has no ownership
|
||||
return value. Looking before adding is therefore the only way to avoid
|
||||
claiming (and later removing) an identical URL configured by the user.
|
||||
Both the oldest supported HA and current HA expose ``UrlManager.urls``;
|
||||
unknown future/legacy shapes fail closed and are never claimed.
|
||||
"""
|
||||
key = getattr(frontend, "DATA_EXTRA_MODULE_URL", None)
|
||||
if key is None:
|
||||
return None
|
||||
manager = hass.data.get(key)
|
||||
if manager is None:
|
||||
# Frontend setup normally creates the manager before this integration.
|
||||
# If it is genuinely absent, no configured URL can predate our add;
|
||||
# add_extra_js_url will either create/use its supported path or fail.
|
||||
return frozenset()
|
||||
urls = getattr(manager, "urls", None)
|
||||
if not isinstance(urls, (set, frozenset, list, tuple)):
|
||||
return None
|
||||
return frozenset(url for url in urls if isinstance(url, str))
|
||||
|
||||
|
||||
async def _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing: list[dict],
|
||||
canonical_id: str,
|
||||
*,
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Best-effort remove legacy duplicates after choosing one authority."""
|
||||
remover = getattr(resources, "async_delete_item", None)
|
||||
if remover is None:
|
||||
return
|
||||
for item in existing:
|
||||
if item.get("id") == canonical_id:
|
||||
continue
|
||||
if not is_active():
|
||||
return
|
||||
try:
|
||||
await remover(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - canonical registration still succeeded
|
||||
_LOGGER.debug(
|
||||
"Could not remove a duplicate House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def async_register_lovelace_resource(
|
||||
hass: HomeAssistant,
|
||||
module_url: str,
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
) -> RegistrationOutcome:
|
||||
"""Create or update the canonical Lovelace resource entry."""
|
||||
active = is_active or (lambda: True)
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return RegistrationOutcome("yaml_fallback")
|
||||
|
||||
try:
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
elif hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
|
||||
existing = [
|
||||
item
|
||||
for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL
|
||||
]
|
||||
if existing:
|
||||
# Prefer an already-current entry when legacy duplicates exist. This
|
||||
# preserves its stable id while the remaining same-base entries are
|
||||
# removed best effort below.
|
||||
item = next(
|
||||
(candidate for candidate in existing if candidate.get("url") == module_url),
|
||||
existing[0],
|
||||
)
|
||||
if item.get("url") == module_url and item.get("type") == "module":
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("existing")
|
||||
if not hasattr(resources, "async_update_item"):
|
||||
return RegistrationOutcome(
|
||||
"transient_error", "registry_update:UnsupportedOperation"
|
||||
)
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_update_item(
|
||||
item["id"], {"url": module_url, "res_type": "module"}
|
||||
)
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("updated")
|
||||
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_create_item(
|
||||
{"url": module_url, "res_type": "module"}
|
||||
)
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource")
|
||||
return RegistrationOutcome("created")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - typed fallback is intentional
|
||||
_LOGGER.debug("Could not register the House Plan Lovelace resource", exc_info=True)
|
||||
return RegistrationOutcome("transient_error", _safe_error("registry", err))
|
||||
|
||||
|
||||
def _add_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> None:
|
||||
"""Add this setup's exact versioned extra-module URL, best effort."""
|
||||
if state.module_url is None or not _is_current(hass, state):
|
||||
return
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
active_before = _active_extra_module_urls(hass)
|
||||
if active_before is not None and state.module_url in active_before:
|
||||
# The exact URL predates this setup. It is a working loader, but it is
|
||||
# not ours to remove after registry recovery or on uninstall.
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
try:
|
||||
frontend.add_extra_js_url(hass, state.module_url)
|
||||
except Exception as err: # noqa: BLE001 - frontend setup must remain available
|
||||
state.loader = "none"
|
||||
state.last_error = _safe_error("fallback_add", err)
|
||||
_LOGGER.warning("Could not add the House Plan frontend fallback", exc_info=True)
|
||||
return
|
||||
if active_before is not None:
|
||||
owned_urls.add(state.module_url)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"House Plan could not prove ownership of the frontend fallback; "
|
||||
"it will remain until Home Assistant restarts"
|
||||
)
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
|
||||
|
||||
def _remove_owned_fallback_url(
|
||||
hass: HomeAssistant,
|
||||
state: FrontendRegistrationState,
|
||||
module_url: str,
|
||||
) -> bool:
|
||||
"""Remove one exact integration-owned fallback when HA supports it."""
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if module_url not in owned_urls:
|
||||
return True
|
||||
remover = getattr(frontend, "remove_extra_js_url", None)
|
||||
if remover is None:
|
||||
return False
|
||||
try:
|
||||
remover(hass, module_url)
|
||||
except KeyError:
|
||||
# Another lifecycle path already removed the exact URL.
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 - registry remains authoritative
|
||||
state.last_error = _safe_error("fallback_remove", err)
|
||||
_LOGGER.debug("Could not remove the House Plan frontend fallback", exc_info=True)
|
||||
return False
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
|
||||
|
||||
def _remove_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> bool:
|
||||
"""Remove this setup's exact fallback when the running HA supports it."""
|
||||
if state.module_url is None:
|
||||
return True
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
removed = _remove_owned_fallback_url(hass, state, state.module_url)
|
||||
state.fallback_added = not removed
|
||||
return removed
|
||||
|
||||
# A pre-existing/user-owned or uninspectable exact URL must never be
|
||||
# removed. Report the combined loader while it is known (or conservatively
|
||||
# assumed) to remain active in this HA document lifecycle.
|
||||
active = _active_extra_module_urls(hass)
|
||||
state.fallback_added = active is None or state.module_url in active
|
||||
return not state.fallback_added
|
||||
|
||||
|
||||
async def _async_create_reload_notice(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Create and persist the one-time localized hard-reload instruction."""
|
||||
# Initial fallback setup and the one-second retry may overlap when loading a
|
||||
# translation is slow. Serialize the persisted one-shot contract rather
|
||||
# than relying only on the stable notification ID (which prevents two UI
|
||||
# rows but would still perform and persist two creates).
|
||||
async with state._notice_lock:
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True:
|
||||
if state.first_reload_notice != "created":
|
||||
state.first_reload_notice = "already_created"
|
||||
return
|
||||
if (
|
||||
not _is_current(hass, state)
|
||||
or not state.card_file_present
|
||||
or not state.static_path_registered
|
||||
or state.loader == "none"
|
||||
):
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
return
|
||||
|
||||
title = _NOTICE_TITLE_FALLBACK
|
||||
description = _NOTICE_DESCRIPTION_FALLBACK
|
||||
try:
|
||||
translations = await async_get_translations(
|
||||
hass,
|
||||
hass.config.language,
|
||||
"issues",
|
||||
integrations={DOMAIN},
|
||||
)
|
||||
title = translations.get(_NOTICE_TITLE_KEY, title)
|
||||
description = translations.get(_NOTICE_DESCRIPTION_KEY, description)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - built-in English fallback is intentional
|
||||
_LOGGER.debug(
|
||||
"Could not load the House Plan reload notice translation",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
try:
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
description,
|
||||
title=title,
|
||||
notification_id=FRONTEND_RELOAD_NOTICE_ID,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - do not consume flag on failure
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not create the House Plan reload notice", exc_info=True
|
||||
)
|
||||
return
|
||||
try:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - roll back the unpersisted notice
|
||||
# A stable ID prevents duplicate rows, but dismissing the notice is
|
||||
# still important: without a durable flag, displaying it would make
|
||||
# System Health claim that the one-shot contract is pending while a
|
||||
# user-visible notice already exists. A later setup/retry can now
|
||||
# attempt the complete create+persist operation honestly.
|
||||
try:
|
||||
persistent_notification.async_dismiss(
|
||||
hass, FRONTEND_RELOAD_NOTICE_ID
|
||||
)
|
||||
except Exception: # noqa: BLE001 - stable ID remains a safe fallback
|
||||
_LOGGER.debug(
|
||||
"Could not roll back an unpersisted House Plan reload notice",
|
||||
exc_info=True,
|
||||
)
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not persist the House Plan reload notice state",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
state.first_reload_notice = "created"
|
||||
|
||||
|
||||
async def _async_retry_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Perform the single delayed resource-registry retry."""
|
||||
if not _is_current(hass, state) or state.module_url is None:
|
||||
return
|
||||
state.retry_pending = False
|
||||
state.retry_attempted = True
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
elif outcome.status == "yaml_fallback":
|
||||
state.resource_status = "yaml_fallback"
|
||||
state.last_error = outcome.last_error
|
||||
_add_fallback(hass, state)
|
||||
else:
|
||||
state.resource_status = "error_fallback"
|
||||
state.last_error = outcome.last_error or "registry_retry:Unavailable"
|
||||
_add_fallback(hass, state)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
|
||||
|
||||
def _schedule_retry(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Schedule exactly one lifecycle-bound retry after HA start plus one second."""
|
||||
state.retry_pending = True
|
||||
|
||||
@callback
|
||||
def _after_started(_event=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_started = None
|
||||
|
||||
@callback
|
||||
def _after_delay(_now=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_timer = None
|
||||
state._retry_task = entry.async_create_background_task(
|
||||
hass,
|
||||
_async_retry_registration(hass, entry, state),
|
||||
"houseplan frontend resource retry",
|
||||
)
|
||||
|
||||
state._cancel_timer = async_call_later(
|
||||
hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay
|
||||
)
|
||||
|
||||
state._cancel_started = async_at_started(hass, _after_started)
|
||||
|
||||
|
||||
async def async_setup_frontend_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
card_path: Path,
|
||||
) -> FrontendRegistrationState:
|
||||
"""Register static/card resources without blocking the rest of entry setup."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
previous = domain_data.get(FRONTEND_REGISTRATION_KEY)
|
||||
if isinstance(previous, FrontendRegistrationState):
|
||||
previous.cancel()
|
||||
|
||||
card_file_present = card_path.is_file()
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}" if card_file_present else None
|
||||
active_fallbacks = _active_extra_module_urls(hass)
|
||||
fallback_added = module_url is not None and (
|
||||
module_url in _owned_fallback_urls(hass)
|
||||
or (active_fallbacks is not None and module_url in active_fallbacks)
|
||||
)
|
||||
state = FrontendRegistrationState(
|
||||
card_file_present=card_file_present,
|
||||
loader="extra_module_url" if fallback_added else "none",
|
||||
module_url=module_url,
|
||||
first_reload_notice=(
|
||||
"already_created"
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True
|
||||
else "pending_frontend"
|
||||
),
|
||||
fallback_added=fallback_added,
|
||||
)
|
||||
domain_data[FRONTEND_REGISTRATION_KEY] = state
|
||||
entry.async_on_unload(state.cancel)
|
||||
|
||||
if card_file_present:
|
||||
static_outcome = await async_register_frontend_static_path(
|
||||
hass, FRONTEND_URL, card_path
|
||||
)
|
||||
state.static_path_registered = static_outcome.registered
|
||||
if not static_outcome.registered:
|
||||
state.last_error = static_outcome.last_error
|
||||
_LOGGER.warning(
|
||||
"Could not register the House Plan card static path (%s)",
|
||||
static_outcome.last_error or "static_path:UnknownError",
|
||||
)
|
||||
|
||||
if not card_file_present:
|
||||
_LOGGER.warning("houseplan-card.js was not found next to the integration")
|
||||
return state
|
||||
if not state.static_path_registered or state.module_url is None:
|
||||
return state
|
||||
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return state
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan card auto-registered as a Lovelace resource: %s",
|
||||
state.module_url,
|
||||
)
|
||||
else:
|
||||
_add_fallback(hass, state)
|
||||
if outcome.status in {"registry_pending", "transient_error"}:
|
||||
_schedule_retry(hass, entry, state)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card uses the frontend extra-module fallback: %s",
|
||||
state.module_url,
|
||||
)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def get_frontend_registration_state(
|
||||
hass: HomeAssistant,
|
||||
) -> FrontendRegistrationState | None:
|
||||
"""Return current frontend registration state for System Health."""
|
||||
state = hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY)
|
||||
return state if isinstance(state, FrontendRegistrationState) else None
|
||||
|
||||
|
||||
async def async_remove_frontend_registration(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Remove all House Plan resource entries and its one-time notice."""
|
||||
state = get_frontend_registration_state(hass)
|
||||
if state is not None:
|
||||
state.cancel()
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
if domain_data.get(FRONTEND_REGISTRATION_KEY) is state:
|
||||
domain_data.pop(FRONTEND_REGISTRATION_KEY, None)
|
||||
|
||||
# Fallback ownership belongs to this HA run, not to the latest config-entry
|
||||
# generation. A bundle-missing reload can therefore have no current URL
|
||||
# while an earlier exact URL is still registered. Uninstall cleans every
|
||||
# URL that this integration itself added, and never pattern-matches foreign
|
||||
# extra-module entries.
|
||||
cleanup_state = state or FrontendRegistrationState(card_file_present=False)
|
||||
for module_url in tuple(_owned_fallback_urls(hass)):
|
||||
_remove_owned_fallback_url(hass, cleanup_state, module_url)
|
||||
|
||||
try:
|
||||
persistent_notification.async_dismiss(hass, FRONTEND_RELOAD_NOTICE_ID)
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not dismiss the House Plan reload notice", exc_info=True)
|
||||
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] != FRONTEND_URL:
|
||||
continue
|
||||
try:
|
||||
await resources.async_delete_item(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - continue best-effort cleanup
|
||||
_LOGGER.debug(
|
||||
"Could not remove one House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
_LOGGER.debug("House Plan Lovelace resource removed")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not remove the House Plan Lovelace resource", exc_info=True)
|
||||
@@ -70,8 +70,7 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
_IMPORT_ID_NAMESPACES = {
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "draft",
|
||||
"draft_segment", "column",
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "column",
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
@@ -97,7 +96,7 @@ _SPACE_DISPLAY_FIELDS = (
|
||||
)
|
||||
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
|
||||
_ROOM_DISPLAY_FIELDS = (
|
||||
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
|
||||
"fill_mode", "custom_fill", "glow", "temp_min", "temp_max", "name_scale", "label_scale",
|
||||
)
|
||||
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
|
||||
_DECOR_KIND_FIELDS = {
|
||||
@@ -266,7 +265,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("walls", ("key", "cm", "a", "b")),
|
||||
("wall_segments", ("id", "a", "b", "cm")),
|
||||
("room_drafts", ("id", "points", "segments")),
|
||||
("partitions", ("id", "a", "b", "cm")),
|
||||
("wall_columns", ("id", "shape", "center", "cm", "angle")),
|
||||
("open_spans", ("a", "b")),
|
||||
@@ -277,11 +275,6 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
values = []
|
||||
for item in space.get(name) or []:
|
||||
selected = _pick_fields(item, fields)
|
||||
if name == "room_drafts" and "segments" in selected:
|
||||
selected["segments"] = [
|
||||
_pick_fields(segment, ("id", "cm"))
|
||||
for segment in selected.get("segments") or []
|
||||
]
|
||||
values.append(selected)
|
||||
projected[name] = values
|
||||
if "openings" in space:
|
||||
@@ -511,7 +504,16 @@ def create_export(
|
||||
# Read before voluptuous coercions: bool is an int subclass and must not
|
||||
# become a plausible model 1 in an otherwise valid export.
|
||||
model_version = _stored_model_version(raw_config)
|
||||
config = CONFIG_SCHEMA(raw_config)
|
||||
if model_version > PLAN_MODEL_VERSION:
|
||||
raise ImportFailure("future_model", "The stored plan uses a newer data model")
|
||||
try:
|
||||
raw_config["model_version"] = model_version
|
||||
config, _ = commit_wall_segment_model(raw_config)
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except (WallSegmentMigrationError, vol.Invalid) as err:
|
||||
code = err.code if isinstance(err, WallSegmentMigrationError) else "invalid_config"
|
||||
raise ImportFailure(code, str(err)) from err
|
||||
model_version = PLAN_MODEL_VERSION
|
||||
# Compatibility belongs to the envelope. Keeping this field inside the
|
||||
# payload would either duplicate it or tempt an exporter to silently stamp
|
||||
# the current version over an older/future stored model.
|
||||
@@ -1278,7 +1280,7 @@ def build_space_merge(
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
for collection in (
|
||||
(sp,), sp.get("rooms") or [], sp.get("room_drafts") or [],
|
||||
(sp,), sp.get("rooms") or [],
|
||||
sp.get("partitions") or [], sp.get("wall_segments") or [],
|
||||
sp.get("wall_columns") or [],
|
||||
sp.get("openings") or [], sp.get("decor") or [],
|
||||
@@ -1290,18 +1292,11 @@ def build_space_merge(
|
||||
} | {
|
||||
str(m.get("id")) for m in current_config.get("markers") or []
|
||||
if m.get("id") is not None
|
||||
} | {
|
||||
str(segment.get("id"))
|
||||
for sp in current_config.get("spaces") or []
|
||||
for draft in sp.get("room_drafts") or []
|
||||
for segment in draft.get("segments") or []
|
||||
if isinstance(segment, dict) and segment.get("id") is not None
|
||||
} | {
|
||||
str(value)
|
||||
for incoming_space in spaces
|
||||
for collection in (
|
||||
(incoming_space,), incoming_space.get("rooms") or [],
|
||||
incoming_space.get("room_drafts") or [],
|
||||
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
|
||||
incoming_space.get("wall_columns") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
|
||||
@@ -1313,12 +1308,6 @@ def build_space_merge(
|
||||
} | {
|
||||
str(m.get("id")) for m in incoming.get("markers") or []
|
||||
if m.get("id") is not None
|
||||
} | {
|
||||
str(segment.get("id"))
|
||||
for incoming_space in spaces
|
||||
for draft in incoming_space.get("room_drafts") or []
|
||||
for segment in draft.get("segments") or []
|
||||
if isinstance(segment, dict) and segment.get("id") is not None
|
||||
}
|
||||
old_space_id = str(space.get("id"))
|
||||
_record_bounded_lineage(
|
||||
@@ -1331,7 +1320,7 @@ def build_space_merge(
|
||||
}
|
||||
id_maps["space"][old_space_id] = new_space_id
|
||||
for collection, prefix in (
|
||||
("rooms", "room"), ("room_drafts", "draft"), ("partitions", "partition"),
|
||||
("rooms", "room"), ("partitions", "partition"),
|
||||
("wall_segments", "wall"),
|
||||
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
|
||||
):
|
||||
@@ -1344,18 +1333,6 @@ def build_space_merge(
|
||||
id_map[old] = _fresh(prefix, old, used)
|
||||
id_maps[prefix][old] = id_map[old]
|
||||
item["id"] = id_map[old]
|
||||
for draft in space.get("room_drafts") or []:
|
||||
for segment in draft.get("segments") or []:
|
||||
if not isinstance(segment, dict) or segment.get("id") is None:
|
||||
continue
|
||||
old = str(segment["id"])
|
||||
_record_bounded_lineage(
|
||||
reference_report, bounded_seen, "draft_segment", old,
|
||||
)
|
||||
new = _fresh("draft_segment", old, used)
|
||||
id_map[old] = new
|
||||
id_maps["draft_segment"][old] = new
|
||||
segment["id"] = new
|
||||
old_room_ids = id_maps["room"]
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
@@ -1798,21 +1775,20 @@ def _materialize_import_candidate(
|
||||
if confirmation_required:
|
||||
_detach_missing(imported_config, content)
|
||||
incoming_model = int(prepared.get("model_version", 0) or 0)
|
||||
target_model = int(current_config.get("model_version", 0) or 0)
|
||||
requires_v8 = incoming_model >= 8 or target_model >= 8
|
||||
if requires_v8:
|
||||
try:
|
||||
# A v8 target cannot be downgraded by a v7 backup, while an
|
||||
# incoming v8 graph must retain its already-persisted IDs. When
|
||||
# both sides are v7 the accepted compatibility contract keeps the
|
||||
# import v7 until a later structural write or explicit Optimize.
|
||||
imported_config["model_version"] = incoming_model
|
||||
imported_config, _ = commit_wall_segment_model(imported_config)
|
||||
prepared["payload"]["config"] = imported_config
|
||||
if prepared["kind"] == "space":
|
||||
current_config, _ = commit_wall_segment_model(current_config)
|
||||
except WallSegmentMigrationError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
migration: dict[str, int] = {}
|
||||
try:
|
||||
# Import preview and apply operate only on the current materialized
|
||||
# wall model. Legacy drafts are converted before copy/remap, so no
|
||||
# current merge path can recreate their carrier.
|
||||
imported_config["model_version"] = incoming_model
|
||||
imported_config, _ = commit_wall_segment_model(
|
||||
imported_config, migration_report=migration,
|
||||
)
|
||||
prepared["payload"]["config"] = imported_config
|
||||
if prepared["kind"] == "space":
|
||||
current_config, _ = commit_wall_segment_model(current_config)
|
||||
except WallSegmentMigrationError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
if prepared["kind"] == "space":
|
||||
config, layout, details = build_space_merge(
|
||||
prepared, current_config, current_layout, duplicate_policy,
|
||||
@@ -1820,8 +1796,6 @@ def _materialize_import_candidate(
|
||||
)
|
||||
else:
|
||||
config = imported_config
|
||||
if not requires_v8 and incoming_model > 0:
|
||||
config["model_version"] = incoming_model
|
||||
_materialize_global_background(config)
|
||||
layout = _json_copy(prepared["payload"]["layout"])
|
||||
if not same_source:
|
||||
@@ -1841,6 +1815,7 @@ def _materialize_import_candidate(
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_layout", str(err)) from err
|
||||
details = _json_copy(details)
|
||||
details["migration"] = migration
|
||||
details["counts"] = _counts(config, layout)
|
||||
return config, layout, details
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> f
|
||||
|
||||
|
||||
def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
|
||||
"""Every current wall the limits judge: contour atoms and partitions."""
|
||||
segments: list[dict[str, Any]] = []
|
||||
for segment in space.get("wall_segments") or []:
|
||||
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
|
||||
@@ -113,19 +113,6 @@ def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"a": partition["a"], "b": partition["b"],
|
||||
"cm": float(partition.get("cm") or 0),
|
||||
})
|
||||
for draft in space.get("room_drafts") or []:
|
||||
points = draft.get("points") or []
|
||||
drafted = draft.get("segments") or []
|
||||
for index in range(max(len(points) - 1, 0)):
|
||||
if not (_finite_point(points[index]) and _finite_point(points[index + 1])):
|
||||
continue
|
||||
piece = drafted[index] if index < len(drafted) else {}
|
||||
segments.append({
|
||||
"id": str((piece or {}).get("id")
|
||||
or f"{draft.get('id') or 'draft'}-{index}"),
|
||||
"a": points[index], "b": points[index + 1],
|
||||
"cm": float((piece or {}).get("cm") or 0),
|
||||
})
|
||||
return [segment for segment in segments
|
||||
if _length(segment["a"], segment["b"]) > _EPS]
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Matysh"
|
||||
],
|
||||
@@ -8,6 +11,7 @@
|
||||
"dependencies": [
|
||||
"http",
|
||||
"frontend",
|
||||
"panel_custom",
|
||||
"websocket_api"
|
||||
],
|
||||
"documentation": "https://github.com/Matysh/houseplan-card",
|
||||
@@ -16,5 +20,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.72.0-beta.1"
|
||||
"version": "1.73.0-beta.6"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Register the House Plan application panel with strict lifecycle ownership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import frontend, panel_custom
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PANEL_COMPONENT_NAME,
|
||||
PANEL_FRONTEND_URL,
|
||||
PANEL_ICON,
|
||||
PANEL_TITLE,
|
||||
PANEL_URL,
|
||||
PANEL_URL_PATH,
|
||||
VERSION,
|
||||
)
|
||||
from .frontend_registration import async_register_frontend_static_path
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PANEL_REGISTRATION_KEY = "panel_registration"
|
||||
PANEL_GENERATION_KEY = "panel_generation"
|
||||
|
||||
PanelStatus = Literal[
|
||||
"not_attempted",
|
||||
"registered",
|
||||
"missing_asset",
|
||||
"collision",
|
||||
"static_error",
|
||||
"registration_error",
|
||||
"ownership_error",
|
||||
"removed",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PanelRegistrationState:
|
||||
"""Observable state for one config-entry setup generation."""
|
||||
|
||||
panel_file_present: bool
|
||||
generation: int
|
||||
panel_static_path_registered: bool = False
|
||||
panel_status: PanelStatus = "not_attempted"
|
||||
panel_url: str | None = None
|
||||
panel_module_url: str | None = None
|
||||
panel_error: str | None = None
|
||||
_owned_panel: object | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
def _safe_error(phase: str, err: BaseException) -> str:
|
||||
"""Return a support-safe fingerprint without an exception message."""
|
||||
return f"{phase}:{type(err).__name__}"
|
||||
|
||||
|
||||
def _panel_registry(hass: HomeAssistant) -> Mapping[str, object] | None:
|
||||
"""Read HA's in-memory panel registry without mutating private storage."""
|
||||
key = getattr(frontend, "DATA_PANELS", None)
|
||||
if not isinstance(key, str):
|
||||
return None
|
||||
panels = hass.data.get(key)
|
||||
return panels if isinstance(panels, Mapping) else None
|
||||
|
||||
|
||||
def _is_current(hass: HomeAssistant, state: PanelRegistrationState) -> bool:
|
||||
"""Return whether state still owns the current setup generation."""
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
return (
|
||||
domain_data.get(PANEL_REGISTRATION_KEY) is state
|
||||
and domain_data.get(PANEL_GENERATION_KEY) == state.generation
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _remove_owned_panel(hass: HomeAssistant, state: PanelRegistrationState) -> None:
|
||||
"""Remove only the exact registry object owned by this generation."""
|
||||
if not _is_current(hass, state) or state._owned_panel is None:
|
||||
return
|
||||
|
||||
panels = _panel_registry(hass)
|
||||
if panels is None:
|
||||
# An unknown registry shape is not authority to remove a route by name.
|
||||
return
|
||||
if panels.get(PANEL_URL_PATH) is not state._owned_panel:
|
||||
# The route was removed or replaced outside this integration. It is no
|
||||
# longer ours, and a later cleanup must not reconsider that identity.
|
||||
state._owned_panel = None
|
||||
state.panel_status = "removed"
|
||||
state.panel_url = None
|
||||
state.panel_module_url = None
|
||||
return
|
||||
|
||||
try:
|
||||
# Keep the HA 2024.6-compatible two-argument call. In particular, do
|
||||
# not pass the newer warn_if_unknown keyword.
|
||||
frontend.async_remove_panel(hass, PANEL_URL_PATH)
|
||||
except Exception as err: # noqa: BLE001 - unload remains fail-soft
|
||||
fingerprint = _safe_error("remove", err)
|
||||
state.panel_error = fingerprint
|
||||
_LOGGER.warning("Could not remove the owned House Plan panel (%s)", fingerprint)
|
||||
return
|
||||
|
||||
state._owned_panel = None
|
||||
state.panel_status = "removed"
|
||||
state.panel_url = None
|
||||
state.panel_module_url = None
|
||||
|
||||
|
||||
@callback
|
||||
def remove_panel_registration(hass: HomeAssistant) -> None:
|
||||
"""Idempotently clean up the current owned panel, if one still exists."""
|
||||
state = get_panel_registration_state(hass)
|
||||
if state is not None:
|
||||
_remove_owned_panel(hass, state)
|
||||
|
||||
|
||||
async def async_setup_panel_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
panel_path: Path,
|
||||
) -> PanelRegistrationState:
|
||||
"""Register the panel last without making integration setup depend on it."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
previous = domain_data.get(PANEL_REGISTRATION_KEY)
|
||||
if isinstance(previous, PanelRegistrationState):
|
||||
# Normally entry unload already did this. Keeping setup self-contained
|
||||
# also makes a direct repeated setup deterministic and duplicate-free.
|
||||
_remove_owned_panel(hass, previous)
|
||||
|
||||
old_generation = domain_data.get(PANEL_GENERATION_KEY)
|
||||
generation = old_generation + 1 if isinstance(old_generation, int) else 1
|
||||
domain_data[PANEL_GENERATION_KEY] = generation
|
||||
|
||||
panel_file_present = panel_path.is_file()
|
||||
state = PanelRegistrationState(
|
||||
panel_file_present=panel_file_present,
|
||||
generation=generation,
|
||||
)
|
||||
domain_data[PANEL_REGISTRATION_KEY] = state
|
||||
|
||||
if not panel_file_present:
|
||||
state.panel_status = "missing_asset"
|
||||
_LOGGER.warning("houseplan-panel.js was not found next to the integration")
|
||||
return state
|
||||
|
||||
static_outcome = await async_register_frontend_static_path(
|
||||
hass, PANEL_FRONTEND_URL, panel_path
|
||||
)
|
||||
state.panel_static_path_registered = static_outcome.registered
|
||||
if not static_outcome.registered:
|
||||
state.panel_status = "static_error"
|
||||
state.panel_error = static_outcome.last_error
|
||||
_LOGGER.warning(
|
||||
"Could not register the House Plan panel static path (%s)",
|
||||
static_outcome.last_error or "static_path:UnknownError",
|
||||
)
|
||||
return state
|
||||
|
||||
module_url = f"{PANEL_FRONTEND_URL}?v={VERSION}"
|
||||
try:
|
||||
await panel_custom.async_register_panel(
|
||||
hass=hass,
|
||||
frontend_url_path=PANEL_URL_PATH,
|
||||
webcomponent_name=PANEL_COMPONENT_NAME,
|
||||
sidebar_title=PANEL_TITLE,
|
||||
sidebar_icon=PANEL_ICON,
|
||||
module_url=module_url,
|
||||
embed_iframe=False,
|
||||
trust_external=False,
|
||||
require_admin=False,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ValueError as err:
|
||||
state.panel_error = _safe_error("registration", err)
|
||||
panels = _panel_registry(hass)
|
||||
if panels is not None and PANEL_URL_PATH in panels:
|
||||
state.panel_status = "collision"
|
||||
_LOGGER.warning(
|
||||
"House Plan cannot register /%s because that panel route is "
|
||||
"already in use; open House Plan in System Health for details",
|
||||
PANEL_URL_PATH,
|
||||
)
|
||||
else:
|
||||
state.panel_status = "registration_error"
|
||||
_LOGGER.warning(
|
||||
"Could not register the House Plan panel (%s)", state.panel_error
|
||||
)
|
||||
return state
|
||||
except Exception as err: # noqa: BLE001 - the card/backend remain available
|
||||
state.panel_status = "registration_error"
|
||||
state.panel_error = _safe_error("registration", err)
|
||||
_LOGGER.warning(
|
||||
"Could not register the House Plan panel (%s)", state.panel_error
|
||||
)
|
||||
return state
|
||||
|
||||
panels = _panel_registry(hass)
|
||||
owned_panel = panels.get(PANEL_URL_PATH) if panels is not None else None
|
||||
if owned_panel is None:
|
||||
# The public register API has no ownership return value. If the exact
|
||||
# object cannot be captured now, leaving a route until a later blind
|
||||
# cleanup would be unsafe. Remove the just-created route immediately.
|
||||
state.panel_status = "ownership_error"
|
||||
state.panel_error = "ownership:Unavailable"
|
||||
try:
|
||||
frontend.async_remove_panel(hass, PANEL_URL_PATH)
|
||||
except Exception as err: # noqa: BLE001 - setup must still succeed
|
||||
_LOGGER.warning(
|
||||
"Could not clean up an unowned House Plan panel (%s)",
|
||||
_safe_error("ownership_remove", err),
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"House Plan panel ownership could not be verified; the panel was removed"
|
||||
)
|
||||
return state
|
||||
|
||||
state._owned_panel = owned_panel
|
||||
state.panel_status = "registered"
|
||||
state.panel_url = PANEL_URL
|
||||
state.panel_module_url = module_url
|
||||
entry.async_on_unload(partial(_remove_owned_panel, hass, state))
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def get_panel_registration_state(
|
||||
hass: HomeAssistant,
|
||||
) -> PanelRegistrationState | None:
|
||||
"""Return the current panel state for System Health."""
|
||||
state = hass.data.get(DOMAIN, {}).get(PANEL_REGISTRATION_KEY)
|
||||
return state if isinstance(state, PanelRegistrationState) else None
|
||||
@@ -6,6 +6,9 @@
|
||||
"data": { "admin_only": "Only administrators may edit the layout" }
|
||||
}
|
||||
},
|
||||
"create_entry": {
|
||||
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
|
||||
},
|
||||
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
|
||||
},
|
||||
"options": {
|
||||
@@ -19,6 +22,28 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan is ready",
|
||||
"description": "Open House Plan from the sidebar. After an update, fully reload the page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). The dashboard card remains available; if you manage its resource manually in storage mode, open Settings → Dashboards → Resources. If the sidebar item is missing, check House Plan in System Health."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice",
|
||||
"panel_file": "House Plan panel file",
|
||||
"panel_static_path": "Panel static frontend path",
|
||||
"panel_status": "Panel registration status",
|
||||
"panel_url": "House Plan panel URL",
|
||||
"panel_module_url": "Panel module URL",
|
||||
"panel_error": "Panel registration error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,21 +325,6 @@ def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[
|
||||
}
|
||||
for item in space.get("walls") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["room_drafts"] = []
|
||||
for draft in space.get("room_drafts") or []:
|
||||
if not isinstance(draft, dict):
|
||||
continue
|
||||
out["room_drafts"].append({
|
||||
"id": ids.get("draft", draft.get("id")),
|
||||
"points": _points(draft.get("points")),
|
||||
"segments": [
|
||||
{
|
||||
**({"id": ids.get("wall", segment.get("id"))} if segment.get("id") else {}),
|
||||
"cm": segment.get("cm"),
|
||||
}
|
||||
for segment in draft.get("segments") or [] if isinstance(segment, dict)
|
||||
],
|
||||
})
|
||||
out["partitions"] = [
|
||||
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
|
||||
"b": _point(item.get("b")), "cm": item.get("cm")}
|
||||
@@ -429,7 +414,6 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
return {
|
||||
"spaces": len(spaces),
|
||||
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
|
||||
"room_drafts": sum(len(space.get("room_drafts") or []) for space in spaces if isinstance(space, dict)),
|
||||
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
|
||||
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
|
||||
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import Any
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .frontend_registration import get_frontend_registration_state
|
||||
from .panel_registration import get_panel_registration_state
|
||||
from .store import get_data
|
||||
|
||||
|
||||
@@ -23,13 +25,82 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
return {
|
||||
result = {
|
||||
"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", {})),
|
||||
}
|
||||
panel = get_panel_registration_state(hass)
|
||||
if panel is None:
|
||||
result.update(
|
||||
{
|
||||
"panel_file": "missing",
|
||||
"panel_static_path": "not_registered",
|
||||
"panel_status": "not_attempted",
|
||||
"panel_url": "unavailable",
|
||||
"panel_module_url": "unavailable",
|
||||
"panel_error": "none",
|
||||
}
|
||||
)
|
||||
else:
|
||||
result.update(
|
||||
{
|
||||
"panel_file": (
|
||||
"present" if panel.panel_file_present else "missing"
|
||||
),
|
||||
"panel_static_path": (
|
||||
"registered"
|
||||
if panel.panel_static_path_registered
|
||||
else "not_registered"
|
||||
),
|
||||
"panel_status": panel.panel_status,
|
||||
"panel_url": panel.panel_url or "unavailable",
|
||||
"panel_module_url": panel.panel_module_url or "unavailable",
|
||||
"panel_error": panel.panel_error or "none",
|
||||
}
|
||||
)
|
||||
|
||||
frontend = get_frontend_registration_state(hass)
|
||||
if frontend is None:
|
||||
result.update(
|
||||
{
|
||||
"card_file": "missing",
|
||||
"static_path": "not_registered",
|
||||
"resource_status": "not_attempted",
|
||||
"resource_loader": "none",
|
||||
"resource_url": "unavailable",
|
||||
"resource_retry": "not_needed",
|
||||
"resource_error": "none",
|
||||
"first_reload_notice": "pending_frontend",
|
||||
}
|
||||
)
|
||||
else:
|
||||
result.update(
|
||||
{
|
||||
"card_file": (
|
||||
"present" if frontend.card_file_present else "missing"
|
||||
),
|
||||
"static_path": (
|
||||
"registered"
|
||||
if frontend.static_path_registered
|
||||
else "not_registered"
|
||||
),
|
||||
"resource_status": frontend.resource_status,
|
||||
"resource_loader": frontend.loader,
|
||||
"resource_url": frontend.module_url or "unavailable",
|
||||
"resource_retry": (
|
||||
"pending"
|
||||
if frontend.retry_pending
|
||||
else "attempted"
|
||||
if frontend.retry_attempted
|
||||
else "not_needed"
|
||||
),
|
||||
"resource_error": frontend.last_error or "none",
|
||||
"first_reload_notice": frontend.first_reload_notice,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"create_entry": {
|
||||
"panel_ready": "Öffnen Sie House Plan über die Seitenleiste, wenn der Eintrag verfügbar ist. Für die Bearbeitung wird ein Desktop-Browser empfohlen. Fehlt der Eintrag, verwenden Sie die House-Plan-Dashboard-Karte und prüfen Sie die Systeminformationen."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
|
||||
}
|
||||
@@ -25,6 +28,28 @@
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan ist bereit",
|
||||
"description": "Öffnen Sie House Plan über die Seitenleiste. Laden Sie die Seite nach einem Update vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Die Dashboard-Karte bleibt verfügbar; wenn Sie deren Ressource im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen. Fehlt der Seitenleisteneintrag, prüfen Sie House Plan in den Systeminformationen."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Datei der House-Plan-Karte",
|
||||
"static_path": "Statischer Frontend-Pfad",
|
||||
"resource_status": "Status der Ressourcenregistrierung",
|
||||
"resource_loader": "Lademethode der Frontend-Ressource",
|
||||
"resource_url": "URL der Frontend-Ressource",
|
||||
"resource_retry": "Wiederholung der Ressourcenregistrierung",
|
||||
"resource_error": "Fehler der Frontend-Registrierung",
|
||||
"first_reload_notice": "Erster Hinweis zum Neuladen",
|
||||
"panel_file": "Datei des House-Plan-Panels",
|
||||
"panel_static_path": "Statischer Frontend-Pfad des Panels",
|
||||
"panel_status": "Status der Panel-Registrierung",
|
||||
"panel_url": "URL des House-Plan-Panels",
|
||||
"panel_module_url": "Modul-URL des Panels",
|
||||
"panel_error": "Fehler der Panel-Registrierung"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"create_entry": {
|
||||
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured — only one entry is allowed."
|
||||
}
|
||||
@@ -25,6 +28,28 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan is ready",
|
||||
"description": "Open House Plan from the sidebar. After an update, fully reload the page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). The dashboard card remains available; if you manage its resource manually in storage mode, open Settings → Dashboards → Resources. If the sidebar item is missing, check House Plan in System Health."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice",
|
||||
"panel_file": "House Plan panel file",
|
||||
"panel_static_path": "Panel static frontend path",
|
||||
"panel_status": "Panel registration status",
|
||||
"panel_url": "House Plan panel URL",
|
||||
"panel_module_url": "Panel module URL",
|
||||
"panel_error": "Panel registration error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"create_entry": {
|
||||
"panel_ready": "Ouvrez House Plan depuis la barre latérale lorsque l’entrée est disponible. Un navigateur sur ordinateur est recommandé pour la modification. Si l’entrée est absente, utilisez la carte House Plan du tableau de bord et consultez les informations système."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
|
||||
}
|
||||
@@ -25,6 +28,28 @@
|
||||
"broken_plan": {
|
||||
"title": "L’image du plan est introuvable",
|
||||
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan est prêt",
|
||||
"description": "Ouvrez House Plan depuis la barre latérale. Après une mise à jour, rechargez complètement la page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). La carte de tableau de bord reste disponible ; si vous gérez manuellement sa ressource en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources. Si l’entrée de la barre latérale est absente, consultez House Plan dans les informations système."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Fichier de la carte House Plan",
|
||||
"static_path": "Chemin statique de l’interface",
|
||||
"resource_status": "État de l’enregistrement de la ressource",
|
||||
"resource_loader": "Mode de chargement de la ressource",
|
||||
"resource_url": "URL de la ressource de l’interface",
|
||||
"resource_retry": "Nouvelle tentative d’enregistrement",
|
||||
"resource_error": "Erreur d’enregistrement de l’interface",
|
||||
"first_reload_notice": "Premier avis de rechargement",
|
||||
"panel_file": "Fichier du panneau House Plan",
|
||||
"panel_static_path": "Chemin statique du panneau",
|
||||
"panel_status": "État de l’enregistrement du panneau",
|
||||
"panel_url": "URL du panneau House Plan",
|
||||
"panel_module_url": "URL du module du panneau",
|
||||
"panel_error": "Erreur d’enregistrement du panneau"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"create_entry": {
|
||||
"panel_ready": "Откройте House Plan в боковом меню, если пункт появился. Для редактирования рекомендуется браузер на компьютере. Если пункта нет, используйте карточку House Plan на панели и проверьте информацию о системе."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Уже настроено — допускается одна запись."
|
||||
}
|
||||
@@ -25,6 +28,28 @@
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan готов к работе",
|
||||
"description": "Откройте House Plan в боковом меню. После обновления полностью перезагрузите страницу, чтобы использовать актуальный интерфейс (`Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS). Карточка для панели остаётся доступна; если вы управляете её ресурсом вручную в режиме хранения, откройте Настройки → Панели → Ресурсы. Если пункта в боковом меню нет, проверьте House Plan в информации о системе."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Файл карточки House Plan",
|
||||
"static_path": "Статический путь интерфейса",
|
||||
"resource_status": "Статус регистрации ресурса",
|
||||
"resource_loader": "Способ загрузки ресурса",
|
||||
"resource_url": "URL ресурса интерфейса",
|
||||
"resource_retry": "Повтор регистрации ресурса",
|
||||
"resource_error": "Ошибка регистрации интерфейса",
|
||||
"first_reload_notice": "Первое уведомление о перезагрузке",
|
||||
"panel_file": "Файл панели House Plan",
|
||||
"panel_static_path": "Статический путь панели",
|
||||
"panel_status": "Статус регистрации панели",
|
||||
"panel_url": "URL панели House Plan",
|
||||
"panel_module_url": "URL модуля панели",
|
||||
"panel_error": "Ошибка регистрации панели"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,17 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
|
||||
except (TypeError, ValueError):
|
||||
return # CONFIG_SCHEMA owns malformed values.
|
||||
previous = previous or {}
|
||||
if old_model >= 10 and new_model < 10 and any(
|
||||
"room_drafts" in space for space in config.get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
):
|
||||
# A v9 card can echo a cached draft without changing the contour
|
||||
# projection at all. The old catalogue guard therefore cannot see the
|
||||
# destructive downgrade, but v10 must never persist the removed
|
||||
# carrier again (#478 AC2).
|
||||
raise WallModelClientOutdatedError(
|
||||
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
|
||||
)
|
||||
contour_geometry_changed = (
|
||||
_catalog_coupled_wall_geometry_projection(config)
|
||||
!= _catalog_coupled_wall_geometry_projection(previous)
|
||||
@@ -1090,6 +1101,15 @@ def _finite(value):
|
||||
return f
|
||||
|
||||
|
||||
def _finite_number_or_none(value):
|
||||
"""A persisted optional number: do not coerce strings or booleans."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise vol.Invalid("temperature threshold must be a finite number or null")
|
||||
return _finite(value)
|
||||
|
||||
|
||||
# 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
|
||||
@@ -1114,8 +1134,8 @@ MAX_DECOR = 1000
|
||||
# later constant declaration.
|
||||
MAX_WALLS = 200_000
|
||||
MAX_WALL_SEGMENTS = 200_000
|
||||
MAX_ROOM_DRAFTS = 200
|
||||
MAX_DRAFT_SEGMENTS = 2000
|
||||
_LEGACY_MAX_ROOM_DRAFTS = 200
|
||||
_LEGACY_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.
|
||||
@@ -1269,6 +1289,8 @@ ROOM_SCHEMA = vol.All(
|
||||
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("temp_min"): _finite_number_or_none,
|
||||
vol.Optional("temp_max"): _finite_number_or_none,
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
},
|
||||
@@ -1484,7 +1506,7 @@ WALL_SEGMENT_SCHEMA = vol.All(
|
||||
)
|
||||
|
||||
|
||||
def _room_draft_segments(value: dict) -> dict:
|
||||
def _legacy_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")
|
||||
@@ -1493,7 +1515,7 @@ def _room_draft_segments(value: dict) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
ROOM_DRAFT_SCHEMA = vol.All(
|
||||
_LEGACY_ROOM_DRAFT_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
@@ -1509,7 +1531,7 @@ ROOM_DRAFT_SCHEMA = vol.All(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_room_draft_segments,
|
||||
_legacy_room_draft_segments,
|
||||
)
|
||||
|
||||
def _partition_nonzero(value: dict) -> dict:
|
||||
@@ -1592,7 +1614,7 @@ def _space_geometry_invariants(value: dict) -> dict:
|
||||
draft_segments = sum(
|
||||
len(item.get("segments", [])) for item in value.get("room_drafts", [])
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
if draft_segments > _LEGACY_MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
for draft in value.get("room_drafts", []):
|
||||
for segment in draft.get("segments", []):
|
||||
@@ -1706,7 +1728,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
),
|
||||
vol.Optional("zero_wall_style"): vol.In(["dashed", "solid"]),
|
||||
vol.Optional("room_drafts"): vol.All(
|
||||
[ROOM_DRAFT_SCHEMA], vol.Length(max=MAX_ROOM_DRAFTS)
|
||||
[_LEGACY_ROOM_DRAFT_SCHEMA], vol.Length(max=_LEGACY_MAX_ROOM_DRAFTS)
|
||||
),
|
||||
vol.Optional("partitions"): vol.All(
|
||||
[PARTITION_SCHEMA], vol.Length(max=MAX_PARTITIONS)
|
||||
@@ -1877,6 +1899,8 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
if model >= 9:
|
||||
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
|
||||
raise vol.Invalid("v9 config must not contain legacy open boundaries")
|
||||
if model >= 10 and "room_drafts" in space:
|
||||
raise vol.Invalid("v10 config must not contain room_drafts")
|
||||
segments = space.get("wall_segments")
|
||||
if segments is None:
|
||||
raise vol.Invalid("v8+ space requires wall_segments")
|
||||
@@ -1917,9 +1941,10 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
if actual_walls != expected_walls:
|
||||
raise vol.Invalid("wall compatibility projection must match wall_segments")
|
||||
|
||||
for draft in space.get("room_drafts", []):
|
||||
if any(not segment.get("id") for segment in draft.get("segments", [])):
|
||||
raise vol.Invalid("v8+ draft wall segments require ids")
|
||||
if model < 10:
|
||||
for draft in space.get("room_drafts", []):
|
||||
if any(not segment.get("id") for segment in draft.get("segments", [])):
|
||||
raise vol.Invalid("v8+ draft wall segments require ids")
|
||||
|
||||
partitions = {item["id"]: item for item in space.get("partitions", [])}
|
||||
wall_opening_intervals: dict[str, list[tuple[float, float]]] = {}
|
||||
@@ -1967,6 +1992,141 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
# #437: the summary is shared configuration, so its wire shape is validated by
|
||||
# the integration rather than trusted to whichever browser happened to save it.
|
||||
def _summary_text(max_length: int):
|
||||
"""Trim user text and reject whitespace-only/raw oversized WS values."""
|
||||
def validate(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise vol.Invalid("summary text must be a string")
|
||||
text = value.strip()
|
||||
if not text or len(text) > max_length:
|
||||
raise vol.Invalid("summary text length is invalid")
|
||||
return text
|
||||
return validate
|
||||
|
||||
|
||||
_SUMMARY_ID = _summary_text(64)
|
||||
_SUMMARY_TITLE = _summary_text(48)
|
||||
_SUMMARY_LABEL = _summary_text(64)
|
||||
_SUMMARY_SCOPE = vol.Any(
|
||||
vol.Schema({vol.Required("type"): vol.Equal("all")}, extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({
|
||||
vol.Required("type"): vol.Equal("space"),
|
||||
vol.Required("space_id"): _SUMMARY_ID,
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
_SUMMARY_SOURCE = vol.Any(
|
||||
vol.Schema({
|
||||
vol.Required("type"): vol.Equal("system"),
|
||||
vol.Required("key"): vol.In(["device_count", "total_area", "datetime"]),
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({
|
||||
vol.Required("type"): vol.Equal("entity"),
|
||||
vol.Required("entity_id"): _summary_text(500),
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
_SUMMARY_VALUE_SCHEMA = vol.Schema({
|
||||
vol.Required("id"): _SUMMARY_ID,
|
||||
vol.Required("label"): _SUMMARY_LABEL,
|
||||
vol.Required("source"): _SUMMARY_SOURCE,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
_SUMMARY_BLOCK_SCHEMA = vol.Schema({
|
||||
vol.Required("id"): _SUMMARY_ID,
|
||||
vol.Required("title"): _SUMMARY_TITLE,
|
||||
vol.Required("visible"): bool,
|
||||
vol.Required("scope"): _SUMMARY_SCOPE,
|
||||
vol.Required("values"): vol.All([_SUMMARY_VALUE_SCHEMA], vol.Length(max=20)),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def _summary_panel_invariants(value: dict) -> dict:
|
||||
"""Stable block/value ids are the reference identity used by the editor."""
|
||||
block_ids: set[str] = set()
|
||||
value_ids: set[str] = set()
|
||||
for block in value.get("blocks", []):
|
||||
block_id = block["id"]
|
||||
if block_id in block_ids:
|
||||
raise vol.Invalid("summary block ids must be unique")
|
||||
block_ids.add(block_id)
|
||||
for item in block.get("values", []):
|
||||
value_id = item["id"]
|
||||
if value_id in value_ids:
|
||||
raise vol.Invalid("summary value ids must be unique")
|
||||
value_ids.add(value_id)
|
||||
return value
|
||||
|
||||
|
||||
SUMMARY_PANEL_SCHEMA = vol.All(vol.Schema({
|
||||
vol.Required("version"): vol.Equal(1),
|
||||
vol.Required("title"): _SUMMARY_TITLE,
|
||||
vol.Required("show_on_mobile"): bool,
|
||||
vol.Required("blocks"): vol.All([_SUMMARY_BLOCK_SCHEMA], vol.Length(max=10)),
|
||||
}, extra=vol.ALLOW_EXTRA), _summary_panel_invariants)
|
||||
|
||||
|
||||
def _future_summary_panel(value: object) -> dict:
|
||||
"""Keep a newer opaque namespace losslessly, with a bounded data envelope."""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("summary panel must be an object")
|
||||
version = value.get("version")
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version <= 1:
|
||||
raise vol.Invalid("unsupported summary panel version")
|
||||
if len(json.dumps(value, separators=(",", ":"), ensure_ascii=False)) > 128 * 1024:
|
||||
raise vol.Invalid("summary panel is too large")
|
||||
return value
|
||||
|
||||
|
||||
SUMMARY_PANEL_WIRE_SCHEMA = vol.Any(SUMMARY_PANEL_SCHEMA, _future_summary_panel)
|
||||
|
||||
|
||||
def preserve_summary_panel_namespace(config: dict, previous: dict | None) -> None:
|
||||
"""A stale client that does not know #437 cannot erase the shared panel."""
|
||||
old_settings = (previous or {}).get("settings")
|
||||
new_settings = config.get("settings")
|
||||
if not isinstance(old_settings, dict) or "summary_panel" not in old_settings:
|
||||
return
|
||||
if not isinstance(new_settings, dict):
|
||||
new_settings = {}
|
||||
config["settings"] = new_settings
|
||||
if "summary_panel" not in new_settings:
|
||||
new_settings["summary_panel"] = copy.deepcopy(old_settings["summary_panel"])
|
||||
|
||||
|
||||
def validate_summary_panel_references(
|
||||
config: dict, previous: dict | None, readable_entity_ids: set[str]
|
||||
) -> None:
|
||||
"""Only newly introduced broken refs fail; stable-id legacy refs warn in UI."""
|
||||
panel = (config.get("settings") or {}).get("summary_panel")
|
||||
if not isinstance(panel, dict) or panel.get("version") != 1:
|
||||
return
|
||||
old_panel = ((previous or {}).get("settings") or {}).get("summary_panel")
|
||||
old_blocks = {
|
||||
block.get("id"): block for block in (old_panel or {}).get("blocks", [])
|
||||
if isinstance(block, dict) and block.get("id")
|
||||
} if isinstance(old_panel, dict) and old_panel.get("version") == 1 else {}
|
||||
old_values = {
|
||||
item.get("id"): item
|
||||
for block in old_blocks.values()
|
||||
for item in block.get("values", [])
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
}
|
||||
spaces = {space.get("id") for space in config.get("spaces", []) if isinstance(space, dict)}
|
||||
for block in panel.get("blocks", []):
|
||||
scope = block.get("scope") or {}
|
||||
if scope.get("type") == "space" and scope.get("space_id") not in spaces:
|
||||
old_scope = (old_blocks.get(block.get("id"), {}).get("scope") or {})
|
||||
if old_scope.get("type") != "space" or old_scope.get("space_id") != scope.get("space_id"):
|
||||
raise vol.Invalid("new summary space reference must exist")
|
||||
for item in block.get("values", []):
|
||||
source = item.get("source") or {}
|
||||
entity_id = source.get("entity_id") if source.get("type") == "entity" else None
|
||||
if entity_id and entity_id not in readable_entity_ids:
|
||||
old_source = (old_values.get(item.get("id"), {}).get("source") or {})
|
||||
if old_source.get("type") != "entity" or old_source.get("entity_id") != entity_id:
|
||||
raise vol.Invalid("new summary entity reference must be readable")
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
@@ -1997,6 +2157,7 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
vol.Optional("summary_panel"): SUMMARY_PANEL_WIRE_SCHEMA,
|
||||
# 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Deterministic persisted contour-wall identity (model v9, issues #282/#306).
|
||||
"""Deterministic persisted contour-wall identity (model v10, issues #282/#478).
|
||||
|
||||
This is the backend twin of ``src/wall-segment-model.ts``. Import preview is
|
||||
a server-side structural writer, so it cannot depend on a browser being open
|
||||
@@ -10,14 +10,17 @@ import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from .coordinate_canonicalization import canonicalize_config_geometry
|
||||
|
||||
WALL_SEGMENT_MODEL_VERSION = 9
|
||||
WALL_SEGMENT_MODEL_VERSION = 10
|
||||
GRID_STEP_N = 1 / 240
|
||||
EPS = 1e-9
|
||||
LEGACY_DRAFT_POINT_EPSILON = 0.001
|
||||
_LEGACY_NUMBER = re.compile(r"^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$")
|
||||
|
||||
|
||||
class WallSegmentMigrationError(ValueError):
|
||||
@@ -30,6 +33,29 @@ class WallSegmentMigrationError(ValueError):
|
||||
super().__init__(f"{reason}: {detail}" if detail else reason)
|
||||
|
||||
|
||||
def _legacy_finite_number(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
return None
|
||||
if isinstance(value, str) and not _LEGACY_NUMBER.fullmatch(value.strip()):
|
||||
return None
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if math.isfinite(parsed) else None
|
||||
|
||||
|
||||
def _legacy_draft_point(value: Any) -> list[float] | None:
|
||||
if not isinstance(value, list) or len(value) < 2:
|
||||
return None
|
||||
x, y = _legacy_finite_number(value[0]), _legacy_finite_number(value[1])
|
||||
return None if x is None or y is None else [x, y]
|
||||
|
||||
|
||||
def _legacy_identity(value: Any) -> str:
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _point_key(point: list[float]) -> str:
|
||||
return f"{float(point[0]):.12f},{float(point[1]):.12f}"
|
||||
|
||||
@@ -618,7 +644,74 @@ def _host_openings(
|
||||
opening.pop("host", None)
|
||||
|
||||
|
||||
def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
def _migrate_room_drafts_to_partitions(
|
||||
space: dict[str, Any], initial_migration: bool,
|
||||
) -> tuple[int, int]:
|
||||
if "room_drafts" not in space:
|
||||
return 0, 0
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")
|
||||
drafts = space.get("room_drafts") or []
|
||||
used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "partitions",
|
||||
"wall_columns", "wall_segments")
|
||||
for item in space.get(name) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
||||
}
|
||||
used.update(
|
||||
str(draft["id"]) for draft in drafts
|
||||
if isinstance(draft, dict) and isinstance(draft.get("id"), str) and draft["id"]
|
||||
)
|
||||
partitions = copy.deepcopy(space.get("partitions") or [])
|
||||
converted = 0
|
||||
for draft in drafts:
|
||||
draft_identity = _legacy_identity(draft.get("id")) if isinstance(draft, dict) else ""
|
||||
if not isinstance(draft, dict):
|
||||
raise WallSegmentMigrationError("zero-length", draft_identity)
|
||||
points = draft.get("points") if isinstance(draft.get("points"), list) else []
|
||||
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
|
||||
if len(points) < 2 or len(segments) != len(points) - 1:
|
||||
raise WallSegmentMigrationError("zero-length", draft_identity)
|
||||
for index, segment in enumerate(segments):
|
||||
a = _legacy_draft_point(points[index])
|
||||
b = _legacy_draft_point(points[index + 1])
|
||||
if a is None or b is None or (
|
||||
abs(a[0] - b[0]) < LEGACY_DRAFT_POINT_EPSILON
|
||||
and abs(a[1] - b[1]) < LEGACY_DRAFT_POINT_EPSILON
|
||||
):
|
||||
raise WallSegmentMigrationError("zero-length", draft_identity)
|
||||
cm = _legacy_finite_number(segment.get("cm") if isinstance(segment, dict) else None)
|
||||
if cm is None or cm < 0 or cm > 100:
|
||||
raise WallSegmentMigrationError("thickness-conflict", draft_identity)
|
||||
segment_id = segment.get("id") if isinstance(segment, dict) and isinstance(segment.get("id"), str) else ""
|
||||
if not segment_id or segment_id in used:
|
||||
base = deterministic_wall_segment_id(
|
||||
_legacy_identity(space.get("id")), a, b,
|
||||
[f"draft:{draft_identity}:{index}"],
|
||||
)
|
||||
segment_id = base
|
||||
suffix = 2
|
||||
while segment_id in used:
|
||||
segment_id = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
used.add(segment_id)
|
||||
partitions.append({"id": segment_id, "a": a, "b": b, "cm": cm})
|
||||
converted += 1
|
||||
if partitions:
|
||||
space["partitions"] = partitions
|
||||
else:
|
||||
space.pop("partitions", None)
|
||||
space.pop("room_drafts", None)
|
||||
return len(drafts), converted
|
||||
|
||||
|
||||
def _migrate_space(
|
||||
space: dict[str, Any], initial_migration: bool,
|
||||
) -> tuple[int, int, int]:
|
||||
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(
|
||||
space, initial_migration,
|
||||
)
|
||||
old: dict[str, dict[str, Any]] = {}
|
||||
for segment in space.get("wall_segments") or []:
|
||||
segment_id = str(segment.get("id", ""))
|
||||
@@ -652,47 +745,35 @@ def _migrate_space(space: dict[str, Any], initial_migration: bool) -> int:
|
||||
space.pop("open_spans", None)
|
||||
for room in space.get("rooms") or []:
|
||||
room.pop("open_to", None)
|
||||
draft_used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "room_drafts", "partitions",
|
||||
"wall_columns", "wall_segments")
|
||||
for item in space.get(name) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
||||
}
|
||||
for draft in space.get("room_drafts") or []:
|
||||
for index, segment in enumerate(draft.get("segments") or []):
|
||||
if isinstance(segment.get("id"), str) and segment["id"]:
|
||||
if segment["id"] in draft_used:
|
||||
raise WallSegmentMigrationError("duplicate-id", segment["id"])
|
||||
draft_used.add(segment["id"])
|
||||
continue
|
||||
try:
|
||||
a, b = draft["points"][index], draft["points"][index + 1]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
raise WallSegmentMigrationError("zero-length", str(draft.get("id", ""))) from None
|
||||
if initial_migration:
|
||||
base = deterministic_wall_segment_id(
|
||||
str(space.get("id", "")), a, b, [f"draft:{draft.get('id', '')}"],
|
||||
)
|
||||
suffix, segment["id"] = 1, base
|
||||
while segment["id"] in draft_used:
|
||||
suffix += 1
|
||||
segment["id"] = f"{base}-{suffix}"
|
||||
else:
|
||||
segment["id"] = _fresh_wall_segment_id(draft_used)
|
||||
draft_used.add(segment["id"])
|
||||
_host_openings(space, segments, initial_migration)
|
||||
return sum(1 for segment in segments if segment["id"] not in old)
|
||||
return (
|
||||
sum(1 for segment in segments if segment["id"] not in old),
|
||||
migrated_drafts,
|
||||
migrated_draft_segments,
|
||||
)
|
||||
|
||||
|
||||
def commit_wall_segment_model(config: Any) -> tuple[Any, int]:
|
||||
def commit_wall_segment_model(
|
||||
config: Any, *, migration_report: dict[str, int] | None = None,
|
||||
) -> tuple[Any, int]:
|
||||
"""Return one migrated deep copy and the count of newly assigned wall ids."""
|
||||
candidate = canonicalize_config_geometry(copy.deepcopy(config))
|
||||
if not isinstance(candidate, dict):
|
||||
raise WallSegmentMigrationError("invalid-room")
|
||||
migrated = 0
|
||||
migrated_drafts = 0
|
||||
migrated_draft_segments = 0
|
||||
initial_migration = int(candidate.get("model_version", 0) or 0) < WALL_SEGMENT_MODEL_VERSION
|
||||
for space in candidate.get("spaces") or []:
|
||||
migrated += _migrate_space(space, initial_migration)
|
||||
wall_segments, drafts, draft_segments = _migrate_space(space, initial_migration)
|
||||
migrated += wall_segments
|
||||
migrated_drafts += drafts
|
||||
migrated_draft_segments += draft_segments
|
||||
candidate["model_version"] = WALL_SEGMENT_MODEL_VERSION
|
||||
if migration_report is not None:
|
||||
migration_report.clear()
|
||||
migration_report.update({
|
||||
"room_drafts": migrated_drafts,
|
||||
"room_draft_segments": migrated_draft_segments,
|
||||
})
|
||||
return canonicalize_config_geometry(candidate), migrated
|
||||
|
||||
@@ -42,6 +42,7 @@ from .const import (
|
||||
PLAN_MODEL_VERSION,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
SUMMARY_PANEL_API_VERSION,
|
||||
SUPPORT_API_VERSION,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
@@ -109,6 +110,7 @@ from .validation import (
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
preserve_summary_panel_namespace,
|
||||
sanitize_filename,
|
||||
valid_space_id,
|
||||
validate_marker_controls,
|
||||
@@ -117,6 +119,7 @@ from .validation import (
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
validate_summary_panel_references,
|
||||
validate_wall_model_transition,
|
||||
)
|
||||
from .virtual_lights import (
|
||||
@@ -1394,6 +1397,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# #423: protocol capability is independent from release skew.
|
||||
"support_api": SUPPORT_API_VERSION,
|
||||
"decor_assets_api": DECOR_ASSETS_API_VERSION,
|
||||
"summary_panel_api": SUMMARY_PANEL_API_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1570,12 +1574,25 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# cached by rev, so a repeated write does not re-judge `previous`.
|
||||
baseline = rt.junction_baseline
|
||||
baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None
|
||||
user = getattr(connection, "user", None)
|
||||
readable_entity_ids: set[str] = set()
|
||||
for state in hass.states.async_all():
|
||||
try:
|
||||
if user and (getattr(user, "is_admin", False)
|
||||
or user.permissions.check_entity(state.entity_id, "read")):
|
||||
readable_entity_ids.add(state.entity_id)
|
||||
except Exception: # noqa: BLE001 - a denied/unknown permission is unreadable
|
||||
continue
|
||||
|
||||
def _validate_config_cpu():
|
||||
preserve_summary_panel_namespace(msg["config"], data.get("config"))
|
||||
validate_wall_model_transition(msg["config"], data.get("config"))
|
||||
checked = CONFIG_SCHEMA(msg["config"])
|
||||
msg["config"].clear()
|
||||
msg["config"].update(checked)
|
||||
validate_summary_panel_references(
|
||||
msg["config"], data.get("config"), readable_entity_ids
|
||||
)
|
||||
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"))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, 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';
|
||||
@@ -8,6 +9,10 @@ import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
import {
|
||||
ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
makeIsometricStage3DenseFixture,
|
||||
} from './performance/isometric-stage3-dense-fixture.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));
|
||||
@@ -15,28 +20,49 @@ 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 profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1', 'large-house-interaction-v1'].includes(profile))
|
||||
if (![
|
||||
'large-house-v1', 'large-house-isometric-v1', ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
'large-house-plan-snap-v1', 'large-house-interaction-v1',
|
||||
].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE;
|
||||
const allowStage2Base = process.argv.includes('--allow-stage2-base');
|
||||
if (allowStage2Base && !stage3Dense)
|
||||
throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile');
|
||||
const requireStage3 = stage3Dense && !allowStage2Base;
|
||||
const isometric = profile === 'large-house-isometric-v1' || stage3Dense;
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const interaction = profile === 'large-house-interaction-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresIsoStructuralBuildCounter = requiresIsometric
|
||||
&& readFileSync(resolve(targetRoot, 'src/houseplan-card.ts'), 'utf8')
|
||||
.includes('private _isoStructuralBuildCount');
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
|
||||
const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS;
|
||||
const sourceSha = (() => {
|
||||
try {
|
||||
return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (isometric && !sourceSha)
|
||||
throw new Error(`${profile} requires an exact git source SHA`);
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
space.room_drafts = [0, 1].map((draft) => {
|
||||
const y = 0.985 + draft * 0.025;
|
||||
return {
|
||||
id: `perf-draft-${floor}-${draft}`,
|
||||
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
|
||||
segments: [{ cm: 15 }, { cm: 20 }],
|
||||
};
|
||||
});
|
||||
space.partitions ||= [];
|
||||
for (const chain of [0, 1]) {
|
||||
const y = 0.985 + chain * 0.025;
|
||||
space.partitions.push(
|
||||
{ id: `perf-chain-${floor}-${chain}-0`, a: [0.10, y], b: [0.38, y], cm: 15 },
|
||||
{ id: `perf-chain-${floor}-${chain}-1`, a: [0.38, y], b: [0.46, y + 0.035], cm: 20 },
|
||||
);
|
||||
}
|
||||
}
|
||||
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
|
||||
fixture.counts = { ...fixture.counts, chainSegments: 12, pointerMoves: 120 };
|
||||
}
|
||||
const viewport = { width: 1440, height: 1000 };
|
||||
|
||||
@@ -73,7 +99,8 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace, interaction, requiresInteraction,
|
||||
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -115,6 +142,15 @@ try {
|
||||
longTasks: await longTasks.stop(),
|
||||
};
|
||||
};
|
||||
// Current candidates split Stage 3 out of the initial graph. Comparison
|
||||
// bundles remain valid: their older monolithic renderer has no preload
|
||||
// hook and is already available synchronously.
|
||||
const ensureIsoRuntime = async (card) => {
|
||||
if (typeof card._ensureIsoSceneRuntime !== 'function') return;
|
||||
if (!(await card._ensureIsoSceneRuntime()))
|
||||
throw new Error(`${profile} isometric runtime did not load`);
|
||||
await card.updateComplete;
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc();
|
||||
@@ -133,6 +169,119 @@ try {
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
});
|
||||
const isoStructuralBuildCount = (card) => Number.isFinite(card._isoStructuralBuildCount)
|
||||
? card._isoStructuralBuildCount : null;
|
||||
const stage3Snapshot = (card) => {
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
// Count the interactive HTML owners, not the diagnostic SVG
|
||||
// ground/tether/plate primitives which deliberately repeat kind/id.
|
||||
const overlayRoots = [...card.renderRoot.querySelectorAll(
|
||||
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
|
||||
)];
|
||||
const overlaySelectors = {
|
||||
device: '[data-hp="device"]',
|
||||
'room-label': '[data-hp="room-label"]',
|
||||
'opening-lock': '.oplock',
|
||||
};
|
||||
const totalRootsByKind = Object.fromEntries(Object.entries(overlaySelectors)
|
||||
.map(([kind, selector]) => [kind, card.renderRoot.querySelectorAll(selector).length]));
|
||||
const stage3RootsByKind = Object.fromEntries(Object.keys(overlaySelectors)
|
||||
.map((kind) => [kind, overlayRoots.filter((root) =>
|
||||
root.getAttribute('data-hp-iso-overlay-kind') === kind).length]));
|
||||
const openingSurfaceCounts = Object.fromEntries(
|
||||
fixture.stage3Dense.expectedOpeningKinds.map((kind) => [kind,
|
||||
card.renderRoot.querySelectorAll(`.iso-opening-panel[data-kind="${kind}"]`).length,
|
||||
]),
|
||||
);
|
||||
const vacuumRoots = [...card.renderRoot.querySelectorAll('.vacpuck, .vactrail')];
|
||||
const materialDefinitions = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]');
|
||||
const definitionCounts = {
|
||||
total: materialDefinitions.length,
|
||||
patterns: card.renderRoot.querySelectorAll('pattern[data-hp-iso-material-def]').length,
|
||||
filters: card.renderRoot.querySelectorAll('filter[data-hp-iso-material-def]').length,
|
||||
};
|
||||
const pulseDeviceId = fixture.stage3Dense?.pulseDeviceIdsBySpace?.[card._space] || '';
|
||||
const pulseRoot = card.renderRoot.querySelector(
|
||||
`.dev[data-id="${CSS.escape(pulseDeviceId)}"]`,
|
||||
);
|
||||
const facetCounts = {
|
||||
value: card.renderRoot.querySelectorAll('.dev .value-badge').length,
|
||||
lqi: card.renderRoot.querySelectorAll('.dev .lqi').length,
|
||||
new: card.renderRoot.querySelectorAll('.dev .newdot').length,
|
||||
pulse: pulseRoot?.querySelectorAll('.device-pulse, .activity-dot').length ?? 0,
|
||||
roomMetrics: card.renderRoot.querySelectorAll('.roomlabel .rlmetrics').length,
|
||||
};
|
||||
const rawBuilds = stage?.getAttribute('data-hp-iso-structural-builds');
|
||||
const structuralBuilds = rawBuilds == null ? null : Number(rawBuilds);
|
||||
return {
|
||||
effectiveProjection: typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null,
|
||||
isoStageRevision: stage?.getAttribute('data-hp-iso-stage') ?? null,
|
||||
structuralBuilds: Number.isFinite(structuralBuilds) ? structuralBuilds : null,
|
||||
renderedOverlayCount: overlayRoots.length,
|
||||
raisedOverlayCount: overlayRoots
|
||||
.filter((root) => root.getAttribute('data-hp-iso-raised') === 'true').length,
|
||||
nudgedOverlayCount: overlayRoots
|
||||
.filter((root) => root.getAttribute('data-hp-iso-nudged') === 'true').length,
|
||||
raisedVacuumCount: vacuumRoots.filter((root) =>
|
||||
root.getAttribute('data-hp-iso-raised') === 'true'
|
||||
|| root.hasAttribute('data-hp-iso-overlay-kind')).length,
|
||||
overlayKinds: [...new Set(overlayRoots
|
||||
.map((root) => root.getAttribute('data-hp-iso-overlay-kind'))
|
||||
.filter(Boolean))].sort(),
|
||||
totalRootsByKind,
|
||||
stage3RootsByKind,
|
||||
openingSurfaceCounts,
|
||||
materialDefinitionCount: definitionCounts.total,
|
||||
definitionCounts,
|
||||
facetCounts,
|
||||
pulseDiagnostics: pulseRoot ? {
|
||||
id: pulseRoot.getAttribute('data-id'),
|
||||
state: pulseRoot.getAttribute('data-state'),
|
||||
className: pulseRoot.className,
|
||||
ariaLabel: pulseRoot.getAttribute('aria-label'),
|
||||
} : null,
|
||||
};
|
||||
};
|
||||
const assertStage3Snapshot = (snapshot, label) => {
|
||||
if (!requireStage3) return;
|
||||
const expectedKinds = fixture.stage3Dense.expectedOverlayKinds;
|
||||
const missingKinds = expectedKinds.filter((kind) => !snapshot.overlayKinds.includes(kind));
|
||||
const failures = [];
|
||||
if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');
|
||||
if (snapshot.isoStageRevision !== '3') failures.push('Stage 3 revision marker is absent');
|
||||
if (snapshot.structuralBuilds == null) failures.push('structural build counter is absent');
|
||||
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
|
||||
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
|
||||
failures.push('one or more Stage 3 overlay roots remained on the floor plane');
|
||||
if (snapshot.nudgedOverlayCount < 1) failures.push('dense fixture produced no bounded nudge');
|
||||
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
|
||||
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
|
||||
for (const kind of expectedKinds) {
|
||||
const total = snapshot.totalRootsByKind[kind];
|
||||
const stage3 = snapshot.stage3RootsByKind[kind];
|
||||
if (total < 1) failures.push(`no rendered ${kind} roots`);
|
||||
else if (stage3 !== total)
|
||||
failures.push(`${kind} Stage 3 roots ${stage3}/${total}`);
|
||||
}
|
||||
for (const kind of fixture.stage3Dense.expectedOpeningKinds) {
|
||||
if (!(snapshot.openingSurfaceCounts[kind] > 0))
|
||||
failures.push(`no Stage 3 ${kind} opening surfaces`);
|
||||
}
|
||||
for (const [facet, count] of Object.entries(snapshot.facetCounts)) {
|
||||
if (!(count > 0)) failures.push(`no observable ${facet} facet${facet === 'pulse'
|
||||
? ` (${JSON.stringify(snapshot.pulseDiagnostics)})` : ''}`);
|
||||
}
|
||||
if (snapshot.materialDefinitionCount < 1
|
||||
|| snapshot.materialDefinitionCount > fixture.stage3Dense.materialDefinitionLimit) {
|
||||
failures.push(`material definitions ${snapshot.materialDefinitionCount}`
|
||||
+ ` exceed 1..${fixture.stage3Dense.materialDefinitionLimit}`);
|
||||
}
|
||||
if (!(snapshot.definitionCounts.patterns > 0)) failures.push('no shared texture pattern');
|
||||
if (!(snapshot.definitionCounts.filters > 0)) failures.push('no shared shadow filter');
|
||||
if (failures.length)
|
||||
throw new Error(`Stage 3 ${label} contract failed: ${failures.join('; ')}`);
|
||||
};
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
@@ -200,6 +349,7 @@ try {
|
||||
// construction; monolithic stable bundles remain immediate (#380).
|
||||
if (!await window.__hpEnsureHarnessEditorRuntime(card))
|
||||
throw new Error('large-house editor runtime did not preload');
|
||||
if (isometric) await ensureIsoRuntime(card);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
@@ -213,6 +363,19 @@ try {
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const initialProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && initialProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback instead of effective Iso`);
|
||||
const initialIsoFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && !initialIsoFingerprint)
|
||||
throw new Error(`${profile} has no rendered Iso geometry fingerprint`);
|
||||
const initialIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter && initialIsoStructuralBuilds == null)
|
||||
throw new Error(`${profile} has no structural build counter`);
|
||||
const initialStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (initialStage3) assertStage3Snapshot(initialStage3, 'initial render');
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
let fullRenderCount = 0;
|
||||
let diagnosticsScanCount = 0;
|
||||
@@ -242,6 +405,7 @@ try {
|
||||
card._setProjection('flat');
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await ensureIsoRuntime(card);
|
||||
await card.updateComplete;
|
||||
} else {
|
||||
// Comparison SHAs before #89 intentionally ignore the Labs operation.
|
||||
@@ -254,7 +418,19 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
const firstEntity = Object.keys(fixture.states)[0];
|
||||
const steadyStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (steadyStage3) assertStage3Snapshot(steadyStage3, 'steady floor');
|
||||
const steadyIsoFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && !steadyIsoFingerprint)
|
||||
throw new Error(`${profile} lost Iso geometry after the space switch`);
|
||||
const steadyIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter && steadyIsoStructuralBuilds == null)
|
||||
throw new Error(`${profile} lost its structural build counter after the space switch`);
|
||||
|
||||
const firstEntity = stage3Dense
|
||||
? fixture.stage3Dense.stateEntityId
|
||||
: Object.keys(fixture.states)[0];
|
||||
const nextStates = {
|
||||
...fixture.states,
|
||||
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
|
||||
@@ -263,6 +439,64 @@ try {
|
||||
card.hass = hassFor(nextStates);
|
||||
await card.updateComplete;
|
||||
});
|
||||
const afterStateStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterStateStage3) assertStage3Snapshot(afterStateStage3, 'HA-only update');
|
||||
const afterStateFingerprint = card.renderRoot
|
||||
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
|
||||
if (requiresIsometric && afterStateFingerprint !== steadyIsoFingerprint)
|
||||
throw new Error(`${profile} rebuilt Iso geometry for an HA-only state update`);
|
||||
const afterStateIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
|
||||
if (requiresIsoStructuralBuildCounter
|
||||
&& afterStateIsoStructuralBuilds !== steadyIsoStructuralBuilds) {
|
||||
throw new Error(`${profile} performed a structural rebuild for an HA-only state update`);
|
||||
}
|
||||
if (requireStage3 && afterStateStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an HA-only state update`);
|
||||
|
||||
let stage3States = nextStates;
|
||||
const openingUpdate = stage3Dense ? await duration(async () => {
|
||||
const entityId = fixture.stage3Dense.contactEntityId;
|
||||
const current = stage3States[entityId];
|
||||
stage3States = {
|
||||
...stage3States,
|
||||
[entityId]: { ...current, state: current.state === 'on' ? 'off' : 'on' },
|
||||
};
|
||||
card.hass = hassFor(stage3States);
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
const afterOpeningStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterOpeningStage3) assertStage3Snapshot(afterOpeningStage3, 'opening update');
|
||||
if (stage3Dense && requiresIsometric && afterOpeningStage3?.effectiveProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback during an opening update`);
|
||||
if (requireStage3 && afterOpeningStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an opening update`);
|
||||
|
||||
const overlayInteraction = stage3Dense ? await duration(async () => {
|
||||
const overlay = card.renderRoot.querySelector(requireStage3
|
||||
? '[data-hp-iso-overlay-kind="device"][data-hp-iso-floor][data-hp-iso-visual]'
|
||||
: '[data-hp="device"]');
|
||||
if (!overlay) throw new Error(`${profile} dense fixture rendered no interactive device`);
|
||||
overlay.dispatchEvent(new PointerEvent('pointerover', {
|
||||
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
overlay.focus?.();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
overlay.dispatchEvent(new PointerEvent('pointerout', {
|
||||
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
overlay.blur?.();
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
const afterInteractionStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (afterInteractionStage3)
|
||||
assertStage3Snapshot(afterInteractionStage3, 'hover/focus update');
|
||||
if (stage3Dense && requiresIsometric && afterInteractionStage3?.effectiveProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback during a hover/focus update`);
|
||||
if (requireStage3
|
||||
&& afterInteractionStage3.structuralBuilds !== steadyStage3.structuralBuilds) {
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for a hover/focus update`);
|
||||
}
|
||||
|
||||
let interactionDiagnostics = null;
|
||||
let interactionTimings = null;
|
||||
@@ -745,12 +979,14 @@ try {
|
||||
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
if (requiresWallFace) {
|
||||
const oldPath = card._path;
|
||||
const oldDraftId = card._activeDraftId;
|
||||
const oldCms = card._draftSegmentCms;
|
||||
const oldChainId = card._activeWallChainId;
|
||||
const oldPartitionIds = card._activeWallChainPartitionIds;
|
||||
const oldCms = card._wallChainSegmentCms;
|
||||
const beforePath = [[10, 10]];
|
||||
card._path = [[10, 10], [20, 10]];
|
||||
card._activeDraftId = 'perf-face-draft';
|
||||
card._draftSegmentCms = [15];
|
||||
card._activeWallChainId = 'perf-face-chain';
|
||||
card._activeWallChainPartitionIds = ['perf-face-segment'];
|
||||
card._wallChainSegmentCms = [15];
|
||||
const acceptedStarted = performance.now();
|
||||
card._offerWallFaces(beforePath);
|
||||
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
|
||||
@@ -758,8 +994,9 @@ try {
|
||||
card._wallFaceBatch = null;
|
||||
card._roomDialog = false;
|
||||
card._path = oldPath;
|
||||
card._activeDraftId = oldDraftId;
|
||||
card._draftSegmentCms = oldCms;
|
||||
card._activeWallChainId = oldChainId;
|
||||
card._activeWallChainPartitionIds = oldPartitionIds;
|
||||
card._wallChainSegmentCms = oldCms;
|
||||
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
|
||||
@@ -850,12 +1087,20 @@ try {
|
||||
const cacheGrowth = Object.fromEntries(
|
||||
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
|
||||
);
|
||||
const finalProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && finalProjection !== 'iso')
|
||||
throw new Error(`${profile} ended the sample in Flat fallback`);
|
||||
const finalStage3 = stage3Dense ? stage3Snapshot(card) : null;
|
||||
if (finalStage3) assertStage3Snapshot(finalStage3, 'final warmed state');
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(openingUpdate ? { openingUpdateMs: openingUpdate.ms } : {}),
|
||||
...(overlayInteraction ? { overlayInteractionMs: overlayInteraction.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
@@ -874,6 +1119,8 @@ try {
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(openingUpdate ? { openingUpdate: openingUpdate.longTasks } : {}),
|
||||
...(overlayInteraction ? { overlayInteraction: overlayInteraction.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
...(interactionSeries ? interactionLongTasks || {} : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
@@ -888,6 +1135,26 @@ try {
|
||||
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
|
||||
preciseGc: typeof globalThis.gc === 'function',
|
||||
renderedDevices: card._devices?.length ?? 0,
|
||||
effectiveProjection: finalProjection,
|
||||
...(isometric ? {
|
||||
isoStructuralBuilds: {
|
||||
supported: steadyIsoStructuralBuilds != null && afterStateIsoStructuralBuilds != null,
|
||||
initial: initialIsoStructuralBuilds,
|
||||
beforeHaUpdate: steadyIsoStructuralBuilds,
|
||||
afterHaUpdate: afterStateIsoStructuralBuilds,
|
||||
haUpdateDelta: steadyIsoStructuralBuilds == null || afterStateIsoStructuralBuilds == null
|
||||
? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds,
|
||||
},
|
||||
} : {}),
|
||||
...(finalStage3 ? {
|
||||
isoStageRevision: finalStage3.isoStageRevision,
|
||||
stage3Diagnostics: {
|
||||
...finalStage3,
|
||||
steadyUpdateStructuralBuildDelta: requireStage3
|
||||
? afterInteractionStage3.structuralBuilds - steadyStage3.structuralBuilds
|
||||
: null,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
card.remove();
|
||||
await frame();
|
||||
@@ -895,7 +1162,8 @@ try {
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction,
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -908,6 +1176,7 @@ const metricNames = [
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (stage3Dense) metricNames.splice(3, 0, 'openingUpdateMs', 'overlayInteractionMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
if (interaction) metricNames.splice(2, 0,
|
||||
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
|
||||
@@ -915,6 +1184,7 @@ if (interaction) metricNames.splice(2, 0,
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
sourceSha,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
@@ -926,11 +1196,16 @@ const report = {
|
||||
deviceScaleFactor: 1,
|
||||
reducedMotion: true,
|
||||
},
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
fixture: fixtureCounts,
|
||||
samples,
|
||||
warmups,
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
effectiveProjection: [...new Set(rows.map((row) => row.effectiveProjection))],
|
||||
...(stage3Dense ? {
|
||||
isoStageRevision: [...new Set(rows.map((row) => row.isoStageRevision))],
|
||||
stage3Required: requireStage3,
|
||||
} : {}),
|
||||
rows,
|
||||
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/** #461 production-bundle wall-draw terminal-click performance profile. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch, finish } from './serve.mjs';
|
||||
import {
|
||||
installWallDrawClickHarness, resetWallDrawClickHarness, runWallDrawClickChain,
|
||||
runWallDrawFinishProfile,
|
||||
} from './wall-draw-click-harness.mjs';
|
||||
|
||||
const outputArg = process.argv.find((arg) => arg.startsWith('--output='));
|
||||
const output = outputArg ? resolve(outputArg.slice('--output='.length)) : null;
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 }, 1);
|
||||
await installWallDrawClickHarness(page);
|
||||
|
||||
const run = async (remote) => {
|
||||
const fixture = await resetWallDrawClickHarness(page, remote);
|
||||
// One complete chain warms the browser/JIT; reset removes all of its state.
|
||||
await runWallDrawClickChain(page);
|
||||
await resetWallDrawClickHarness(page, remote);
|
||||
return { fixture, result: await runWallDrawClickChain(page) };
|
||||
};
|
||||
const base = await run(false);
|
||||
const remote = await run(true);
|
||||
const runFinish = async (remoteVariant) => {
|
||||
await resetWallDrawClickHarness(page, remoteVariant);
|
||||
// Warm the distinct finalizer path, then measure it from the same fixture.
|
||||
await runWallDrawFinishProfile(page);
|
||||
await resetWallDrawClickHarness(page, remoteVariant);
|
||||
return runWallDrawFinishProfile(page);
|
||||
};
|
||||
const baseFinish = await runFinish(false);
|
||||
const remoteFinish = await runFinish(true);
|
||||
const sorted = [...base.result.times].sort((a, b) => a - b);
|
||||
const medianMs = sorted[Math.floor(sorted.length / 2)];
|
||||
const maxMs = Math.max(...base.result.times);
|
||||
const remoteSorted = [...remote.result.times].sort((a, b) => a - b);
|
||||
const remoteMedianMs = remoteSorted[Math.floor(remoteSorted.length / 2)];
|
||||
|
||||
const clickShape = (click) => click.fullSpacePhysicalChecks === 0
|
||||
&& click.localPhysicalChecks === 1
|
||||
&& click.junctionArtifactPasses === 1
|
||||
&& click.configWrites === 1 && click.history === 1 && click.pathPoints === 1;
|
||||
const fixtureShape = base.fixture.spaces === 5 && base.fixture.rooms === 12
|
||||
&& base.fixture.positiveSegments >= 36 && base.fixture.savedPartitions === 8;
|
||||
const remoteFixtureShape = remote.fixture.positiveSegments === base.fixture.positiveSegments * 2;
|
||||
const structural = fixtureShape && remoteFixtureShape
|
||||
&& base.result.first.fullSpacePhysicalChecks === 0
|
||||
&& base.result.first.localPhysicalChecks === 0
|
||||
&& base.result.first.configWrites === 0 && base.result.first.history === 0
|
||||
&& base.result.clicks.length === 7 && base.result.clicks.every(clickShape)
|
||||
&& remote.result.clicks.every(clickShape)
|
||||
&& base.result.metrics.localProofMaxObjects < base.fixture.positiveSegments
|
||||
&& remote.result.metrics.localProofMaxObjects === base.result.metrics.localProofMaxObjects
|
||||
&& base.result.ids.length === 7 && new Set(base.result.ids).size === 7
|
||||
&& base.result.metrics.wallGenericFallbacks === 0
|
||||
&& base.result.terminal.fullSpacePhysicalChecks === 0
|
||||
&& base.result.terminal.localPhysicalChecks === 0
|
||||
&& base.result.terminal.configWrites === 0 && base.result.terminal.history === 0
|
||||
&& base.result.terminalPartitionCount === base.fixture.savedPartitions + 7
|
||||
&& base.result.activeCleared;
|
||||
const finishStructural = baseFinish.terminal.fullSpacePhysicalChecks === 0
|
||||
&& baseFinish.terminal.localPhysicalChecks === 1
|
||||
&& baseFinish.terminal.configWrites === 1
|
||||
&& baseFinish.terminal.history === 0
|
||||
&& baseFinish.terminal.wallFinishBarriers === 1
|
||||
&& baseFinish.terminal.wallGenericFallbacks === 0
|
||||
&& baseFinish.surviving.length === 1
|
||||
&& baseFinish.terminalPartitionCount === base.fixture.savedPartitions + 1
|
||||
&& baseFinish.optimizeChanged === false
|
||||
&& baseFinish.activeCleared
|
||||
&& remoteFinish.terminal.fullSpacePhysicalChecks === 0
|
||||
&& remoteFinish.terminal.localPhysicalChecks === 1
|
||||
&& remoteFinish.terminal.configWrites === 1
|
||||
&& remoteFinish.terminal.wallFinishBarriers === 1
|
||||
&& remoteFinish.surviving.length === 1
|
||||
&& remoteFinish.optimizeChanged === false;
|
||||
const budgets = {
|
||||
medianMs: 150,
|
||||
maxMs: 250,
|
||||
remoteMedianMs: medianMs * 1.5 + 20,
|
||||
finishMs: 250,
|
||||
remoteFinishMs: baseFinish.finishMs * 1.5 + 20,
|
||||
};
|
||||
const timing = medianMs <= budgets.medianMs && maxMs <= budgets.maxMs
|
||||
&& remoteMedianMs <= budgets.remoteMedianMs
|
||||
&& baseFinish.finishMs <= budgets.finishMs
|
||||
&& remoteFinish.finishMs <= budgets.remoteFinishMs;
|
||||
const report = {
|
||||
issue: 461, profile: 'wall-draw-click-v1',
|
||||
fixture: base.fixture, remoteFixture: remote.fixture,
|
||||
timing: {
|
||||
samplesMs: base.result.times.map((value) => Math.round(value * 10) / 10),
|
||||
medianMs: Math.round(medianMs * 10) / 10,
|
||||
maxMs: Math.round(maxMs * 10) / 10,
|
||||
remoteMedianMs: Math.round(remoteMedianMs * 10) / 10,
|
||||
finishMs: Math.round(baseFinish.finishMs * 10) / 10,
|
||||
remoteFinishMs: Math.round(remoteFinish.finishMs * 10) / 10,
|
||||
},
|
||||
budgets: Object.fromEntries(Object.entries(budgets)
|
||||
.map(([key, value]) => [key, Math.round(value * 10) / 10])),
|
||||
counters: base.result.metrics,
|
||||
finishCounters: baseFinish.terminal,
|
||||
structural: structural && finishStructural,
|
||||
timingPass: timing,
|
||||
pass: structural && finishStructural && timing,
|
||||
};
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
await finish(browser);
|
||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
|
||||
import { assertBundleManifest, verifyBundleTree } from '../scripts/bundle-tree.mjs';
|
||||
|
||||
const fingerprintForTree = async (root) => {
|
||||
const modulePath = resolve(root, 'scripts/source-fingerprint.mjs');
|
||||
@@ -25,6 +26,14 @@ const verifyManifestTree = (root, expected) => {
|
||||
|| !Array.isArray(manifest.files)) {
|
||||
throw new Error('demo bundle manifest is stale or malformed; run npm run bundle:sync');
|
||||
}
|
||||
// Comparative performance runs intentionally build older trees. Require the
|
||||
// two-entry contract exactly when that target contains the panel source; a
|
||||
// pre-#486 baseline remains measurable under its own fingerprint contract.
|
||||
if (existsSync(resolve(root, 'src/houseplan-panel.ts'))) {
|
||||
assertBundleManifest(manifest, manifestPath);
|
||||
verifyBundleTree(assetRoot);
|
||||
return;
|
||||
}
|
||||
for (const file of manifest.files) {
|
||||
const path = resolve(assetRoot, String(file?.path || ''));
|
||||
const rel = relative(assetRoot, path);
|
||||
|
||||
@@ -132,6 +132,12 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
card._infoCard = device;
|
||||
}
|
||||
|
||||
if (current.dialog === 'pdf') {
|
||||
if (!(await card._pdfRuntimeLoader.ensure()))
|
||||
throw new Error('documentation PDF runtime did not load');
|
||||
card._pdfDialog = true;
|
||||
}
|
||||
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
@@ -154,7 +160,11 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
|
||||
}
|
||||
|
||||
await settleCamera();
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
|
||||
// Most dialogs render hp-dialog directly into the card, while the lazy PDF
|
||||
// surface owns that shell inside hp-pdf-dialog's shadow root. The host is
|
||||
// the stable public capture boundary; reaching through its shadow root here
|
||||
// would couple documentation capture to the dialog implementation.
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog, hp-pdf-dialog') };
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
|
||||
* тот скрипт при импорте поднимает браузер и снимает картинки.
|
||||
*/
|
||||
export const DOC_SCREENSHOT_VERSION = 1;
|
||||
export const DOC_SCREENSHOT_VERSION = 2;
|
||||
export const DOC_SCREENSHOTS = Object.freeze([
|
||||
{
|
||||
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
|
||||
@@ -72,4 +72,10 @@ export const DOC_SCREENSHOTS = Object.freeze([
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'pdf-export', file: '10-pdf-export.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'view', dialog: 'pdf',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'light',
|
||||
viewport: { width: 900, height: 760 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const CELL_CM = 5;
|
||||
const point = (x, y) => [x / 240, y / 240];
|
||||
|
||||
const roomAt = (id, x, y) => {
|
||||
const poly = [point(x, y), point(x + 24, y), point(x + 24, y + 24), point(x, y + 24)];
|
||||
const wallIds = poly.map((_, index) => `${id}-wall-${index}`);
|
||||
return {
|
||||
room: { id, name: id, area: null, poly, wall_ids: wallIds },
|
||||
segments: poly.map((a, index) => ({
|
||||
id: wallIds[index], a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
walls: poly.map((a, index) => ({
|
||||
key: `${id}-legacy-${index}`, a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const roomGrid = (prefix, xOffset = 0) => Array.from({ length: 12 }, (_, index) =>
|
||||
roomAt(`${prefix}-${index}`, xOffset + 8 + (index % 4) * 32, 8 + Math.floor(index / 4) * 32));
|
||||
|
||||
export const WALL_DRAW_CLICK_POINTS = [
|
||||
point(152, 144), point(176, 144), point(176, 168), point(200, 168),
|
||||
point(200, 144), point(224, 144), point(224, 168), point(236, 168),
|
||||
];
|
||||
|
||||
export function makeWallDrawClickFixture(remoteVariant = false) {
|
||||
const primary = roomGrid('base');
|
||||
const remote = remoteVariant ? roomGrid('remote', 300) : [];
|
||||
const all = [...primary, ...remote];
|
||||
const savedPartitions = Array.from({ length: 4 }, (_, index) => {
|
||||
const points = [
|
||||
point(8 + index * 28, 112), point(20 + index * 28, 112),
|
||||
point(20 + index * 28, 124),
|
||||
];
|
||||
return points.slice(1).map((b, edge) => ({
|
||||
id: `saved-partition-${index}-${edge}`, a: points[edge], b, cm: 15,
|
||||
}));
|
||||
}).flat();
|
||||
const edited = {
|
||||
id: 'edited', title: 'Wall draw click', cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: all.map((item) => item.room),
|
||||
wall_segments: all.flatMap((item) => item.segments),
|
||||
walls: all.flatMap((item) => item.walls),
|
||||
partitions: savedPartitions, wall_columns: [], openings: [], open_spans: [],
|
||||
};
|
||||
return {
|
||||
model_version: 10,
|
||||
spaces: [edited, ...Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `other-${index}`, title: `Other ${index}`, cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], walls: [],
|
||||
partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
}))],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
}
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,14 +1,21 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-04T13:10:56.492Z",
|
||||
"sourceFingerprint": "7acb1b9d3bb384bc1a0c70fb01347386c157ae88b12b00ccd876b7658fc84a1c",
|
||||
"matrixVersion": 61,
|
||||
"acceptedAt": "2026-09-08T07:21:40.179Z",
|
||||
"sourceFingerprint": "890c1fb3c26652d17297e069612868f7bdad06b80839ef1dfda080e53d2b19c1",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "win32",
|
||||
"witnesses": {
|
||||
"count": 127,
|
||||
"count": 138,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"panel-wide-view-light-en": "5d81a6c410c4289f82b994e7b6c9af729b56ceea59bf5823e35f638f54b5bc30",
|
||||
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
|
||||
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
|
||||
"version-mismatch-desktop-dark-en": "ad08f293c728913549b0a71a1a514a60e3030d885acfb0c9951f1d9a1125a1d3",
|
||||
"version-mismatch-touch-light-ru": "bf511c2e7e8714e30a12126c0c0b7f7ea7f5885e8c8ee0c475b364f6510c8702",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
|
||||
@@ -20,36 +27,44 @@
|
||||
"split-corner-wall-thin-dark": "9a509f0fa7ee8503c54a508c313b47e10f8044f0a9931791d1cab83467dc3f90",
|
||||
"split-corner-wall-thick-dark": "51ac5e7594386e306f5ebc94e50d6c6d428fd6a4f26a447b28e1cbdd429afda7",
|
||||
"split-zero-divider-taper-dark": "eb43a7ba81ebd8b1f7c7f76306bb6205ea77a5cef3c21739d40ca540f0f7a1bf",
|
||||
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
|
||||
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
|
||||
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
|
||||
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
|
||||
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
|
||||
"isometric-large-warm-remount-dark": "299401bd4b499876d40678593308b42ac8aa4d3328113f27ad5940fc3621184a",
|
||||
"isometric-geometry-view-dark": "52b01bf23b31353900ea9174fdb4b9d27a586f8370fb9b00c4db6b9bbe7f73f2",
|
||||
"isometric-geometry-view-light": "6ee98e56782feaabf784eea7d927ea35bdde054d8605484f4a5e92ea93f7c82a",
|
||||
"isometric-live-layers-dark": "0d123ab0285fe3ad8487fb5263c3d8074fe2d2490091868ed648009039c04a3b",
|
||||
"isometric-no-borders-dark": "7cd9e7d9797c920e7f3564a24b1fd6143b4da94a342af3b34d5a1cd8d584f11b",
|
||||
"isometric-touch-kiosk-dark": "a6515349431d53688415e42a7711e8adf052eacf5e63a74f892fed60002c6d11",
|
||||
"isometric-large-warm-remount-dark": "91f24287e8c3148fd6470ab4abc4f856f2863c2ae7372cd90a3770ddee48f209",
|
||||
"isometric-stage3-overlays-light": "efb39c86776c507b25a8f1955f7371086cdca92e0df0ecb4c4127577fb925ba9",
|
||||
"isometric-stage3-overlays-dark": "af2af4520240ebec87d84727fccc249c5a008b0ce4403bb9c2d37aa317d8a3e8",
|
||||
"isometric-stage3-openings-dark": "a387ad33e2d238cd5f5088f9de2be8797f8b724d40599ef21eaae4ad0ff27b33",
|
||||
"isometric-stage3-forced-colors-dark": "36bd5a28a336b2be2d33640dc57d8eb3992198c812c6cfb5c20bf7daa6a611f2",
|
||||
"isometric-stage3-no-filter-dark": "277e79c005736ed81135f70517902ccea4d4300ee886e0cf05eb027e7b46d687",
|
||||
"geometry-view-dark-fit": "1ea111a2f68d71c2d2269ee65ac70b586b7d8a3a76f550bb1caf8d154bf42b26",
|
||||
"geometry-view-light-fit": "80bbb618619731dcb9dbd5e872785f32f1e52ff71ce989a2154c5f1218812eda",
|
||||
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
|
||||
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
|
||||
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
|
||||
"vacuum-trail-smoothing-dark": "b33ddbfaed72f51e601576bbf87664a08578fbce5c22c69a11d83cc2644ff312",
|
||||
"room-label-parity-view-dark": "0b4dd478e9b5e0f7acbf46c135309c8b1ed984b8c21f9c7ca0be9a25c10779b7",
|
||||
"room-label-parity-plan-dark": "9112819b3af20153022dbc79940961c2776d70eaeb61716dad62df3c4c518665",
|
||||
"room-label-parity-view-light": "a8a9915d2900a1347e85baa3df2ebde602904a9e35bbcc143fea7b1067c171f0",
|
||||
"room-label-parity-plan-light": "354e6c78c6daf94305b9b0c9b4efa5caea89a327d3a81f634f1c3f5c6674524c",
|
||||
"room-label-parity-view-dark": "eea967d733bea8c08242d9265e572dc13ee90894e0df71a4ca93cee7631f7fef",
|
||||
"room-label-parity-plan-dark": "dfc804fa1aa5bc0222759463195bae0751513e0e428c8eae0bbc9d49fbc5a1b9",
|
||||
"room-label-parity-view-light": "bb15fe24a803b1a3e07b778ccdda7c187bf8b85cd0d2f0c85e65bd77029d500e",
|
||||
"room-label-parity-plan-light": "8ca4ce563f87d15c70b680ea61fcbf19ba0881123f9bb4bf76e6ea6f4e58c76d",
|
||||
"washer-active-cycle-dark": "0f38b85f2f912059a6380a0642a4d0314ea5c6fa6acb9568e1c4abbf4a7731a2",
|
||||
"washer-idle-cycle-dark": "08d5f10dc14b548e6a149c8d9c93f9b635854604cebd681644f30a1ca0b4ee4d",
|
||||
"day-cycle-dawn-dark": "cee9dea7017ee192dc69771822e53583df973dceecd46279c93079ba6bc730c5",
|
||||
"day-cycle-day-dark": "96fb83defa72f78d9e58413505f9250fae9cc766e21bdecb4b23374e04f15d43",
|
||||
"day-cycle-dusk-dark": "eca235de3abf21776b160d1b79cde3309952f0030bc2031a0ada7bdf2847d2be",
|
||||
"day-cycle-night-dark": "feec7492db1187b84e59cb627de9dd8d6d412f1dfc2643c3eab5cafe8f198f7c",
|
||||
"geometry-plan-editor-dark": "0fc3d491560d87e9c9b0285729c50bd20bc85c959b487b40c4d47f8c9378e8f4",
|
||||
"safe-resize-handles-clamp-light": "1a5adb25fd12180bc7b9781924abb4ffad637579ec1eba9e1a76481523080960",
|
||||
"safe-resize-handles-clamp-dark": "bd0035cacc4d8c16c789754507a00f673b75cfecd1569635e673683e5b9f0247",
|
||||
"space-tab-drop-before-light": "d174ac6b543672ebd649e7c9363c3bb79b22b417f7d899c08f755e273632f702",
|
||||
"space-tab-drop-after-dark": "eb8d6bae0e32392234bd4462652e2a8f7831d7017bf5e8d8106c9d6f203b3eaf",
|
||||
"plan-snap-endpoint-light": "dd4fc2254ae457e9ae5628b1e97ca0228699cdedf9b57f5358b2280dbebd6d6e",
|
||||
"plan-snap-line-gaps-dark": "b8457468ef5d4b8b38b62c4a52950364d231aedb7549fcbcbd4ee6f9444569e1",
|
||||
"wall-junctions-plan-preview-light": "3151196d0015c56a46b17a599513ced13dc5558c03106e98fb7d6dda5fad8de2",
|
||||
"wall-junctions-plan-t-dark": "6d16a52e7cf362622778e5b5692b6f3ae4ec04c790e5117144fb0ca50f6c70b2",
|
||||
"geometry-plan-editor-dark": "6fa29c51ac37449e53ff76afe17760a86325e160f53040c737619aae1095599d",
|
||||
"safe-resize-handles-clamp-light": "98db89eb1ec29aa6297ac0bc10653c8efce16175833091d0e76dd033ad9f4a8e",
|
||||
"safe-resize-handles-clamp-dark": "581f85ca93197ba796e72daa67402eebb7c64f13d4722b5d33943577837c8980",
|
||||
"space-tab-drop-before-light": "2678c840a33ade95f8a038213a696132612cb9bae8290e467ae9c4ac2ee265e3",
|
||||
"space-tab-drop-after-dark": "f4f3ab1bb986426f281753a101b4055fae1f5f1ed0c04c8d41071dfa260978f1",
|
||||
"plan-snap-endpoint-light": "40f761e1a7a12e5113ec6e736c6d7176908d8a1585281c2c2f438bc1fee36e1d",
|
||||
"plan-snap-line-gaps-dark": "e4e5e15a98e35ac26a5b0fe7ef674341fb55d8445587ccc25a81603e1ef92774",
|
||||
"wall-junctions-plan-preview-light": "f6f450768912f37b3d91730cc38f43b399eab781e585bdbe6c9bb29458b56f4c",
|
||||
"wall-junctions-plan-t-dark": "6c3748e931407079e3aad3ade77c46069ef75444c27405549d1610670a33ad95",
|
||||
"wall-junctions-view-dark": "ad23e5fb0ca9ba23f58ee4839cec489e7d05a28ef193d424b451a4020ef8ce0b",
|
||||
"junction-patch-resilience-plan-dark": "80d032f5962dce27cd3577972e3d651d41080dae22c114fde8aec4c4b605f24e",
|
||||
"junction-patch-resilience-plan-dark": "8ad87deb245097cd090f301d259710323cf4ea577cd8edaf16607d6ad8a3d29d",
|
||||
"junction-patch-resilience-view-dark": "4ba710fd6859f958ad6c576a01d0b240a3b1723fe9df54f8cabd971414a943a6",
|
||||
"wall-union-isolation-view-light": "426ef2ada0e69febd73ab305aa99b600a95e69db68dc30dae4c5288ee1151604",
|
||||
"wall-union-isolation-view-dark": "215ed9d8cba31c5de4cb9d0e2b347bf0c96c8d1bba4d35feca57d6f66fc25322",
|
||||
@@ -57,27 +72,27 @@
|
||||
"orthogonal-strip-cell-5-view-dark": "05cdd744d09025719eda4c5e68cf3ebc5d7d201afd3028da91262d0f4874c126",
|
||||
"orthogonal-strip-cell-1-view-dark": "9c0f91b11d47f1c5df4476774ae6028db21dc15cc6fb81d9f80009e0fa86bffe",
|
||||
"wall-key-roundtrip-view-dark": "cc6f755c03b1a586684627fc1dc27eb1aa28d2673a6c79bcb687da94a6bf9b3b",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "d986d82fe68bec4b0b0556870d3ec7de280a00acb42cf0165a3d64f91f994bed",
|
||||
"opening-placement-passage-thick-wall-dark": "f53908dbdf8cf6681c97317398e8a04cd98ebacdbc81571c2d15d38b2599e049",
|
||||
"opening-placement-passage-thick-wall-light": "f7bdf033bbc511b34269044c450f8c93a3493e27bbbcf758050b7798197efd80",
|
||||
"isometric-wall-junctions-dark": "af692b2c01293c59cc74e5731309e6612c913ca1c8384572fd1cb67a19d0f902",
|
||||
"opening-placement-door-thick-wall-dark": "9d587eff0c2561ef5f84e9280cd812fa6e9af7cf6ff00c922d275796774ba513",
|
||||
"opening-placement-passage-thick-wall-dark": "6ed48b39c0e11fbf4b0df7d3e4dad8078e5b17694f1a780936a864bebd0c1c57",
|
||||
"opening-placement-passage-thick-wall-light": "3d4e3a5d9b67f2450fab9ccc0f5dc91f4485080582377de904f16ea1d62fbafa",
|
||||
"opening-symbol-room-wall-light": "c487c11f12b2bba1473559a82af3320b7995fa9c8d1b379274b942e431930beb",
|
||||
"opening-symbol-diagonal-partition-dark": "77101350b15845207644b6df86a3bc5f48b4dc873b9cfd7443c0d23507412de3",
|
||||
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
|
||||
"device-inbox-desktop-en-light": "89587944da06872f610da33e8e39441d108cb34849d8b09d4925bff4d0977565",
|
||||
"device-inbox-desktop-ru-dark": "fe9506408fc198c167f2d4b9cfe91bd9fdfb722b311772457ae44c9d06248680",
|
||||
"device-inbox-narrow-ru-dark": "3ddb3b66a613c6406d3d9ce2d4d479c8d84eff04acd9b5b2dce02c4c777fc738",
|
||||
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
|
||||
"isometric-opening-symbol-parity-dark": "e2ead596818f6182bd2478dc3823f31312d361080c99afbb9c7f7d1862cf781c",
|
||||
"geometry-devices-editor-dark": "e273c4fe49619a5856a9212ff6261803640e5afa79b841edfccf5a4056e0264d",
|
||||
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
|
||||
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
|
||||
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
|
||||
"geometry-decor-editor-dark": "2f8a3742166c10c0a5aa1239c9b6b7d908e7e578dc43229274b17d015c47503c",
|
||||
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
|
||||
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
|
||||
"tray-medium-group-en": "b38abb69d1fac7414fc24d6cda6b5698eb861f8af94054605902e8529fe1e993",
|
||||
"tray-medium-selection-ru": "b53f9513b167a33cffed2177603ae5b562264a83d5544d938c2af42ec8a551a5",
|
||||
"tray-wide-tool-ru": "99abefe9c5b0f21356e87b46fc6d6d615cf9b799614c14bf8fffa0261fd9059a",
|
||||
"tray-medium-group-en": "afdb651c687cabda48e62d426b9ef4926a23caa86904fe6fa2c163930070b2bd",
|
||||
"tray-medium-selection-ru": "5d78f5d818514ab80a4fb2949274edffd372e0d2e41bc3ccf39a22fd7c993f6b",
|
||||
"tray-narrow-palette-en": "ae23f1b1384bec23ec272156cb2b7d3fe4088addaefa2f575bc1daa0480569a1",
|
||||
"furniture-categories-light": "02ce65879aca968d05c7400e32d3f38c73a53f3bc036ee306fdf851ed84ed88c",
|
||||
"furniture-variants-dark": "f52db569690a67e84701aeb79cc8967c9ee117b543fd9bf3d6fb8c3fee7a660b",
|
||||
"furniture-variants-light": "4a78f688423f0f0946f50ec1a92fe9a3de5bfa288c7e153425c1beadf5c5f839",
|
||||
"furniture-categories-light": "1aae9b6166146b06c6571730761daed5690ea50769e6dc618a66c19339f5d834",
|
||||
"furniture-variants-dark": "aa15801cc56ab488e9563e05d15694db48294dd4f3d7e5bc238b4f79ec8d9b7b",
|
||||
"furniture-variants-light": "286269d08ddcc086747d0703a59cd03bac29b11b69839364c01cb8492a00602d",
|
||||
"furniture-placement-preview-light": "f983eda1bf42e27d17afb78eada8b96b192a7e91bebb0c70e6766994000aabe3",
|
||||
"furniture-plan-art-dark": "e83a8c40add2bf33af304f86ad29fc77d8d47bd7228f17c152e962f3431e0c50",
|
||||
"furniture-transform-light": "3e37e03457bac87c9d7eaf6bbeb0f4054fb6e6a66a7da5953714446a92ccf296",
|
||||
@@ -133,33 +148,35 @@
|
||||
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
|
||||
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
|
||||
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
|
||||
"device-dialog-desktop-en": "465175fc9960c7ceb5208cf62f919381d56179ef12a93bee20e5c7af63757e7a",
|
||||
"device-dialog-mobile-ru": "8128783f4e9f9ff301f15db9fa7388b51aa7e8a4a74ac0aaf962b21ecda52c78",
|
||||
"device-dialog-desktop-de": "8440d4e980fac5df53142b6b118e527d9c0272e7515b65faaba6a58bc9441075",
|
||||
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
|
||||
"toggle-entity-dialog-desktop-en": "7a8ac3071d8bb3f79f2bfe724d0138e2b309baab3104ef8afb0b2b081c8b82b1",
|
||||
"toggle-entity-dialog-mobile-ru": "d3062e76da27218cac45ca4c76f34b358d9cb98e85288f24e64b7de1bcca949d",
|
||||
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
|
||||
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
|
||||
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
|
||||
"general-color-popover-desktop-en": "ba147ee0a21d0ad0986fbdf933b4b36b8c8ae7dee005efb8befe7fc59a9dc28d",
|
||||
"settings-help-zoom-200-en-light": "9f5e2ae5ac30c99c52870cebd5114096b413a6e4a5ee060b87f3a0f327065ee6",
|
||||
"settings-help-zoom-200-ru-dark": "07b3a635617d9b592c7b51e3a34bd1f2b88447814b60d694746ba85bd178520d",
|
||||
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
|
||||
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
|
||||
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
|
||||
"support-phone-success-dark-en": "81f83402933a573002581646d0b322f3770f3df41d5334373315ed5ad4974883",
|
||||
"support-relay-error-light-en": "e9a9ad906e106eedfa6e84c592325f1604eb64c89c51135c076ac87a6a0766b2",
|
||||
"support-tablet-preview-dark-ru": "1264e005cd764408edb9b285a5dfbe92ceb90cb925f624b8f65cd47edb96c0c9",
|
||||
"device-ripple-color-popover-mobile-ru": "d9ebf83ad0d2995b46f6174daf6e21ca5404952afa308ddd3a66ecefcee565d0",
|
||||
"space-room-color-popover-desktop-ru": "9bbbcf9c5ea6ff5490b500be8cb3086af9d368d14ca50c304f42a697e296ece1",
|
||||
"backup-full-preview-desktop-en": "0fbd23a92121668001faf5c9ed555a31107704f3074d1791709c8436517d3d1f",
|
||||
"backup-plan-only-export-desktop-en": "ce66fb57edbc87d56355b0d73a2d4203a67c275d5927d400ef1758ac0b607b34",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "101e1a97c6229c693a4edfb4878c6a4564132d6fbcaddd6276cb25aefa429260",
|
||||
"optimize-preflight-dialog-light-ru": "4f34a59baa95b7ad0ad3dbb47f23996537878f6b666dc9111236713f513de97e",
|
||||
"optimize-orphan-references-dark-en": "c8122eda61a52ead0f3609b9872b513004ffbc44c1d4928ec11e5d3b5978af78",
|
||||
"optimize-orphan-references-light-ru": "3cc0b16af015cf695f8bd278dee4d76b160639021a33bc3a28441fb72a5e8e5c",
|
||||
"device-dialog-desktop-en": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
|
||||
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
|
||||
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
|
||||
"room-temperature-dialog-desktop-en": "b496e84e960f7b42eb38094ebaadeff8da146ce43c2f21405ddebf59b8561465",
|
||||
"room-temperature-dialog-mobile-ru": "2c3bd451394e00c539aca68a1a27979d3c3fb6f5cc0e6fb189545b884458c59e",
|
||||
"german-view-mobile-light": "dc252c1a0ced7c636dfbd5cc3113abe546d568b9c62e4812326ab604dfd4e5cd",
|
||||
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
|
||||
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
|
||||
"device-help-popover-light-ru": "1905d843c715b5d1f1dbfe06ca5cbcb8f0fea27f26301bfc1b826227334d10b2",
|
||||
"decor-color-popover-mobile-ru": "dbe9b01e848d3f2776ac74c6bad0134c978b04c07c37b674ca7c823aff5fcd33",
|
||||
"decor-color-popover-desktop-en": "034cf59419f2e940e5fd05be10f441714291a90a2043a7cfcd6e1cda249a1292",
|
||||
"general-color-popover-desktop-en": "16c7b7c206970a6f36c5c70cf493d3e04ea6509a89c65751c83842096c1550d6",
|
||||
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
|
||||
"settings-help-zoom-200-ru-dark": "90e9052fa339ada6a8c05b1732a5786bee9b61266d1a9305f316c4fc35c03df2",
|
||||
"support-desktop-empty-light-en": "87d6db3b1d5a5d58034ddba0612c59bc1d3ce42ad43e812a6c28ce42a38ff499",
|
||||
"support-desktop-preview-dark-en": "4ab4a46a6f06064df60f3bb7ba62229398061c9407380050269d7523b502a2d4",
|
||||
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
|
||||
"support-phone-success-dark-en": "8471706c17e14d199e6d8ca329d4cfe15cb7910f9d0aec28bfd839f49e002612",
|
||||
"support-relay-error-light-en": "ea6cfd6385561562eea3300b802e5036ac63dfb21bad7e9b7bb57e4dbb5e605e",
|
||||
"support-tablet-preview-dark-ru": "549310cc299c346f5495abbd386e5e56b011c812d815cbbe39871165cf9d7981",
|
||||
"device-ripple-color-popover-mobile-ru": "93d04a498e16ac7e98bd420bef566645c2215488138568c5b150110380aa782e",
|
||||
"space-room-color-popover-desktop-ru": "c4cb215714ce98728c2e7b9d3f083055650b639302578874d28067c741ba0cf8",
|
||||
"backup-full-preview-desktop-en": "c8fbd39e63e6ff3f8158ae36ae4ef299ad7635bf41dc490547cd6ad56aeffdfc",
|
||||
"backup-plan-only-export-desktop-en": "cf00fa1784b7a4c4aa3153802ca6a79afbe988ae1b70a9c5ba1983aba0863b4f",
|
||||
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
|
||||
"optimize-preflight-dialog-dark-en": "fa16f7caa6ab75db6a76ada7d21450cee488900a7dc70505867bed7c6f5325d5",
|
||||
"optimize-preflight-dialog-light-ru": "14ff81985b7564e23f9d1124c2be44a31b017186691745a6092fe918e79328e1",
|
||||
"optimize-orphan-references-dark-en": "d6a59ae1f7accef832ac6458c618ab4719d110c0154a4d607454fc7dab34d8a1",
|
||||
"optimize-orphan-references-light-ru": "88455ebb34a052abcc75cd4454f1256cebb11ee0a8b880b8507235c332f8ae14",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 119 KiB |