Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 как подтверждение дешёвых гейтов — смок его часть.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
@@ -94,17 +94,46 @@ 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 card is 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
|
||||
|
||||
|
||||
@@ -98,17 +98,46 @@ 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-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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,9 +15,11 @@ 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 .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
@@ -99,49 +100,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
|
||||
@@ -319,61 +283,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
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."""
|
||||
await async_remove_frontend_registration(hass, entry)
|
||||
|
||||
@@ -51,7 +51,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.72.0-beta.3"
|
||||
VERSION = "1.73.0-beta.2"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
@@ -70,7 +70,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,251 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "f5a5df099a166da3e233ffe681f86d9ee30cfedaac3da73518fcea0d0e4db9b7",
|
||||
"fingerprint": "be1f77110b0cecb8df31bb3975762395858023520ad1bbdb0a4840f2ea028077",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js",
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 297481,
|
||||
"initialViewGzipBytes": 289680,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-BlzFtfHW.js",
|
||||
"houseplan-assets/de-ikYRfVaQ.js",
|
||||
"houseplan-assets/editor-D3hGLWnj.js",
|
||||
"houseplan-assets/fr-CztCSf8d.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-QXAvNIKY.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DoCgwkP3.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-GnSX-nxk.js",
|
||||
"houseplan-assets/live-interaction-runtime-DBSJOtiD.js",
|
||||
"houseplan-assets/zigbee-topology-CYMZJB0I.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-eieh242A.js"
|
||||
"houseplan-assets/backdrop-pick-DXZltWqg.js",
|
||||
"houseplan-assets/de-D4Fox7UA.js",
|
||||
"houseplan-assets/editor-BsRVhFIh.js",
|
||||
"houseplan-assets/fr-C-y8mxjP.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-HLrVON0e.js",
|
||||
"houseplan-assets/guard-BDlUT3Kk.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DreWhF65.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Df_XDC5U.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-BEEaYQ2u.js",
|
||||
"houseplan-assets/iso-scene-render-3tm5cCgt.js",
|
||||
"houseplan-assets/live-interaction-runtime-DYl_V0cy.js",
|
||||
"houseplan-assets/pdf-export-Bi5oR1ho.js",
|
||||
"houseplan-assets/zigbee-topology-B6N55BDi.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-Cg6EjnY4.js"
|
||||
],
|
||||
"lazyGzipBytes": 247974,
|
||||
"lazyGzipBytes": 377474,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-BlzFtfHW.js",
|
||||
"houseplan-assets/editor-D3hGLWnj.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-QXAvNIKY.js",
|
||||
"houseplan-assets/zigbee-topology-CYMZJB0I.js"
|
||||
"houseplan-assets/backdrop-pick-DXZltWqg.js",
|
||||
"houseplan-assets/editor-BsRVhFIh.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-HLrVON0e.js",
|
||||
"houseplan-assets/guard-BDlUT3Kk.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DreWhF65.js",
|
||||
"houseplan-assets/zigbee-topology-B6N55BDi.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 183988,
|
||||
"lazyEditorGzipBytes": 195227,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-BlzFtfHW.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DoCgwkP3.js"
|
||||
"houseplan-assets/backdrop-pick-DXZltWqg.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Df_XDC5U.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14312,
|
||||
"lazyOnboardingGzipBytes": 14325,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-ikYRfVaQ.js",
|
||||
"houseplan-assets/fr-CztCSf8d.js"
|
||||
"houseplan-assets/de-D4Fox7UA.js",
|
||||
"houseplan-assets/fr-C-y8mxjP.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 49914,
|
||||
"lazyLocaleGzipBytes": 50273,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-BDlUT3Kk.js",
|
||||
"houseplan-assets/iso-scene-render-3tm5cCgt.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13456,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-HLrVON0e.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 10253,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-HLrVON0e.js",
|
||||
"houseplan-assets/pdf-export-Bi5oR1ho.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 114552,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-BlzFtfHW.js",
|
||||
"sha256": "15fde4dd87aa1039573fae46a9dc05bd4190eabec121686fe323192019345efe",
|
||||
"path": "houseplan-assets/backdrop-pick-DXZltWqg.js",
|
||||
"sha256": "e16030e14b99f3e883df2ee5561e594c8f3949249051a3fc366c38d49e00999c",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7357,
|
||||
"gzipBytes": 7358,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js"
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-ikYRfVaQ.js",
|
||||
"sha256": "2afe4ba96c9b4779665af9ec1c286c246d7cf4fb0b0f5d55c9e1f8bce22e2403",
|
||||
"rawBytes": 83907,
|
||||
"gzipBytes": 25232,
|
||||
"path": "houseplan-assets/de-D4Fox7UA.js",
|
||||
"sha256": "1629ec424f83ece342a27a17604bf202dbb6d3df4eb3d30e28c39c4c74de74e2",
|
||||
"rawBytes": 84320,
|
||||
"gzipBytes": 25379,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-D3hGLWnj.js",
|
||||
"sha256": "016215135309933c3655320b4332d415995ad2ecf831014bd82c963b968f0700",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1579,
|
||||
"path": "houseplan-assets/editor-BsRVhFIh.js",
|
||||
"sha256": "e11f0dd559a8d6f30a4b3b1d9a906a3793581ea063a9b0c48a89feede8fc30bb",
|
||||
"rawBytes": 3837,
|
||||
"gzipBytes": 1589,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js"
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-CztCSf8d.js",
|
||||
"sha256": "b0c22cda12f5f09379b0900b670afe10f6cc456988fbb5433204b321cc817f2f",
|
||||
"rawBytes": 85967,
|
||||
"gzipBytes": 24682,
|
||||
"path": "houseplan-assets/fr-C-y8mxjP.js",
|
||||
"sha256": "da7db759ae68453d71f0c12ea08d7719c34bed3ef80c62b13194b610071b397a",
|
||||
"rawBytes": 86352,
|
||||
"gzipBytes": 24894,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card--Yu_3uZU.js",
|
||||
"sha256": "cf3e3c01dc56cd44b3cdae00fc38ef54ddcf47fc5cb02531ee222f764818732e",
|
||||
"rawBytes": 1058176,
|
||||
"gzipBytes": 296688,
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-HLrVON0e.js",
|
||||
"sha256": "3d59ba8789d32ab6296b9925d48d3ceeff8adc309da60a59d41a1217dd36cfac",
|
||||
"rawBytes": 33655,
|
||||
"gzipBytes": 10253,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/guard-BDlUT3Kk.js",
|
||||
"sha256": "f8d2b4324662f3eeeb4c030d9ed5f5d6550547f63505a04d587d3b16bbc85e7a",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 370,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DwS0pmiF.js",
|
||||
"sha256": "bf9816849f459343595fe1ed86a779ea308e0769e8e1710070fdebf7bfe70f2a",
|
||||
"rawBytes": 1041197,
|
||||
"gzipBytes": 288884,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-ikYRfVaQ.js",
|
||||
"houseplan-assets/editor-D3hGLWnj.js",
|
||||
"houseplan-assets/fr-CztCSf8d.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-QXAvNIKY.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DoCgwkP3.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-GnSX-nxk.js",
|
||||
"houseplan-assets/live-interaction-runtime-DBSJOtiD.js"
|
||||
"houseplan-assets/de-D4Fox7UA.js",
|
||||
"houseplan-assets/editor-BsRVhFIh.js",
|
||||
"houseplan-assets/fr-C-y8mxjP.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-HLrVON0e.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DreWhF65.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Df_XDC5U.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-BEEaYQ2u.js",
|
||||
"houseplan-assets/iso-scene-render-3tm5cCgt.js",
|
||||
"houseplan-assets/live-interaction-runtime-DYl_V0cy.js",
|
||||
"houseplan-assets/pdf-export-Bi5oR1ho.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-QXAvNIKY.js",
|
||||
"sha256": "1309a63497de61690f0f86cc862902c57b9cb4cde841e0948494a2c074b15dc4",
|
||||
"rawBytes": 631744,
|
||||
"gzipBytes": 167631,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DreWhF65.js",
|
||||
"sha256": "1e041618470a7d2005510d4daceb6264df6efbc1a247b3df262f78c27bf56c66",
|
||||
"rawBytes": 631884,
|
||||
"gzipBytes": 168232,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-BlzFtfHW.js",
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js",
|
||||
"houseplan-assets/zigbee-topology-CYMZJB0I.js"
|
||||
"houseplan-assets/backdrop-pick-DXZltWqg.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-HLrVON0e.js",
|
||||
"houseplan-assets/guard-BDlUT3Kk.js",
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js",
|
||||
"houseplan-assets/zigbee-topology-B6N55BDi.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-eieh242A.js"
|
||||
"houseplan-assets/zigbee-topology-runtime-Cg6EjnY4.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-DoCgwkP3.js",
|
||||
"sha256": "a71b2f5339068aee09b9222503b734f0c91bf31b46c3b74337ee6d49ea444634",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6955,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Df_XDC5U.js",
|
||||
"sha256": "2586ffa6b848c97ad69e05dd23524ddb234f1ae190c370894aed351d25372b0f",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6967,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-BlzFtfHW.js",
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js"
|
||||
"houseplan-assets/backdrop-pick-DXZltWqg.js",
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-GnSX-nxk.js",
|
||||
"sha256": "18fbb64b96dbdb48569355909b6d70e7ee3f369c761304329e5d28382a10863e",
|
||||
"rawBytes": 8626,
|
||||
"gzipBytes": 3234,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-BEEaYQ2u.js",
|
||||
"sha256": "f653aae1c3714204bb2c47ff5d5ad4f67f0726172ca3d2fe24b48e3a5b57c805",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3731,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js",
|
||||
"houseplan-assets/zigbee-topology-CYMZJB0I.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-eieh242A.js"
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js",
|
||||
"houseplan-assets/zigbee-topology-B6N55BDi.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-Cg6EjnY4.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-DBSJOtiD.js",
|
||||
"sha256": "e2188ee62c129161ead60af08a613353ff6e6b5fea0209d88c60a00795293f47",
|
||||
"path": "houseplan-assets/iso-scene-render-3tm5cCgt.js",
|
||||
"sha256": "95fbcec6397e23b103da9554fd95c751e9446478f1c2fab33859e2f6c2d84613",
|
||||
"rawBytes": 38562,
|
||||
"gzipBytes": 13086,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-BDlUT3Kk.js",
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-DYl_V0cy.js",
|
||||
"sha256": "858491f4e6e479d8e58289bf643db5203a251b82ddbf10bdb678bc39b17ed298",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2122,
|
||||
"gzipBytes": 2128,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js"
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-CYMZJB0I.js",
|
||||
"sha256": "3dbdda4a3c8decf229325193447a7e8498ac79ee9671e5fa923e1afab15fda78",
|
||||
"path": "houseplan-assets/pdf-export-Bi5oR1ho.js",
|
||||
"sha256": "549bebf0c0f7afd4c09a505b4a100ae204ba2b45a02004e2a63fcd825cd6e225",
|
||||
"rawBytes": 220678,
|
||||
"gzipBytes": 104299,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-HLrVON0e.js",
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-B6N55BDi.js",
|
||||
"sha256": "8be68400c60f5036132db789f6880583fb5f2e3b5fac83b5a5a893cbce495757",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7421,
|
||||
"gzipBytes": 7425,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js"
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-eieh242A.js",
|
||||
"sha256": "9226ed8ab8996a3139630492336d1c669077ca6442f60e85b84068b94165754e",
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-Cg6EjnY4.js",
|
||||
"sha256": "e8b3a5180b562f3a47728eacb44fe1df3d19ebc06ffb4b8d42488135d0d6fcff",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1761,
|
||||
"gzipBytes": 1763,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js",
|
||||
"houseplan-assets/zigbee-topology-CYMZJB0I.js"
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js",
|
||||
"houseplan-assets/zigbee-topology-B6N55BDi.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "fa974652b479dbe9381b5862de0feb250083eda438065263b15754ffb069696a",
|
||||
"sha256": "4daf155cfa86ab21eb053d04c7ca5c7ad13256feb469733261d5eb1eb7073514",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 793,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card--Yu_3uZU.js"
|
||||
"houseplan-assets/houseplan-card-DwS0pmiF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="be1f77110b0cecb8df31bb3975762395858023520ad1bbdb0a4840f2ea028077";import{t as e,l as o,x as t,dV as s,A as a,dW as i,dX as l,dY as n,dM as r,b as c}from"./houseplan-card-DwS0pmiF.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__="f5a5df099a166da3e233ffe681f86d9ee30cfedaac3da73518fcea0d0e4db9b7";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--Yu_3uZU.js";class f 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,f={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")},h=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=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.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(h.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)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="be1f77110b0cecb8df31bb3975762395858023520ad1bbdb0a4840f2ea028077";import{dK as r,dL as t,dM as e}from"./houseplan-card-DwS0pmiF.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f5a5df099a166da3e233ffe681f86d9ee30cfedaac3da73518fcea0d0e4db9b7";import{n as t,b as n}from"./zigbee-topology-CYMZJB0I.js";import{I as e}from"./houseplan-card--Yu_3uZU.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 f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(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(f(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,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=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 f=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&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),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{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="be1f77110b0cecb8df31bb3975762395858023520ad1bbdb0a4840f2ea028077";import{n as t,b as n}from"./zigbee-topology-B6N55BDi.js";import{F as e}from"./houseplan-card-DwS0pmiF.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};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f5a5df099a166da3e233ffe681f86d9ee30cfedaac3da73518fcea0d0e4db9b7";try{await import("./houseplan-assets/houseplan-card--Yu_3uZU.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__="be1f77110b0cecb8df31bb3975762395858023520ad1bbdb0a4840f2ea028077";try{await import("./houseplan-assets/houseplan-card-DwS0pmiF.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)}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"""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 card connected"
|
||||
_NOTICE_DESCRIPTION_FALLBACK = (
|
||||
"The House Plan card is connected. Fully reload this page to use the latest "
|
||||
"frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you "
|
||||
"manage dashboard resources manually in storage mode, open Settings → "
|
||||
"Dashboards → Resources."
|
||||
)
|
||||
|
||||
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(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 _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 domain_data.get(FRONTEND_STATIC_REGISTERED_KEY):
|
||||
state.static_path_registered = True
|
||||
elif card_file_present:
|
||||
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(card_path), cache_headers=False
|
||||
)
|
||||
else:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - setup continues without frontend
|
||||
state.last_error = _safe_error("static_path", err)
|
||||
_LOGGER.warning("Could not register the House Plan static path", exc_info=True)
|
||||
else:
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = True
|
||||
state.static_path_registered = True
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
],
|
||||
@@ -16,5 +19,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.72.0-beta.3"
|
||||
"version": "1.73.0-beta.2"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
"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 card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@ 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 .store import get_data
|
||||
|
||||
|
||||
@@ -23,13 +24,51 @@ 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", {})),
|
||||
}
|
||||
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",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
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
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"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-Karte verbunden",
|
||||
"description": "Die House-Plan-Karte ist verbunden. Laden Sie diese Seite vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Wenn Sie Dashboard-Ressourcen im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"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 card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"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": "Carte House Plan connectée",
|
||||
"description": "La carte House Plan est connectée. Rechargez complètement cette page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). Si vous gérez manuellement les ressources du tableau de bord en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"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). Если вы вручную управляете ресурсами панели в режиме хранения, откройте Настройки → Панели → Ресурсы."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Файл карточки House Plan",
|
||||
"static_path": "Статический путь интерфейса",
|
||||
"resource_status": "Статус регистрации ресурса",
|
||||
"resource_loader": "Способ загрузки ресурса",
|
||||
"resource_url": "URL ресурса интерфейса",
|
||||
"resource_retry": "Повтор регистрации ресурса",
|
||||
"resource_error": "Ошибка регистрации интерфейса",
|
||||
"first_reload_notice": "Первое уведомление о перезагрузке"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -1114,8 +1125,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.
|
||||
@@ -1484,7 +1495,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 +1504,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 +1520,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 +1603,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 +1717,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 +1888,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 +1930,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]]] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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,15 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-05T07:54:06.456Z",
|
||||
"sourceFingerprint": "a02457b163466b4e4bf0b01b64a926e1cde349edb5acb4cd92648d66eafb1cca",
|
||||
"matrixVersion": 57,
|
||||
"acceptedAt": "2026-09-07T02:12:07.024Z",
|
||||
"sourceFingerprint": "be1f77110b0cecb8df31bb3975762395858023520ad1bbdb0a4840f2ea028077",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"platform": "win32",
|
||||
"witnesses": {
|
||||
"count": 130,
|
||||
"count": 100,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"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",
|
||||
@@ -21,36 +24,42 @@
|
||||
"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": "34db73d337876dd4461dd4431c4909c85ea3a4a95fa5849e72839c8d18ff1e03",
|
||||
"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",
|
||||
@@ -58,27 +67,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",
|
||||
@@ -134,33 +143,33 @@
|
||||
"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": "90d7e6dc8ca57aed3fdaf202098810b928fc2794f2e53d02871ac8075be20192",
|
||||
"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": "04bf325ddb449001f6c0b459761dcb84eda6432dca2fbd10ce5a129484d0756c",
|
||||
"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",
|
||||
"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 |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 347 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 93 KiB |