Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aa9ffbd1a | ||
|
|
81daebd788 | ||
|
|
036a15aade | ||
|
|
ce6650b1e0 | ||
|
|
a1fac44d72 | ||
|
|
3342910b27 | ||
|
|
4a91d5cbe8 | ||
|
|
6d2facc2e3 | ||
|
|
6502f516f9 | ||
|
|
0f06b60084 | ||
|
|
66a6485418 | ||
|
|
7baa1cfa13 | ||
|
|
293309355b | ||
|
|
c291384b7a | ||
|
|
ccfd2565a7 | ||
|
|
1ef35883e0 | ||
|
|
2c6410bb87 | ||
|
|
e921d5906d | ||
|
|
bb031d8599 | ||
|
|
1e1880f010 | ||
|
|
4906f0a97f | ||
|
|
05a2c68da2 | ||
|
|
063de0eef4 | ||
|
|
a52dcab77a | ||
|
|
1ca3b5fae3 | ||
|
|
e63460f068 | ||
|
|
ac1e69be7f | ||
|
|
5f0dfa84e1 | ||
|
|
5afdb644c7 | ||
|
|
a9a6d37bba | ||
|
|
1fb84755c2 | ||
|
|
e40d3f18d2 | ||
|
|
8f68cb51bd | ||
|
|
41ae5e9986 | ||
|
|
1cfe7fa53c | ||
|
|
3322c20c09 | ||
|
|
ced9b41642 | ||
|
|
7a81d41bab | ||
|
|
590f5ab127 | ||
|
|
11ccd5b095 | ||
|
|
e09bb2faee | ||
|
|
73c13e37b4 | ||
|
|
e74823737f | ||
|
|
acea695069 | ||
|
|
9584933c98 | ||
|
|
8afba7e6ed | ||
|
|
116cfd5fef | ||
|
|
f65d07e3ea | ||
|
|
45d44608a1 | ||
|
|
0d63529e90 | ||
|
|
77a3d2607c | ||
|
|
9d1ba1103c | ||
|
|
af3be59a2c | ||
|
|
58e31668c9 | ||
|
|
9d35ab35ac | ||
|
|
2e26116bbb | ||
|
|
d21797dc66 | ||
|
|
b4a16ad704 | ||
|
|
a261e54ce7 | ||
|
|
af2081cbc4 | ||
|
|
8f5a08a609 | ||
|
|
db39b620ba | ||
|
|
85e5d61fb1 | ||
|
|
49912ac989 | ||
|
|
b736878c0b | ||
|
|
2977177283 | ||
|
|
e408b6fb70 | ||
|
|
bde05697d1 | ||
|
|
b9e40aebc2 | ||
|
|
0a0a9f0f4c | ||
|
|
7e39c3715c | ||
|
|
f37ccdcebc | ||
|
|
78bc76073e | ||
|
|
4451cf50d9 | ||
|
|
9609040d31 | ||
|
|
9ad8b1f710 | ||
|
|
b59e245e32 | ||
|
|
cd47a59d3f | ||
|
|
6b64801603 | ||
|
|
f4625dd4fb | ||
|
|
8ba87b3ff7 | ||
|
|
ab296302d1 | ||
|
|
8d5bade656 | ||
|
|
9edef041a9 | ||
|
|
95175a7616 | ||
|
|
19e421b3cf | ||
|
|
3f3583ac58 | ||
|
|
9a3af808d9 | ||
|
|
deebcfb095 | ||
|
|
0715321fdc | ||
|
|
53585b451b | ||
|
|
fa7ac02c0f | ||
|
|
548e7a8563 | ||
|
|
9592092b8e | ||
|
|
c50bc725c8 | ||
|
|
b2346eaac3 | ||
|
|
a325294d7a | ||
|
|
3066fec17e | ||
|
|
462b56453c | ||
|
|
49a02637ca | ||
|
|
a625871538 | ||
|
|
e181b08f2b | ||
|
|
50fa7f9be2 | ||
|
|
b49721a0bd | ||
|
|
ad2f9afd2b | ||
|
|
27ea23c1ec | ||
|
|
3d43980568 | ||
|
|
37ef970dfc | ||
|
|
914e84028d | ||
|
|
d5575f6393 | ||
|
|
1950df608f | ||
|
|
94ea4694f4 | ||
|
|
be6d57e96f | ||
|
|
5acf04fa33 | ||
|
|
93c6c7551b | ||
|
|
bac63dbcaf | ||
|
|
31c2583e46 | ||
|
|
2f41c0b29e | ||
|
|
a3e7ac1fc6 | ||
|
|
7194fb2df0 | ||
|
|
a44fbd373d | ||
|
|
c21e9de32b | ||
|
|
9bbba66d11 | ||
|
|
b4a2d5b952 | ||
|
|
b6a66a1bac | ||
|
|
88eb382403 | ||
|
|
f6eac1d971 | ||
|
|
67c780a742 | ||
|
|
2783ceff94 | ||
|
|
0c74828240 | ||
|
|
9b00002a5b | ||
|
|
19b14d3976 | ||
|
|
172d9d5a00 | ||
|
|
588ef42546 | ||
|
|
e2bb1e5390 | ||
|
|
398281a93f | ||
|
|
9d375dcb40 | ||
|
|
57f38fedba | ||
|
|
ae8f6728d7 | ||
|
|
d204bf5d77 | ||
|
|
156835c048 | ||
|
|
e20560afa8 | ||
|
|
6b31e94501 | ||
|
|
a824acc1b1 | ||
|
|
a72f5274ca | ||
|
|
441d3cc3de | ||
|
|
9ab4896c90 | ||
|
|
9293109ba1 | ||
|
|
96bfd94313 | ||
|
|
3ad5d0baea | ||
|
|
440460255b | ||
|
|
aeb0a473db | ||
|
|
0ce5e4eed3 | ||
|
|
ed6d1d76db | ||
|
|
a8a9b7d99e | ||
|
|
39571e20d6 | ||
|
|
0796a01571 | ||
|
|
57b19f9914 | ||
|
|
c50458a098 | ||
|
|
d20ef60037 | ||
|
|
8f037a74dc | ||
|
|
ad2858a80c | ||
|
|
5a1cddeaf0 | ||
|
|
3c1b4ebee2 | ||
|
|
be93291065 | ||
|
|
6ac513caa0 | ||
|
|
e38beed796 | ||
|
|
6b592c13c0 | ||
|
|
72764cff90 | ||
|
|
f02af9330b | ||
|
|
c544c26b66 | ||
|
|
fa01aa00bf | ||
|
|
0b9acd6382 | ||
|
|
f5fb7a0f8a | ||
|
|
61905bacdc | ||
|
|
9b65d4bcf7 | ||
|
|
e96389778f | ||
|
|
ffe2ec51c0 | ||
|
|
b7d8b9e6ee | ||
|
|
e5a62f7ffd | ||
|
|
7674b78c33 | ||
|
|
3f402a3481 | ||
|
|
278d9257eb | ||
|
|
33cb131b3a | ||
|
|
615181050b | ||
|
|
8acbd333d8 | ||
|
|
00130e08aa | ||
|
|
f6e64ba985 | ||
|
|
cbece6324f | ||
|
|
97dfa457a4 | ||
|
|
34c5c2d134 | ||
|
|
73d6e2eeeb | ||
|
|
24cd39fca5 | ||
|
|
65526a6a7f | ||
|
|
b593489041 | ||
|
|
a91ba16a5c | ||
|
|
1063156f12 | ||
|
|
2b8b7b2aa7 | ||
|
|
eed7239aa3 | ||
|
|
9d8f89d260 | ||
|
|
590b3a7e67 | ||
|
|
6842b69eed | ||
|
|
ceab345215 | ||
|
|
d72cb704f3 | ||
|
|
913e3187f1 | ||
|
|
43809178ee | ||
|
|
581d165bda | ||
|
|
a9d0cab2dd | ||
|
|
480180290e | ||
|
|
9d6ac98d3a | ||
|
|
2e502e09a2 | ||
|
|
e17cdb5162 | ||
|
|
d11f95bc40 | ||
|
|
6dfcb0e9bb | ||
|
|
8b07711d27 | ||
|
|
ab71f27298 | ||
|
|
3d2bfe9f6d | ||
|
|
6fe01ecc1f | ||
|
|
7d30a39020 | ||
|
|
4f040c4c8e | ||
|
|
5f8d40caf0 | ||
|
|
c71f21f3c9 | ||
|
|
5d6f610a7c | ||
|
|
ac478127d5 | ||
|
|
2aa6ed21e7 | ||
|
|
7b9af60541 | ||
|
|
f03a9e5ddc | ||
|
|
d32dfc9be1 | ||
|
|
2f607a04f1 | ||
|
|
05778a47c0 | ||
|
|
d23cb267d0 | ||
|
|
bb14064b18 | ||
|
|
f29b9831a5 | ||
|
|
598e95f4c4 | ||
|
|
2e387ac1e0 | ||
|
|
2946e28352 | ||
|
|
adbd349920 | ||
|
|
70a394d228 | ||
|
|
abbca50ce7 | ||
|
|
7f1cae21cf | ||
|
|
8e48ba80f6 | ||
|
|
8721d7d911 | ||
|
|
1d072be0a9 | ||
|
|
b607ade972 | ||
|
|
a56df6eb8e | ||
|
|
590d6b59b6 | ||
|
|
244efed878 | ||
|
|
7648ea79ce | ||
|
|
931f1d45aa | ||
|
|
e77f7b7361 | ||
|
|
ca25e91087 | ||
|
|
3f0b4aeeea | ||
|
|
02289aee44 | ||
|
|
f2007326c4 | ||
|
|
e9fb255a29 | ||
|
|
f911a442c4 | ||
|
|
d433b64cf0 | ||
|
|
3938caf00a | ||
|
|
d12488f40d | ||
|
|
63eb4f315c | ||
|
|
95a3889699 | ||
|
|
a53356e090 | ||
|
|
e6a31489fe | ||
|
|
e645e6df9f | ||
|
|
58f5f18540 | ||
|
|
7e4751ba62 | ||
|
|
4e9feaaead | ||
|
|
1d3d379d6c | ||
|
|
51d5858b3d | ||
|
|
a253dc83be | ||
|
|
1fff72aa3f | ||
|
|
2d8e7871f6 | ||
|
|
78afb68962 | ||
|
|
3434747d1c | ||
|
|
260435e438 | ||
|
|
578cae5240 | ||
|
|
0bce4b1123 | ||
|
|
13a0bfdf87 | ||
|
|
687056f3f5 | ||
|
|
eb3abe2053 | ||
|
|
3113ca8c57 | ||
|
|
1787e505a1 | ||
|
|
a0f61551cb | ||
|
|
ce575f4012 | ||
|
|
488f70c493 | ||
|
|
a0a9e4b579 | ||
|
|
18e98ac5f2 | ||
|
|
fff171c7dc | ||
|
|
9bfe78850d | ||
|
|
e0e68f255d | ||
|
|
71aeb86079 | ||
|
|
f0c8ae24ba | ||
|
|
c5325b0a17 | ||
|
|
6e95c649e6 | ||
|
|
405bce42c3 | ||
|
|
165ba9eca2 | ||
|
|
a358ed11fd | ||
|
|
32b1baa189 | ||
|
|
51beeeb2c4 | ||
|
|
658e395360 | ||
|
|
227b73683e | ||
|
|
fd64422276 | ||
|
|
6b70e5a3ad | ||
|
|
d271d8beab | ||
|
|
c9a0690c2d | ||
|
|
01c653a4c6 | ||
|
|
87f19941a3 | ||
|
|
a43bc3eb27 | ||
|
|
5c9e79081b | ||
|
|
fd364c57a8 | ||
|
|
7f029f768d | ||
|
|
24c1b723f9 | ||
|
|
ea6061e9fa | ||
|
|
5586e0591c | ||
|
|
15e0efe3bf | ||
|
|
9c61ac6768 | ||
|
|
1d2ed1e30d | ||
|
|
e072b22274 | ||
|
|
a48827ec7c | ||
|
|
9cf10bdde7 | ||
|
|
586494b726 | ||
|
|
63ceef6618 | ||
|
|
f87d3dc545 | ||
|
|
b371b68309 | ||
|
|
89976c85ed | ||
|
|
336d8f30ba | ||
|
|
af44114992 | ||
|
|
3c1a9f8ff7 | ||
|
|
584a154099 | ||
|
|
96e07b9a02 | ||
|
|
7dbb6c8e72 | ||
|
|
14597ccdf9 | ||
|
|
2a7d1dd206 | ||
|
|
4a1aac16fe | ||
|
|
ed9ee026dc | ||
|
|
3a702b3124 | ||
|
|
68462b81e0 | ||
|
|
89deee0fb2 | ||
|
|
ea12650f33 | ||
|
|
e2751062dc | ||
|
|
d2d9b1ac5d | ||
|
|
42b44c7564 | ||
|
|
b13a63f587 | ||
|
|
2b2fc94479 | ||
|
|
e97e568111 | ||
|
|
2877213396 | ||
|
|
471527c474 | ||
|
|
cba4eeba56 | ||
|
|
90472ee583 | ||
|
|
0d85510ba3 |
@@ -3,9 +3,14 @@ name: Анонс релиза
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
# remains allowed to send a test message.
|
||||
# #538: НЕТ триггера `release: published`. Три воркфлоу висели на одном событии
|
||||
# и бежали параллельно, а анонсу нечего было проверять — он выигрывал гонку
|
||||
# всегда. 12.09 v1.75.0 объявили в канале в ту же минуту, когда гейт ассетов
|
||||
# отказал: релиз остался без `houseplan-card.js`, E2E не выполнялся вовсе, а
|
||||
# подписчики получили сообщение. Анонс вызывается только после того, как работа
|
||||
# сделана: `release.yml` зовёт его после выкладки ассетов,
|
||||
# `publish-prerelease.yml` — после публикации беты.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -37,7 +42,7 @@ permissions:
|
||||
jobs:
|
||||
telegram:
|
||||
name: Оповещение в Telegram (только стабильные)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
@@ -54,32 +59,20 @@ jobs:
|
||||
INPUT_NAME: ${{ inputs.release_name }}
|
||||
INPUT_URL: ${{ inputs.url }}
|
||||
INPUT_PRE: ${{ inputs.prerelease }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ github.event.release.name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
RELEASE_PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
RELEASE_BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
if [ "$CALLED" = "true" ]; then
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
else
|
||||
TAG=$RELEASE_TAG
|
||||
NAME=$RELEASE_NAME
|
||||
URL=$RELEASE_URL
|
||||
PRE=$RELEASE_PRE
|
||||
BODY=$RELEASE_BODY
|
||||
fi
|
||||
# Единственный путь к сообщению о релизе — вызов из воркфлоу, который
|
||||
# уже закончил работу (#538). Тело берётся из файла заметок ветки
|
||||
# тега, а не из события: события здесь больше нет.
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
if [ "$PRE" = "true" ]; then
|
||||
echo "Prerelease Telegram announcement is disabled"
|
||||
exit 0
|
||||
|
||||
@@ -5,11 +5,12 @@ name: Мутационный гейт
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
|
||||
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
|
||||
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
|
||||
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
|
||||
# test/mutation-gate.test.mjs.
|
||||
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
|
||||
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
|
||||
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
|
||||
# Мутанты, задетые диффом, конвейер ревью гоняет отдельно на кандидате (#510).
|
||||
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
|
||||
# обычными юнитами: test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
@@ -25,14 +26,15 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
|
||||
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
|
||||
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
|
||||
# запуск (отладка гейта) отменял идущий по расписанию — так 24.08 погиб
|
||||
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
|
||||
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
|
||||
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
|
||||
@@ -111,8 +113,8 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
|
||||
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
|
||||
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
|
||||
# для отладки самого гейта, его результат смотрят в прогоне — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
#
|
||||
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
|
||||
# успехом в момент постановки Validate в очередь, и красный полный прогон не
|
||||
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
|
||||
# его: успешный dispatch — не успешная проверка.
|
||||
name: Ночной полный прогон dev
|
||||
|
||||
on:
|
||||
@@ -26,13 +31,34 @@ permissions:
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором"
|
||||
name: "Запустить Validate на dev с полным набором и дождаться результата"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
since=$(date -u +%FT%TZ)
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $since"
|
||||
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
|
||||
# раньше момента запуска. До трёх минут на появление в списке.
|
||||
run_id=""
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 10
|
||||
run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \
|
||||
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
|
||||
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
|
||||
[ -n "$run_id" ] && break
|
||||
done
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
|
||||
exit 1
|
||||
fi
|
||||
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
|
||||
echo "дочерний прогон: $url"
|
||||
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Ждём завершения; красный дочерний прогон — красный ночной.
|
||||
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
|
||||
|
||||
@@ -21,10 +21,12 @@ on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
# Два события по одному issue не должны запускать два прогона.
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Concurrency стоит на job, а не на workflow (#499). На уровне workflow в
|
||||
# группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки,
|
||||
# которую переставил сам конвейер. GitHub держит в группе один идущий и один
|
||||
# ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review
|
||||
# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает
|
||||
# ни одной job (`if` на guard) и в группу не входит.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -36,7 +38,13 @@ permissions:
|
||||
jobs:
|
||||
guard:
|
||||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||||
# Только статусные метки этапов ревью запускают конвейер (#499). Остальные
|
||||
# события помечаются skipped и не занимают место в группе concurrency.
|
||||
if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
@@ -57,12 +65,20 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
|
||||
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Контроллер идемпотентен (#499): событие только будит его, а состояние
|
||||
# читается ТЕКУЩЕЕ, не из снимка события. Прогон мог простоять в
|
||||
# очереди concurrency, пока владелец снял метку или поставил blocked —
|
||||
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
|
||||
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
|
||||
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
|
||||
has blocked && BLOCKED=true
|
||||
has review-4 && EXHAUSTED=true
|
||||
has small && SMALL=true
|
||||
has trivial && TRIVIAL=true
|
||||
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
stage=""; marker=""
|
||||
case "$LABEL" in
|
||||
@@ -70,6 +86,13 @@ jobs:
|
||||
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
|
||||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||||
esac
|
||||
# Метка, породившая событие, уже снята — запрос отозван. Это не отказ
|
||||
# и не повод для комментария: владелец передумал сам, шум ему не нужен.
|
||||
if [ -n "$stage" ] && ! has "$LABEL"; then
|
||||
echo "метка $LABEL уже снята с issue — запрос отозван, конвейер не запускается (#499)"
|
||||
echo "запрос отозван: \`$LABEL\` снята до старта" >> "$GITHUB_STEP_SUMMARY"
|
||||
stage=""
|
||||
fi
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
limit=4
|
||||
@@ -236,6 +259,9 @@ jobs:
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -295,20 +321,10 @@ jobs:
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
echo "name=$branch" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||||
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
|
||||
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
|
||||
# ветку, и спрашивать его поздно.
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||||
# длиннее одного.
|
||||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ
|
||||
# ребейза (#515): снятые здесь, они после force-push приведённой
|
||||
# ветки указывали на осиротевший коммит, и ни reuse (#499), ни
|
||||
# страховка #414 не находили дерева в свежем клоне.
|
||||
else
|
||||
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -331,6 +347,8 @@ jobs:
|
||||
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
# #539: тем же токеном спрашивается REST — через него идёт и диспатч.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
@@ -373,6 +391,25 @@ jobs:
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
fi
|
||||
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
|
||||
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
|
||||
# нельзя. 12.09 на #536 диспатч, отправленный через три секунды после
|
||||
# этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на
|
||||
# материале и вернул задачу автору, которому чинить было нечего.
|
||||
# Поэтому шаг не заканчивается, пока REST не отдаст новую вершину —
|
||||
# именно REST, потому что через него же идёт и сам диспатч.
|
||||
after=$(git rev-parse HEAD)
|
||||
settled=false
|
||||
for _ in $(seq 1 30); do
|
||||
seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \
|
||||
--jq .object.sha 2>/dev/null || true)
|
||||
if [ "$seen" = "$after" ]; then settled=true; break; fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA"
|
||||
exit 1
|
||||
fi
|
||||
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
|
||||
# и без этого он ребейзил бы заново уже приведённое.
|
||||
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
|
||||
@@ -387,9 +424,73 @@ jobs:
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||||
# хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая
|
||||
# копия равна тому, что ревьюер прочтёт, и коммит с этим деревом
|
||||
# уже запушен в ветку — следующий прогон найдёт его в свежем клоне.
|
||||
# В шаге публикации дерево уже сброшено на целевую ветку, и
|
||||
# спрашивать его поздно.
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||||
# длиннее одного.
|
||||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||||
# Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это
|
||||
# единственный якорь, доказывающий «вердикт вынесен на этом тексте».
|
||||
# Читается здесь, а не из github.event.issue.body: между событием
|
||||
# метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание
|
||||
# Validate), и снимок события описывал бы не тот текст.
|
||||
body=$(mktemp)
|
||||
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
|
||||
digest=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
|
||||
});
|
||||
' "$body")
|
||||
echo "issue_body=$digest" >> "$GITHUB_OUTPUT"
|
||||
echo "тело issue: ${digest:0:12}"
|
||||
else
|
||||
echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт"
|
||||
fi
|
||||
echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')"
|
||||
|
||||
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
|
||||
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
|
||||
# в S7, и ревьюер двенадцать минут заново разбирал дерево, в котором с r3
|
||||
# изменился ровно один файл — его собственный документ r3. Правило узкое:
|
||||
# последний документ этапа несёт записанный конвейером вердикт `green`
|
||||
# с High 0, и `git diff` между его якорем-деревом и HEAD пуст вне
|
||||
# docs/reviews/**. Любое иное отличие — ребейз, тест, фикстура, скрипт,
|
||||
# ТЗ — даёт полный разбор. Только этап code: материал spec может жить в
|
||||
# теле issue, которого в дереве нет.
|
||||
- name: "Зелёный вердикт прошлого захода применим без ревью (#499)"
|
||||
id: reuse
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
# Правка ТЗ между раундами обязана отменять повторное применение
|
||||
# зелёного вердикта: иначе вызов модели пропускается и находку
|
||||
# «ТЗ менялось» некому напечатать (#517 AC6).
|
||||
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
run: |
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
--issue-body="${ISSUE_BODY}")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
|
||||
echo "вердикт прошлого захода применяется повторно: модель не вызывается" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||||
@@ -426,6 +527,64 @@ jobs:
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
|
||||
# запускает Validate с мутантами на материале и ждёт его. Красный или
|
||||
# пропавший прогон возвращает задачу автору без ревью — цикл не
|
||||
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
|
||||
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
|
||||
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
|
||||
# слияние само дожидается Validate на кандидате.
|
||||
- name: Validate с мутантами на материале
|
||||
id: gate
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
run: |
|
||||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||||
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then
|
||||
echo 'proceed=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'proceed=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Validate красный — вернуть автору без ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
RESULT: ${{ steps.gate.outputs.result }}
|
||||
NOTE: ${{ steps.gate.outputs.note }}
|
||||
URL: ${{ steps.gate.outputs.url }}
|
||||
run: |
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
cat > /tmp/gate.md <<EOF
|
||||
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
|
||||
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
|
||||
2. дождаться зелёного дешёвого Validate на пуше;
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
|
||||
|
||||
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
|
||||
|
||||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||||
# ревью тратится на повторение CI вместо чтения кода.
|
||||
@@ -435,7 +594,7 @@ jobs:
|
||||
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
|
||||
- name: Зелёные гейты на этом SHA
|
||||
id: validated
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
@@ -464,21 +623,21 @@ jobs:
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
@@ -486,9 +645,66 @@ jobs:
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
|
||||
# Action ревью ставит Claude Code через `claude install`, и с его
|
||||
# v1.0.218 (Claude Code 2.1.265) лаунчер ~/.local/bin/claude на
|
||||
# ubuntu-latest иногда не появляется, хотя установщик рапортует об успехе;
|
||||
# action верит рапорту и падает на ENOENT (anthropics, issue 1817).
|
||||
# Кладём бинарник сами: версию берём ту, что пинит сам action (он уже
|
||||
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: |
|
||||
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
|
||||
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
ver="${ver:-2.1.265}"
|
||||
base=https://downloads.claude.ai/claude-code-releases
|
||||
bin="$HOME/.local/bin/claude"
|
||||
mkdir -p "$(dirname "$bin")"
|
||||
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
|
||||
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
|
||||
echo "$sum $bin" | sha256sum -c -
|
||||
chmod +x "$bin"
|
||||
"$bin" --version
|
||||
echo "path=$bin" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ТЗ живёт в теле issue (#517), а GitHub хранит его правки без diff и без
|
||||
# доступной чтением истории. Единственное, что можно доказать, — «текст
|
||||
# изменился после того, как ревью ТЗ признало его зелёным». Это не отказ:
|
||||
# автор вправе уточнять ТЗ. Это находка, доставляемая ревьюеру кода в
|
||||
# промпт и владельцу в комментарий вердикта: разбор AC в таком заходе
|
||||
# обязан быть полным, а не по дельте.
|
||||
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
|
||||
id: spec_body
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||||
run: |
|
||||
if [ -z "$DIGEST" ]; then
|
||||
echo "хеша тела нет — сравнивать не с чем"
|
||||
exit 0
|
||||
fi
|
||||
out=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const [num, digest] = process.argv.slice(1);
|
||||
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
|
||||
const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n")
|
||||
.filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name));
|
||||
const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) }));
|
||||
const changed = m.issueBodyChanged(docs, digest);
|
||||
if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`);
|
||||
else process.stdout.write("changed=false\n");
|
||||
});
|
||||
' "$NUM" "$DIGEST")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
@@ -498,6 +714,7 @@ jobs:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||||
prompt: |
|
||||
Ты ревьюер проекта House Plan. Язык ответа — русский.
|
||||
|
||||
@@ -514,6 +731,8 @@ jobs:
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
|
||||
${{ steps.spec_body.outputs.changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', steps.spec_body.outputs.doc, steps.spec_body.outputs.recorded) || '' }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
@@ -559,8 +778,9 @@ jobs:
|
||||
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
|
||||
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
|
||||
|
||||
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
|
||||
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
|
||||
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
|
||||
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
|
||||
есть у старой задачи, он и есть материал, новые не создаются.
|
||||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||||
указание способа доказательства. Отдельно проверь, что автор не
|
||||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||||
@@ -573,7 +793,14 @@ jobs:
|
||||
решаешь по существу в своём вердикте.
|
||||
|
||||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||||
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
|
||||
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
|
||||
`${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не
|
||||
делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт
|
||||
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
|
||||
ним. Если автор в issue называет более новый коммит, которого в
|
||||
материале нет, — это находка «материал не был запушен до метки», а не
|
||||
повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так,
|
||||
#499). Ручного тестирования в цикле нет,
|
||||
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
@@ -704,7 +931,7 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
@@ -712,10 +939,17 @@ jobs:
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
|
||||
# После ребейза конвейером — якоря приведённого материала (#515).
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
# Вердикт из structured_output попадает в блок якорей (#499): по нему
|
||||
# следующий заход решает, можно ли применить зелёный вердикт повторно.
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
run: |
|
||||
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
|
||||
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
@@ -770,7 +1004,9 @@ jobs:
|
||||
# может; блок помечен как машинный, чтобы никто не правил его руками.
|
||||
node scripts/review-doc-guard.mjs --anchor="$doc" \
|
||||
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
|
||||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS"
|
||||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
|
||||
--issue-body="$MATERIAL_ISSUE_BODY" \
|
||||
--verdict="$verdict" --high="$high"
|
||||
else
|
||||
echo "::warning::$SOURCE не найден — документа для публикации нет"
|
||||
fi
|
||||
@@ -860,7 +1096,7 @@ jobs:
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -877,13 +1113,30 @@ jobs:
|
||||
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
REUSE_DOC: ${{ steps.reuse.outputs.doc }}
|
||||
REUSE_ROUND: ${{ steps.reuse.outputs.round }}
|
||||
REUSE_TREE: ${{ steps.reuse.outputs.tree }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||||
high=$(echo "$OUT" | jq -r '.high')
|
||||
if [ "$REUSE" = "true" ]; then
|
||||
# Модель не вызывалась: вердикт — записанный конвейером зелёный
|
||||
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
|
||||
verdict=green; high=0
|
||||
short_tree=$(printf '%s' "$REUSE_TREE" | cut -c1-12)
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC}
|
||||
|
||||
Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор."
|
||||
else
|
||||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||||
high=$(echo "$OUT" | jq -r '.high')
|
||||
fi
|
||||
echo "вердикт: $verdict, High: $high"
|
||||
|
||||
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
|
||||
@@ -915,7 +1168,7 @@ jobs:
|
||||
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
|
||||
# случится (#364).
|
||||
- name: dev ушёл вперёд, пока шло ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
if: steps.gate.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
@@ -934,16 +1187,19 @@ jobs:
|
||||
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
|
||||
# ДО метки, иначе она врёт в промежутке.
|
||||
#
|
||||
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
|
||||
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
|
||||
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
|
||||
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
|
||||
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
|
||||
# Слияние — точный кандидат (#492 §4, scripts/merge-candidate.mjs):
|
||||
# ветка сверяется с материалом (#312); если dev не двигался — push с
|
||||
# lease на текущую вершину; если двигался — ребейз, сравнение patch-id
|
||||
# с проверенным диффом, публикация кандидата в ветку, ожидание
|
||||
# Validate на этом SHA и только потом push в dev с lease. Повторное
|
||||
# движение dev — новая попытка, не более трёх. Каждый исход, кроме
|
||||
# успеха, ведёт в S6-in-progress/S7-code-review с комментарием, ПОСЛЕ
|
||||
# ПРОГОНА МЕТКА МЕНЯЕТСЯ ВСЕГДА — инвариант тот же, что и раньше.
|
||||
- name: Слить ветку в dev
|
||||
id: merge
|
||||
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
@@ -954,61 +1210,11 @@ jobs:
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git fetch -q origin dev "$BRANCH"
|
||||
# #312: сливается только проверенный код. Допустимые вершины ветки:
|
||||
# сам SHA материала либо он же плюс ровно один коммит публикации
|
||||
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
|
||||
# ветка уехала после ревью, вердикт к ней не применим: возврат в
|
||||
# S6-in-progress через merged=false, как при конфликте.
|
||||
actual=$(git rev-parse "origin/$BRANCH")
|
||||
reviewed="$MATERIAL_SHA"
|
||||
fresh=false
|
||||
if [ "$actual" = "$reviewed" ]; then
|
||||
fresh=true
|
||||
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
|
||||
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
|
||||
fresh=true
|
||||
fi
|
||||
if [ "$fresh" != true ]; then
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
|
||||
cat > /tmp/stale-verdict.md <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
fi
|
||||
git checkout -q -B merge-into-dev "$actual"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase origin/dev; then
|
||||
git rebase --abort || true
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
|
||||
cat > /tmp/conflict.md <<EOF
|
||||
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
|
||||
|
||||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||||
2. запушить ветку;
|
||||
3. вернуть метку \`S7-code-review\`.
|
||||
|
||||
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
|
||||
exit 0
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
echo "слито в dev: $(git rev-parse --short HEAD)"
|
||||
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
|
||||
--issue="$NUM" --repo="${{ github.repository }}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
@@ -1016,7 +1222,10 @@ jobs:
|
||||
FROM: ${{ steps.decide.outputs.from }}
|
||||
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
|
||||
# автору: метка утверждала бы, что код в dev, а его там нет.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
|
||||
# Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 —
|
||||
# когда кандидат не слит (конфликт, красный Validate, изменившийся
|
||||
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
|
||||
run: |
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
|
||||
@@ -50,6 +50,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
# #514: the only check on a real Home Assistant. houseplan-e2e installs
|
||||
# the release's houseplan.zip — the bytes HACS ships — into HA in docker
|
||||
# and walks the sidebar page, dashboards, roles, PDF, restart and the
|
||||
# stable→tag upgrade. A red, missing or cancelled run withholds the
|
||||
# assets exactly like Full Performance. Cross-repository dispatch needs a
|
||||
# token with Actions: write on houseplan-e2e; HP_PROCESS_TOKEN (classic,
|
||||
# repo scope) has it, E2E_DISPATCH_TOKEN is the fallback for a
|
||||
# fine-grained token.
|
||||
- name: Require green E2E on a real Home Assistant for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: node scripts/e2e-gate.mjs --tag="$TAG"
|
||||
build:
|
||||
name: Сборка бандла и загрузка ассетов
|
||||
needs: gate
|
||||
@@ -82,6 +96,23 @@ jobs:
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
announce:
|
||||
# #538: анонс — последнее звено, а не параллельное. Пока он висел на самом
|
||||
# событии `release: published`, он обгонял гейт: 12.09 v1.75.0 объявили в
|
||||
# канале в ту же минуту, когда проверка отказала выкладывать ассеты.
|
||||
# `needs: build` означает, что молчание — это тоже ответ: красный гейт или
|
||||
# несостоявшаяся выкладка сообщения не рождают.
|
||||
name: Оповещение о релизе после выкладки
|
||||
needs: build
|
||||
uses: ./.github/workflows/announce.yml
|
||||
with:
|
||||
reusable: true
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
release_name: ${{ github.event.release.name }}
|
||||
url: ${{ github.event.release.html_url }}
|
||||
prerelease: ${{ github.event.release.prerelease }}
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
secrets: inherit
|
||||
hacs-discovery:
|
||||
name: HACS-видимость пре-релиза (порядок бет)
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
|
||||
@@ -18,9 +18,15 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full:
|
||||
description: 'Полный набор: смоки, golden, performance_smoke'
|
||||
description: 'Полный набор: смоки, golden, performance_smoke, мутанты по диффу'
|
||||
type: boolean
|
||||
default: true
|
||||
# Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние
|
||||
# кандидата запускают `-f full=false -f mutants=true` на материале.
|
||||
mutants:
|
||||
description: 'Мутанты по диффу на этом SHA (кандидат ревью)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
@@ -215,13 +221,13 @@ jobs:
|
||||
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
|
||||
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
|
||||
mutants: ${{ steps.classify.outputs.mutants }}
|
||||
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
|
||||
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
|
||||
heavy: ${{ steps.heavy.outputs.heavy }}
|
||||
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||||
# судить сам диапазон на dev (#388). Общее имя означало бы, что
|
||||
# потребитель однажды молча возьмёт чужую базу.
|
||||
# `base` — до какого коммита классифицировать файлы ветки (#387),
|
||||
# `range_base` — от какого судить диапазон на dev (#388).
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
@@ -241,6 +247,7 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
MUTANTS_INPUT: ${{ inputs.mutants }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -320,13 +327,28 @@ jobs:
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
|
||||
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
|
||||
# Решение — в scripts/classify-changes.mjs по единому manifest входов
|
||||
# (#492 §5.2): там оно проверяемо unit-тестом, здесь — только
|
||||
# переписывается в выходы (#473 AC8).
|
||||
printf '%s\n' "$files" | node scripts/classify-changes.mjs > /tmp/classify.out
|
||||
tee -a "$GITHUB_OUTPUT" < /tmp/classify.out
|
||||
# Файл, которого нет в manifest ни одной проверки, — не «не влияет»,
|
||||
# а «неизвестно»: прогон расширен до полного набора, и это видно в
|
||||
# summary, а не только в логе.
|
||||
unknown=$(sed -n 's/^unknown_inputs=//p' /tmp/classify.out)
|
||||
if [ -n "$unknown" ]; then
|
||||
{
|
||||
echo "### Неизвестные входы (#492)"
|
||||
echo "Файлы, которых нет в manifest ни одной проверки — прогон расширен до полного набора:"
|
||||
for f in $unknown; do echo "- \`$f\`"; done
|
||||
echo "Впишите файл в \`CHECKS\` или \`NOT_AN_INPUT\` (scripts/check-inputs.mjs) с причиной."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = хеш содержимого
|
||||
# всех входов job по единому manifest (#492, scripts/check-inputs.mjs):
|
||||
# что собирает, что исполняет, что читает, чем запускается — включая
|
||||
# `validate.yml`. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
@@ -461,14 +483,21 @@ jobs:
|
||||
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
|
||||
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
|
||||
# детерминированных непересекающихся шарда вместо ослабления проверки.
|
||||
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
|
||||
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
|
||||
# материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на
|
||||
# промежуточных пушах она стоила 48 из 56 часов job-минут и в основном
|
||||
# отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при
|
||||
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
|
||||
# её исход по job, и skipped был бы неотличим от «не запрашивали».
|
||||
changed_mutants:
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
|
||||
if: needs.changes.outputs.mutants_requested == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -479,33 +508,30 @@ jobs:
|
||||
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). Отменённый или упавший по таймауту
|
||||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||||
# сохраняется при любом исходе шага, а следующий пуш начинает с того
|
||||
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||||
# (файлы патча и гарда, без строки версии) повторно не гоняется.
|
||||
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
|
||||
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||||
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
|
||||
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
|
||||
# пропуск станет известен только после установки окружения.
|
||||
- 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: Затронутые мутанты ловятся
|
||||
# Cache immutable: rerun с тем же run_id обязан получить новый key,
|
||||
# иначе post-save не сможет записать прогресс поверх первой попытки.
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
|
||||
mutation-ledger-${{ matrix.shard }}-
|
||||
# #518: план шарда считается до установки окружения. npm ci, Python с
|
||||
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
|
||||
# их платили даже тогда, когда гонять было нечего: правка задела чужой
|
||||
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
|
||||
# git и node из образа.
|
||||
- name: План шарда
|
||||
id: plan
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -527,22 +553,57 @@ jobs:
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
|
||||
echo "count=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
|
||||
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
|
||||
echo "к прогону в этом шарде: ${count:-0}"
|
||||
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
run: npm ci
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
if: steps.plan.outputs.count != '0'
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.plan.outputs.count != '0'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Затронутые мутанты ловятся
|
||||
if: steps.plan.outputs.count != '0'
|
||||
env:
|
||||
BASE: ${{ steps.plan.outputs.base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
|
||||
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||||
# пока job не убита целиком. Ключ уникален на прогон: cache не
|
||||
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
|
||||
# пока job не убита целиком. Ключ уникален на попытку: cache не
|
||||
# перезаписывает существующий ключ, а restore-keys берёт предыдущую
|
||||
# попытку этого run либо самый новый журнал шарда.
|
||||
- name: Сохранить журнал свидетелей
|
||||
if: always()
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
# Без слэша: с ним шаблон не покрывает СИМВОЛИЧЕСКУЮ ССЫЛКУ с этим именем,
|
||||
# и `git add -A` в чужом worktree затягивает её в коммит (#520, прежде #429).
|
||||
node_modules
|
||||
tsout/
|
||||
test-build/
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -158,7 +158,16 @@ remaining work is the rebase. When the conflict is only in the committed bundle
|
||||
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
|
||||
tree as it was. Then push the branch and re-apply `S7-code-review`. When the
|
||||
only difference from the reviewed material is the pipeline's own review-document
|
||||
commit, the next run re-applies the green verdict without calling the model
|
||||
(#499); any other change to the tree — a rebase included — gets a full review.
|
||||
|
||||
The pipeline is an idempotent controller (#499): only `S4-spec-review` and
|
||||
`S7-code-review` start it, it reads the issue's *current* labels rather than the
|
||||
event snapshot, and a label removed before the run starts is treated as a
|
||||
withdrawn request. Push the material **before** applying the label — the reviewer
|
||||
is pinned to the SHA the pipeline captured and must not fetch newer commits. 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.
|
||||
|
||||
@@ -221,7 +230,12 @@ pipeline, which reviews without anyone asking and takes ten to forty-five minute
|
||||
session.** Reporting "handed over for review" stops a conveyor that could have kept
|
||||
moving on its own. An agent has no clock — it exists only during its own turn — so
|
||||
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
|
||||
the command timeout. Watch the **label**, not the comment: the label is the state,
|
||||
the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue NN
|
||||
[--sha <tip>]` (#496): it watches the label, the pipeline's own comments (conflict,
|
||||
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
|
||||
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
|
||||
4 on timeout — the same 90 s × 30 without a model turn per tick. It writes
|
||||
nothing. Watch the **label**, not the comment: the label is the state,
|
||||
the comment only explains it. Do not wait at all while `blocked` is set — the task
|
||||
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
|
||||
tell the owner: a failed run leaves the label where it was, forever.
|
||||
@@ -291,10 +305,14 @@ does not meet.
|
||||
|
||||
## Specs
|
||||
|
||||
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
|
||||
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
|
||||
on which surface, at what moment; and what the person sees before and after, in one
|
||||
sentence without implementation terms.
|
||||
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
|
||||
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
|
||||
and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two
|
||||
product ones: which persona meets this, on which surface, at what moment; and what
|
||||
the person sees before and after, in one sentence without implementation terms.
|
||||
Proof that a verdict was passed on a given text is the pipeline's job: it writes
|
||||
the `sha256` of the normalised body into the review document's anchor block, and
|
||||
an edit made after a green spec review reaches the code reviewer as a finding.
|
||||
|
||||
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
|
||||
fact is the worst kind of defect: it passes review because it looks like a decision.
|
||||
@@ -341,8 +359,18 @@ 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.
|
||||
diff selects but does not run them by default — `npm run gate:small -- --smokes`
|
||||
(#496) adds the browser phase after the artefact preparation: `bundle-sync`, then
|
||||
the directly matched and registered smokes two at a time; "broad" matches stay
|
||||
the reviewer's call. `golden`, `pytest` and `check-docs --screenshots=strict`
|
||||
remain the author's call by diff and AC.
|
||||
|
||||
**Start a task from its packet** (#496): `node scripts/task-packet.mjs --issue NN`
|
||||
prints one derived view — status and track, what the status permits, the owner's
|
||||
recent decisions, the branch against `dev` and Validate on its tip, the previous
|
||||
verdict with its recorded tree, AC → evidence from the last review document and
|
||||
what is still unwitnessed. It reads GitHub and git and writes nothing; the labels
|
||||
remain the only source of status.
|
||||
|
||||
**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
|
||||
@@ -363,6 +391,18 @@ demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues`
|
||||
locally with `gh` available before pushing (without `gh` the hook cannot check the
|
||||
issue status and stays silent). After `S7-code-review` do not push to the branch
|
||||
until the verdict or the return arrives: a push on top of a running review cancels
|
||||
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
|
||||
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
|
||||
with the diff mutants on the material itself and returns a red one to `S6` without
|
||||
spending a review cycle. Mutants by diff no longer run on ordinary pushes — only
|
||||
on the review candidate, the merge candidate, the beta candidate, PRs and the
|
||||
nightly run — so a routine push costs ~3 minutes; 08–09.09 they cost 48 of 56
|
||||
Validate job-hours and were mostly cancelled by the next push.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
@@ -385,7 +425,14 @@ simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build.
|
||||
index records the platform next to the Chromium build. The one local
|
||||
shortcut is `npm run docs:accept -- --identical` (#512): it re-captures on this
|
||||
machine, compares decoded pixels with the committed frames and, only when every
|
||||
frame is identical, refreshes the manifest fingerprints — frames that differ go
|
||||
through the artefact as before. The displayed card version reaches the DOM
|
||||
through `displayVersion()` (`src/card-version.ts`); the global
|
||||
`__HP_VERSION_OVERRIDE__` behind it is for harnesses only and the product never
|
||||
sets it.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
@@ -421,14 +468,19 @@ an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
went red after the #113 merge. A stable release additionally waits for Full
|
||||
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
|
||||
dispatched on the tag by `release.yml`, #514); betas and the development cycle
|
||||
never run E2E.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
## Environments
|
||||
|
||||
**Local Windows checkout** is the day-to-day environment: Node 22 as in CI, Python
|
||||
3.13 in a venv, `gh` authenticated. `.venv-backend` does **not** exist there — it is
|
||||
**Local Windows checkout** is the day-to-day environment: Node 22 and Python 3.14
|
||||
as in CI (`npm run toolchain:check` compares the machine with the pins CI actually
|
||||
uses — `.nvmrc` and `.python-version` are derived from the same sources, #496),
|
||||
`gh` authenticated. `.venv-backend` does **not** exist there — it is
|
||||
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
|
||||
Playwright Chromium.
|
||||
|
||||
|
||||
@@ -122,9 +122,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
|
||||
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
|
||||
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
|
||||
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
|
||||
- **Артефакт:** **тело issue**, раздел `## ТЗ` (решение владельца 2026-09-10,
|
||||
#517). Файл в `docs/specs/` не создаётся ни на одном треке: каталог — архив
|
||||
ТЗ до этой даты, и задачи, у которых файл уже есть, доживают по старой схеме.
|
||||
Доказуемость («вердикт вынесен на этом тексте») держит конвейер: в блок якорей
|
||||
документа ревью пишется `sha256` нормализованного тела, и правка ТЗ после
|
||||
зелёного ревью ТЗ приходит ревьюеру кода находкой, а не тишиной.
|
||||
- **Выход:** полная первая редакция по §7.
|
||||
|
||||
### 2.4 ТЗ на ревью
|
||||
@@ -155,8 +158,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
|
||||
- влияние на производительность и бюджеты названо (или явно «нет»);
|
||||
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
|
||||
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
|
||||
документация, golden/скриншоты, performance/security);
|
||||
- release-артефакты названы: changelog RU+EN, документация, golden/скриншоты,
|
||||
performance/security — либо явное «нет»;
|
||||
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
|
||||
- открытых продуктовых вопросов нет; риски перечислены.
|
||||
|
||||
@@ -428,8 +431,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
**Что упрощается:**
|
||||
|
||||
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
|
||||
доказательством · откат. Файл в `docs/specs/` не создаётся;
|
||||
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
|
||||
(в теле issue, как и на полном треке с 2026-09-10);
|
||||
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
|
||||
- лимит ревью ТЗ — 2 цикла.
|
||||
|
||||
@@ -488,7 +491,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
| Роль | Делает | Не имеет права |
|
||||
|---|---|---|
|
||||
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
|
||||
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
|
||||
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
|
||||
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
|
||||
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
|
||||
@@ -520,7 +523,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
```
|
||||
issue #NN
|
||||
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
|
||||
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
|
||||
↔ ветка issue/NN-slug
|
||||
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
|
||||
@@ -593,10 +596,9 @@ issue #NN
|
||||
|
||||
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
|
||||
|
||||
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
|
||||
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
|
||||
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
|
||||
таблицу «issue ↔ ТЗ».
|
||||
1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт
|
||||
в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён
|
||||
вместе с самой таблицей. Статус задачи — только метка `S*`.
|
||||
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
|
||||
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
|
||||
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
|
||||
@@ -620,13 +622,13 @@ node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый ко
|
||||
npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
|
||||
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
|
||||
python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд
|
||||
```
|
||||
|
||||
**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
|
||||
`any` в 49 файлах; перетипизировать это одним заходом — месяц риска ради нуля
|
||||
пользовательской ценности, поэтому долг снимается при плановом извлечении
|
||||
подсистем (#34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
|
||||
подсистем (#425, прежний #34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
|
||||
**только добавленные строки**: существующий долг на нетронутой строке законен,
|
||||
правка строки со старым `any` — новая ответственность. Исключение объявляется на
|
||||
той же строке, `// any-ok: <конкретная причина>`; голый маркер и причины вида
|
||||
@@ -653,6 +655,13 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
|
||||
человек.
|
||||
|
||||
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
|
||||
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
|
||||
декодирует оба набора в Chromium и при нуле отличающихся пикселей во всех
|
||||
кадрах обновляет только отпечаток исходников в `screenshots.json`; байты
|
||||
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
|
||||
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
@@ -694,7 +703,9 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
|
||||
|
||||
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
|
||||
Performance зелёные на точном SHA; статусов issue не касается.
|
||||
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
|
||||
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на теге и ждёт его
|
||||
зелёного (#514); статусов issue не касается.
|
||||
|
||||
---
|
||||
|
||||
@@ -811,9 +822,11 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
|
||||
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
|
||||
2. имя ветки `issue/NN-slug` соответствует трейлерам;
|
||||
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
|
||||
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
|
||||
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
|
||||
3. у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1` в теле issue —
|
||||
либо архивный `docs/specs/NN-*.md` у задачи до 2026-09-10. Офлайн тела нет,
|
||||
и проверка молчит; с `--issues` — предупреждение (настоящий рубеж — ревью ТЗ).
|
||||
Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог
|
||||
заморожен (#517);
|
||||
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
|
||||
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>`;
|
||||
@@ -897,12 +910,42 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
|
||||
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
|
||||
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
|
||||
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
|
||||
только по запросу: на кандидате ревью, кандидате слияния (#492), кандидате беты,
|
||||
в ночном прогоне и на PR; обычный push обходится дешёвыми гейтами (~3 минуты).
|
||||
За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов
|
||||
job-минут Validate и в основном отменялись следующим пушем.
|
||||
|
||||
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
|
||||
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
|
||||
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
|
||||
диффу, который читает ханки `git diff --unified=0`. Сторона гарда осталась
|
||||
файловой: у гарда якоря нет. Неоднозначный якорь и непрочитанные ханки дают
|
||||
прежний широкий ответ — незнание не доказательство. Приближение того же класса,
|
||||
что и сам отбор по диффу; нижняя граница — ночной полный гейт (#513). Шард
|
||||
считает свой план до установки окружения и при пустом плане не платит за
|
||||
npm ci, Python и Chromium, оставаясь исполненной job: доказательство гейта
|
||||
требует успешной job, а не пропущенной.
|
||||
|
||||
**Один хендофф — один пуш.** Перед пушем — локальный `node scripts/process-gate.mjs
|
||||
--issues` при доступном `gh` (хук без `gh` статус issue не проверяет и молчит);
|
||||
после `S7-code-review` в ветку не пушить, пока не пришёл вердикт или возврат: пуш
|
||||
поверх идущего ревью отменяет его и стоит 10–20 минут раннера, а после фиксации
|
||||
материала — ещё и слияние (#312). `S7` ставится один раз на заход, не после
|
||||
каждого фикса CI: красный Validate конвейер вернёт сам.
|
||||
|
||||
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
|
||||
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
|
||||
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
|
||||
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
|
||||
секунд, не более 30 попыток. Смотреть на метку, а не на комментарий: метка и есть
|
||||
состояние. При `blocked` не ждать — задача ждёт владельца.
|
||||
секунд, не более 30 попыток — `node scripts/wait-verdict.mjs --issue NN` делает его
|
||||
детерминированно и говорит только при смене состояния (#496). Смотреть на метку, а
|
||||
не на комментарий: метка и есть состояние. При `blocked` не ждать — задача ждёт
|
||||
владельца.
|
||||
|
||||
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
|
||||
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
|
||||
@@ -933,6 +976,29 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
ветки и пушем автор мог запушить коммит, и слепой `--force` потерял бы его молча.
|
||||
Расхождение lease — падение прогона, а не предупреждение.
|
||||
|
||||
**В `dev` уезжает точный кандидат, и только проверенный** (#492,
|
||||
`scripts/merge-candidate.mjs`). Ревью длится десятки минут, `dev` за это время
|
||||
двигается; ребейз после вердикта даёт дерево, которого никто не видел, — а чистый
|
||||
ребейз ничего не доказывает: соседняя правка в `dev` меняет поведение без единого
|
||||
конфликта. Шаг слияния поэтому:
|
||||
|
||||
- сверяет вершину ветки с материалом ревью (#312) — иначе `S6-in-progress`;
|
||||
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
|
||||
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
|
||||
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
|
||||
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
|
||||
|
||||
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
|
||||
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
|
||||
каждое движение `dev` в двадцатиминутный прогон, а проверяет ровно то, что
|
||||
проверил бы пуш той же дельты.
|
||||
|
||||
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
|
||||
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
|
||||
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
|
||||
@@ -942,6 +1008,18 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
|
||||
сообщать владельцу, а не продолжать опрос.
|
||||
|
||||
**Конвейер — идемпотентный контроллер, а событие лишь будит его** (#499). Guard
|
||||
читает метки issue текущими, а не из снимка события: прогон мог простоять в очереди,
|
||||
пока владелец снял метку — отозванный запрос не исполняется, и комментария об этом
|
||||
нет. Конвейер запускают только `S4-spec-review` и `S7-code-review`; остальные метки
|
||||
не создают ни одной job и не входят в concurrency-группу issue — прежде любая
|
||||
посторонняя метка вытесняла ожидающий запуск ревью. Зелёный вердикт применяется
|
||||
повторно **без вызова модели**, если последний документ этапа несёт записанный
|
||||
конвейером вердикт `green` с High 0 и дерево материала не изменилось ни в одном
|
||||
файле вне `docs/reviews/**` (сравнивает `git diff` по содержимому). Ребейз, правка
|
||||
теста, фикстуры или ТЗ дают отличие дерева и полный разбор — правило §7.2 не
|
||||
ослабляется, оно просто не касается дерева, которое уже читали.
|
||||
|
||||
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
|
||||
считались все вердикты подряд, и первое код-ревью #89 получило `r2/4`.
|
||||
|
||||
@@ -1047,9 +1125,9 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
|
||||
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
|
||||
одна `S*`-метка, инварианты чистые.
|
||||
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
|
||||
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
|
||||
код, которого уже нет.
|
||||
2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/`
|
||||
стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых
|
||||
документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет.
|
||||
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
|
||||
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
|
||||
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
|
||||
|
||||
@@ -29,3 +29,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Tabler Icons — summary panel outline icons
|
||||
|
||||
Source: https://github.com/tabler/tabler-icons
|
||||
The settings, sidebar-right, eye, eye-off, plus and chevron paths were provided
|
||||
in the designer reference for #505 and are distributed as inline SVG paths.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2026 Paweł Kuna
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
@@ -29,3 +29,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Tabler Icons — summary panel outline icons
|
||||
|
||||
Source: https://github.com/tabler/tabler-icons
|
||||
The settings, sidebar-right, eye, eye-off, plus and chevron paths were provided
|
||||
in the designer reference for #505 and are distributed as inline SVG paths.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2026 Paweł Kuna
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
@@ -30,6 +30,7 @@ from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import (
|
||||
HouseplanConfigEntry,
|
||||
async_resolve_pending_pair,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
create_data,
|
||||
@@ -83,6 +84,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
|
||||
entry.runtime_data = data
|
||||
|
||||
from .radar import RadarCoordinator
|
||||
data.radar_coordinator = RadarCoordinator(hass, data)
|
||||
await data.radar_coordinator.async_setup()
|
||||
|
||||
# server-side vacuum trails: the integration records the path itself
|
||||
from .trails import TrailRecorder
|
||||
recorder = TrailRecorder(hass, data)
|
||||
@@ -111,6 +116,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# repairs or housekeeping below.
|
||||
await async_setup_frontend_registration(hass, entry, card_path)
|
||||
|
||||
# Resolve an interrupted whole-plan pair before any other setup-time
|
||||
# writer reads or mutates either store. This is the same fence used by
|
||||
# runtime writers, so startup migration cannot build on a half-commit.
|
||||
optimize_revs: tuple[int, int] | None = None
|
||||
recovered_import = False
|
||||
async with data.write_lock:
|
||||
resolved = await async_resolve_pending_pair(data)
|
||||
if resolved.recovered_kind is not None:
|
||||
optimize_revs = (
|
||||
int(resolved.config_data.get("rev", 0)),
|
||||
int(resolved.layout_data.get("rev", 0)),
|
||||
)
|
||||
recovered_import = resolved.recovered_kind.startswith("import")
|
||||
_LOGGER.warning(
|
||||
"House Plan: completed an interrupted %s",
|
||||
resolved.recovered_kind.replace("_", " "),
|
||||
)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
# square and a plan is centred inside it. Nothing about the drawing changes
|
||||
@@ -154,66 +177,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# event must never see one migrated half and one old one
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
|
||||
|
||||
# Finish an explicit whole-plan optimization/undo interrupted between the
|
||||
# config and layout store writes. The target was persisted before either
|
||||
# visible half changed, so setup can always converge on the requested pair.
|
||||
optimize_revs: tuple[int, int] | None = None
|
||||
recovered_import = False
|
||||
async with data.write_lock:
|
||||
stored = await data.config_store.async_load() or {}
|
||||
lay_stored = await data.store.async_load() or {}
|
||||
pending = lay_stored.get("optimize_pending")
|
||||
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
|
||||
and isinstance(pending.get("layout"), dict):
|
||||
target_config = pending["config"]
|
||||
target_layout = pending["layout"]
|
||||
config_rev = int(stored.get("rev", 0))
|
||||
layout_rev = int(lay_stored.get("rev", 0))
|
||||
target_config_rev = int(pending.get(
|
||||
"config_rev", config_rev + (stored.get("config") != target_config)
|
||||
))
|
||||
target_layout_rev = int(pending.get(
|
||||
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
|
||||
))
|
||||
if stored.get("config") != target_config or config_rev < target_config_rev:
|
||||
previous_config_rev = config_rev
|
||||
config_rev = max(config_rev, target_config_rev)
|
||||
await async_save_config_state(
|
||||
data,
|
||||
target_config,
|
||||
config_rev,
|
||||
previous_rev=previous_config_rev,
|
||||
)
|
||||
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
|
||||
layout_rev = max(layout_rev, target_layout_rev)
|
||||
exact_metadata = pending.get("final_metadata")
|
||||
replace_metadata = isinstance(exact_metadata, dict)
|
||||
metadata = dict(exact_metadata) if replace_metadata else None
|
||||
if not replace_metadata and not pending.get("clear_backup") \
|
||||
and "optimize_backup" in lay_stored:
|
||||
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
|
||||
remove_metadata = ["optimize_pending", "optimize_backup"]
|
||||
if pending.get("clear_backup"):
|
||||
# A recovered whole-plan undo replaces the complete layout;
|
||||
# a point-wise repair snapshot from the replaced layout must
|
||||
# not survive and later restore coordinates into the new pair.
|
||||
remove_metadata.append("repair_backup")
|
||||
await async_save_layout_state(
|
||||
data,
|
||||
lay_stored,
|
||||
target_layout,
|
||||
layout_rev,
|
||||
metadata=metadata,
|
||||
remove=tuple(remove_metadata),
|
||||
replace_metadata=replace_metadata,
|
||||
)
|
||||
optimize_revs = (config_rev, layout_rev)
|
||||
recovered_import = str(pending.get("kind") or "").startswith("import")
|
||||
_LOGGER.warning(
|
||||
"House Plan: completed an interrupted %s",
|
||||
str(pending.get("kind") or "plan optimization").replace("_", " "),
|
||||
)
|
||||
if optimize_revs is not None:
|
||||
# Setup-time geometry migration may have advanced either revision
|
||||
# after pair recovery. Events always describe the final durable stores.
|
||||
recovered_config = await data.config_store.async_load() or {}
|
||||
recovered_layout = await data.store.async_load() or {}
|
||||
optimize_revs = (
|
||||
int(recovered_config.get("rev", 0)),
|
||||
int(recovered_layout.get("rev", 0)),
|
||||
)
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
|
||||
if recovered_import:
|
||||
@@ -292,6 +264,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
if entry.runtime_data.radar_coordinator:
|
||||
entry.runtime_data.radar_coordinator.teardown()
|
||||
entry.runtime_data.radar_coordinator = None
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -57,8 +57,9 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.73.0-beta.5"
|
||||
VERSION = "1.75.0"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
# #43: the support transport is deliberately not configurable. A user supplied
|
||||
# URL would turn the integration into an SSRF proxy and make the privacy notice
|
||||
|
||||
@@ -13,6 +13,7 @@ import re
|
||||
import stat
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -34,6 +35,8 @@ MAX_RASTER_DIMENSION = 16_384
|
||||
MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4
|
||||
MAX_SVG_ELEMENTS = 5_000
|
||||
MAX_SVG_DEPTH = 64
|
||||
# Rendering follows href/url() chains; a chain this long is not art (#498).
|
||||
MAX_SVG_REF_DEPTH = 64
|
||||
MAX_SVG_ATTR_CHARS = 512_000
|
||||
MAX_SVG_ATTR_VALUE_CHARS = 65_536
|
||||
_SVG_TAGS = frozenset({
|
||||
@@ -269,22 +272,7 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
|
||||
if any(ref not in ids for ref in refs):
|
||||
raise DecorAssetError("invalid_image", "The SVG contains an unresolved local reference")
|
||||
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
|
||||
def _visit(node_id: str) -> None:
|
||||
if node_id in visiting:
|
||||
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
|
||||
if node_id in visited:
|
||||
return
|
||||
visiting.add(node_id)
|
||||
for ref in ref_graph.get(node_id, ()):
|
||||
_visit(ref)
|
||||
visiting.remove(node_id)
|
||||
visited.add(node_id)
|
||||
|
||||
for node_id in ids:
|
||||
_visit(node_id)
|
||||
_walk_reference_graph(ids, ref_graph)
|
||||
view_box = root.attrib.get("viewBox")
|
||||
width = _svg_number(root.attrib.get("width"))
|
||||
height = _svg_number(root.attrib.get("height"))
|
||||
@@ -309,6 +297,52 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
|
||||
return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h)
|
||||
|
||||
|
||||
def _walk_reference_graph(ids: set[str], ref_graph: dict[str, set[str]]) -> None:
|
||||
"""Reject cycles and chains of local references longer than MAX_SVG_REF_DEPTH.
|
||||
|
||||
Iterative on purpose: a flat chain of a few thousand `href`s passes every
|
||||
element/depth/attribute bound yet used to blow the interpreter's recursion
|
||||
limit inside a recursive DFS, which the upload view answered with a 500
|
||||
instead of a refusal (#498).
|
||||
|
||||
The limit is the longest chain *through* a node, memoised per node, not the
|
||||
stack height of whichever traversal happened to reach it first: a chain
|
||||
cut into short segments by a hostile `id` order must still be measured
|
||||
end to end (spec review #498 r1).
|
||||
"""
|
||||
longest: dict[str, int] = {}
|
||||
visiting: set[str] = set()
|
||||
for start in sorted(ids):
|
||||
if start in longest:
|
||||
continue
|
||||
stack: list[tuple[str, Iterator[str], int]] = [
|
||||
(start, iter(sorted(ref_graph.get(start, ()))), 1),
|
||||
]
|
||||
visiting.add(start)
|
||||
while stack:
|
||||
node_id, children, chain = stack[-1]
|
||||
ref = next(children, None)
|
||||
if ref is None:
|
||||
stack.pop()
|
||||
visiting.discard(node_id)
|
||||
longest[node_id] = chain
|
||||
if chain > MAX_SVG_REF_DEPTH:
|
||||
raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit")
|
||||
if stack:
|
||||
parent, parent_children, parent_chain = stack[-1]
|
||||
stack[-1] = (parent, parent_children, max(parent_chain, chain + 1))
|
||||
continue
|
||||
if ref in visiting:
|
||||
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
|
||||
if ref in longest:
|
||||
stack[-1] = (node_id, children, max(chain, longest[ref] + 1))
|
||||
continue
|
||||
if len(stack) > MAX_SVG_REF_DEPTH:
|
||||
raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit")
|
||||
visiting.add(ref)
|
||||
stack.append((ref, iter(sorted(ref_graph.get(ref, ()))), 1))
|
||||
|
||||
|
||||
def validate_asset(
|
||||
data: bytes, filename: str, declared_mime: str | None = None,
|
||||
) -> ValidatedAsset:
|
||||
|
||||
@@ -1,273 +1,311 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174",
|
||||
"fingerprint": "2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js",
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 289933,
|
||||
"initialViewGzipBytes": 288244,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js",
|
||||
"houseplan-card.js",
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 291890,
|
||||
"initialPanelGzipBytes": 289767,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 1957,
|
||||
"initialPanelOnlyGzipBytes": 2327,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
|
||||
"houseplan-assets/de-B2xCz0Y2.js",
|
||||
"houseplan-assets/editor-IhKLQpZc.js",
|
||||
"houseplan-assets/fr-DiZ0MsmF.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
|
||||
"houseplan-assets/guard-C2lVSPan.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CDt3rR2N.js",
|
||||
"houseplan-assets/iso-scene-render-B2S8l3JB.js",
|
||||
"houseplan-assets/live-interaction-runtime-iTa3Xqvq.js",
|
||||
"houseplan-assets/pdf-export-Dmz9UctW.js",
|
||||
"houseplan-assets/zigbee-topology-CZaP3VEx.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-COLrfV4a.js"
|
||||
"houseplan-assets/backdrop-pick-CciPZUUr.js",
|
||||
"houseplan-assets/de-uycSOqOJ.js",
|
||||
"houseplan-assets/editor-daj6fW7q.js",
|
||||
"houseplan-assets/fr-CMnfhkfR.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
|
||||
"houseplan-assets/guard-BXhRxwsH.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-DnPLAFkF.js",
|
||||
"houseplan-assets/iso-scene-render-XD6IkUPA.js",
|
||||
"houseplan-assets/live-interaction-runtime-BCgeh23Y.js",
|
||||
"houseplan-assets/pdf-export-B5BBwVOF.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js",
|
||||
"houseplan-assets/zigbee-topology-8yx_3zIp.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js"
|
||||
],
|
||||
"lazyGzipBytes": 383241,
|
||||
"lazyGzipBytes": 417495,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
|
||||
"houseplan-assets/editor-IhKLQpZc.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
|
||||
"houseplan-assets/guard-C2lVSPan.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
|
||||
"houseplan-assets/zigbee-topology-CZaP3VEx.js"
|
||||
"houseplan-assets/backdrop-pick-CciPZUUr.js",
|
||||
"houseplan-assets/editor-daj6fW7q.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
|
||||
"houseplan-assets/guard-BXhRxwsH.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
|
||||
"houseplan-assets/zigbee-topology-8yx_3zIp.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 195232,
|
||||
"lazyEditorGzipBytes": 204999,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js"
|
||||
"houseplan-assets/backdrop-pick-CciPZUUr.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14328,
|
||||
"lazyOnboardingGzipBytes": 13919,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-B2xCz0Y2.js",
|
||||
"houseplan-assets/fr-DiZ0MsmF.js"
|
||||
"houseplan-assets/de-uycSOqOJ.js",
|
||||
"houseplan-assets/fr-CMnfhkfR.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 50246,
|
||||
"lazyLocaleGzipBytes": 54822,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-C2lVSPan.js",
|
||||
"houseplan-assets/iso-scene-render-B2S8l3JB.js"
|
||||
"houseplan-assets/guard-BXhRxwsH.js",
|
||||
"houseplan-assets/iso-scene-render-XD6IkUPA.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13458,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 10252,
|
||||
"lazyFurnitureArtGzipBytes": 10251,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
|
||||
"houseplan-assets/pdf-export-Dmz9UctW.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
|
||||
"houseplan-assets/pdf-export-B5BBwVOF.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 120332,
|
||||
"lazyPdfGzipBytes": 120122,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
|
||||
"sha256": "dda27a953418c018d47cbd64b71af2dda10dd5d036538a2ce621ef58c860131e",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7358,
|
||||
"path": "houseplan-assets/backdrop-pick-CciPZUUr.js",
|
||||
"sha256": "e1a28d74ad3b3e8b3537e130469c6011300a67764bc8d4eb8f7af063afd652c8",
|
||||
"rawBytes": 19956,
|
||||
"gzipBytes": 6881,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-B2xCz0Y2.js",
|
||||
"sha256": "bb80b9f8d3b10ffdb1563573657fa892ab726fa9c6795cc010b9bb6e068e489c",
|
||||
"rawBytes": 84250,
|
||||
"gzipBytes": 25377,
|
||||
"path": "houseplan-assets/de-uycSOqOJ.js",
|
||||
"sha256": "1f1025e042c2beefdb858ed428e0c6873b0711f66411c2cd5c4ad3d62e9553f7",
|
||||
"rawBytes": 91402,
|
||||
"gzipBytes": 27694,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-IhKLQpZc.js",
|
||||
"sha256": "c9c18bc9007007e444f11378e2db99ade47495e5c7c4d4ff4db47347ceb167cd",
|
||||
"rawBytes": 3837,
|
||||
"gzipBytes": 1590,
|
||||
"path": "houseplan-assets/editor-daj6fW7q.js",
|
||||
"sha256": "1103cb4da4a0bc47c04801487c285e945fe4ca7926adf6e52800bbfbf5ec27ea",
|
||||
"rawBytes": 3832,
|
||||
"gzipBytes": 1584,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-DiZ0MsmF.js",
|
||||
"sha256": "5f7355fbb790735da981ab00499aa311f7faf5aa8f9a941ccc3a2a35da6b0cd1",
|
||||
"rawBytes": 86269,
|
||||
"gzipBytes": 24869,
|
||||
"path": "houseplan-assets/fr-CMnfhkfR.js",
|
||||
"sha256": "153f722d7983dbe9318280e36bc440ee0aaee395921627e86e6e680f775304b6",
|
||||
"rawBytes": 93667,
|
||||
"gzipBytes": 27128,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
|
||||
"sha256": "31ea8e24963afbf4cc05b2213411c09f2c7c53946637b27f6710cb3eb470d322",
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
|
||||
"sha256": "e218436400a1057aa9593cb1c27e8c8d58b49a2ac8ad1573e5065bddfc6f5214",
|
||||
"rawBytes": 33655,
|
||||
"gzipBytes": 10252,
|
||||
"gzipBytes": 10251,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/guard-C2lVSPan.js",
|
||||
"sha256": "f57ddc549f9be6eeaa229a640002526fedc539f044900b0e8a10f3594276e531",
|
||||
"path": "houseplan-assets/guard-BXhRxwsH.js",
|
||||
"sha256": "c39dc15896a989fa4120e00099d4686e9340cb83d7eba6df26fe28f6db0b6909",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 371,
|
||||
"gzipBytes": 368,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DEeKwv6C.js",
|
||||
"sha256": "15b1ab1f41ad11a6ecb6ce91523657fc930412d2b8f9c4fabe5151baf08133d0",
|
||||
"rawBytes": 1042320,
|
||||
"gzipBytes": 289129,
|
||||
"path": "houseplan-assets/houseplan-card-8Ch93AMN.js",
|
||||
"sha256": "3c2e105a32596279be0b78199d3d8ef5570e0ef8d17bf30a16174435bc8d12a3",
|
||||
"rawBytes": 1023948,
|
||||
"gzipBytes": 287440,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-B2xCz0Y2.js",
|
||||
"houseplan-assets/editor-IhKLQpZc.js",
|
||||
"houseplan-assets/fr-DiZ0MsmF.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CDt3rR2N.js",
|
||||
"houseplan-assets/iso-scene-render-B2S8l3JB.js",
|
||||
"houseplan-assets/live-interaction-runtime-iTa3Xqvq.js",
|
||||
"houseplan-assets/pdf-export-Dmz9UctW.js"
|
||||
"houseplan-assets/de-uycSOqOJ.js",
|
||||
"houseplan-assets/editor-daj6fW7q.js",
|
||||
"houseplan-assets/fr-CMnfhkfR.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-DnPLAFkF.js",
|
||||
"houseplan-assets/iso-scene-render-XD6IkUPA.js",
|
||||
"houseplan-assets/live-interaction-runtime-BCgeh23Y.js",
|
||||
"houseplan-assets/pdf-export-B5BBwVOF.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
|
||||
"sha256": "346e6cde25f6863916281d5d9078114d8255d40fea9a48abfe0a260a85f99fa9",
|
||||
"rawBytes": 631884,
|
||||
"gzipBytes": 168234,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
|
||||
"sha256": "5d2e22c3b55bf49f4141e7e642e37b5c100b36e6146cb7a1bf57876df4d2dea9",
|
||||
"rawBytes": 668813,
|
||||
"gzipBytes": 178483,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
|
||||
"houseplan-assets/guard-C2lVSPan.js",
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js",
|
||||
"houseplan-assets/zigbee-topology-CZaP3VEx.js"
|
||||
"houseplan-assets/backdrop-pick-CciPZUUr.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
|
||||
"houseplan-assets/guard-BXhRxwsH.js",
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js",
|
||||
"houseplan-assets/zigbee-topology-8yx_3zIp.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-COLrfV4a.js"
|
||||
"houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js",
|
||||
"sha256": "a275ce721c74bfaf1babe91f40948413d76e6cc132a00ce35f9d38f223b7efba",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6970,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js",
|
||||
"sha256": "4345517490d8e0e52e3c5c509502c85785902d78911c0921064e13c017bd03fd",
|
||||
"rawBytes": 28287,
|
||||
"gzipBytes": 7038,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/backdrop-pick-CciPZUUr.js",
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CDt3rR2N.js",
|
||||
"sha256": "d92a6d30d93ca7b6fb5ab873a469862893d8b4661c63b680fc38e790c8925b03",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3732,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-DnPLAFkF.js",
|
||||
"sha256": "64115cec8a70d6c12efa33bf2c54c97df34c77aba366d8ee8148a146a9a4c28f",
|
||||
"rawBytes": 10756,
|
||||
"gzipBytes": 3683,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js",
|
||||
"houseplan-assets/zigbee-topology-CZaP3VEx.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-COLrfV4a.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js",
|
||||
"houseplan-assets/zigbee-topology-8yx_3zIp.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-B2S8l3JB.js",
|
||||
"sha256": "9ccb3b2fc1f9f0a222f831611815d649981658e8bd41f2672ab3d8e09da35617",
|
||||
"path": "houseplan-assets/iso-scene-render-XD6IkUPA.js",
|
||||
"sha256": "efe46a8662594bab6dc4669e9e5820be74e89be95ecbb4892ad7a3383d867e33",
|
||||
"rawBytes": 38562,
|
||||
"gzipBytes": 13087,
|
||||
"gzipBytes": 13090,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-C2lVSPan.js",
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/guard-BXhRxwsH.js",
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-iTa3Xqvq.js",
|
||||
"sha256": "dc6912a8ed3b0b9dee80d3b504728d8fed8733a52ca179af760daaf849d491a8",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2128,
|
||||
"path": "houseplan-assets/live-interaction-runtime-BCgeh23Y.js",
|
||||
"sha256": "b22ef9073783f3cbaa4dfd756f4cff06607c27374622dc4b69bf5b02f61e81c9",
|
||||
"rawBytes": 6233,
|
||||
"gzipBytes": 2499,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-Dmz9UctW.js",
|
||||
"sha256": "d0ebeab685fa8d64f4a53c6cf3d0a079a02ba230fc0e68a42cd2a80b8eb658f4",
|
||||
"rawBytes": 237163,
|
||||
"gzipBytes": 110080,
|
||||
"path": "houseplan-assets/pdf-export-B5BBwVOF.js",
|
||||
"sha256": "b7a28b5e17e70a0a41e78ffae7cf8c99afaae1a7407098c21e1c8334e8162949",
|
||||
"rawBytes": 236244,
|
||||
"gzipBytes": 109871,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-CZaP3VEx.js",
|
||||
"sha256": "90ad4577b9b0cbfcc2b7c654c9e2f6c5caea028f4669acf1971cf63108c36534",
|
||||
"path": "houseplan-assets/summary-panel-editor-BRkGpUh9.js",
|
||||
"sha256": "2987629d3b00a1c19ecbb16bddf8378080e48eb8424589980102d8f3243236b7",
|
||||
"rawBytes": 14824,
|
||||
"gzipBytes": 3401,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-i2JV6WHE.js",
|
||||
"sha256": "488946689796491660ab1bdd4678c4b40cc0a313470564d3035c8a0e9589212f",
|
||||
"rawBytes": 2610,
|
||||
"gzipBytes": 1306,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js",
|
||||
"sha256": "4065e3510d83c9f90c0d940cabe5eb6559d6c0703ff5604000c5d5f474d48d37",
|
||||
"rawBytes": 72272,
|
||||
"gzipBytes": 19729,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-BRkGpUh9.js",
|
||||
"houseplan-assets/summary-panel-metrics-i2JV6WHE.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-8yx_3zIp.js",
|
||||
"sha256": "921ec7d673d3002fe9b7ead32f66f2ae51bb282388d560f3d6057bf7ef777251",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7427,
|
||||
"gzipBytes": 7432,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-COLrfV4a.js",
|
||||
"sha256": "6aa78fc9472bb0b02d3dc508a261b91e978652a463c29790848283b12f74f18b",
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js",
|
||||
"sha256": "26f39d1e04162a323f1f839cf777d6b8858bc224c14d9a009af18978cd345bc9",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1766,
|
||||
"gzipBytes": 1764,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js",
|
||||
"houseplan-assets/zigbee-topology-CZaP3VEx.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js",
|
||||
"houseplan-assets/zigbee-topology-8yx_3zIp.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "173cf7315cdd654750f812a4e8ac95c1777a725cd4b91b372b02829062267ee6",
|
||||
"sha256": "c8ffb958eb61db62b5b244addffea9304413311c2ba655db4dd4546595d826da",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 804,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DEeKwv6C.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "db260f0fbb15ffa5f12c0ffb4dbbc702882e0d14766142230d782b86d1323d86",
|
||||
"rawBytes": 5337,
|
||||
"gzipBytes": 1957,
|
||||
"sha256": "5c5492c7255657d35e560b6307540464868c3df544ecb48908709f823798211a",
|
||||
"rawBytes": 6165,
|
||||
"gzipBytes": 2327,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-card.js"
|
||||
"houseplan-assets/houseplan-card-8Ch93AMN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";import{t as e,l as o,x as t,dX as s,A as a,dY as i,dZ as l,d_ as n,dM as r,b as c}from"./houseplan-card-DEeKwv6C.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-8Ch93AMN.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{dS as r,dT as t,dU as e}from"./houseplan-card-8Ch93AMN.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 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";import{dK as r,dL as t,dM as s}from"./houseplan-card-DEeKwv6C.js";const e={},a=r(class extends t{constructor(){super(...arguments),this.ot=e}render(r,t){return t()}update(r,[t,e]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return s}else if(this.ot===t)return s;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,e)}});export{a as i};
|
||||
@@ -0,0 +1,203 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{b as a,A as e}from"./houseplan-card-8Ch93AMN.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-aJMNl6Eh.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
.checked=${d.localShow}
|
||||
?disabled=${d.busy} aria-label=${b("summary.show_local")}
|
||||
aria-describedby="summary-local-show-hint"
|
||||
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
|
||||
.title=${b("summary.settings")} wide flex-content
|
||||
dismiss-on-scrim aria-busy=${String(d.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor" @click=${()=>m.closeSource()}>
|
||||
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
|
||||
<section class="summary-general" aria-labelledby="summary-general-title">
|
||||
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
|
||||
<div class="summary-general-grid">
|
||||
${d.localOnly?e:a`<div class="summary-field">
|
||||
<label for="summary-panel-title">${b("summary.panel_title")}</label>
|
||||
<input id="summary-panel-title" type="text" .value=${d.draft.title}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&"title"===y?.path)}
|
||||
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
|
||||
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
|
||||
</div>`}
|
||||
${g}
|
||||
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
|
||||
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
|
||||
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
|
||||
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
|
||||
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
|
||||
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
|
||||
</div>
|
||||
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
|
||||
</section>
|
||||
${d.localOnly?e:a`
|
||||
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
|
||||
<header class="summary-blocks-heading">
|
||||
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
|
||||
<p>${b("summary.blocks_hint")}</p></div>
|
||||
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
|
||||
</header>
|
||||
<div class="summary-editor-blocks">
|
||||
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
|
||||
@dragover=${a=>a.preventDefault()}
|
||||
@drop=${a=>m.drop(a,`block:${t}`)}>
|
||||
<div class="summary-block-head">
|
||||
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
|
||||
@dragstart=${a=>m.dragStart(a,`block:${t}`)}>${l("grip")}</span>
|
||||
<div class="summary-order">
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.up")}
|
||||
aria-label=${b("summary.up")} ?disabled=${0===t||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t-1)})}>${l("up")}</button>
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.down")}
|
||||
aria-label=${b("summary.down")}
|
||||
?disabled=${t===d.draft.blocks.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<input class="summary-block-title" type="text" .value=${s.title}
|
||||
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
|
||||
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
|
||||
<button class="summary-icon-button summary-visibility" type="button"
|
||||
?disabled=${d.busy} aria-pressed=${String(s.visible)}
|
||||
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
|
||||
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
|
||||
<div class="summary-scope">
|
||||
<select .value=${s.scope.type} ?disabled=${d.busy}
|
||||
aria-label=${b("summary.scope")}
|
||||
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
|
||||
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
|
||||
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
|
||||
</select>
|
||||
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
|
||||
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
|
||||
aria-invalid=${"error"===n?.kind?"true":"false"}
|
||||
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
|
||||
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
|
||||
${c._model.map(e=>a`<option value=${e.id}
|
||||
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
|
||||
</select>`:e}
|
||||
</div>
|
||||
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
|
||||
</div>
|
||||
<div class="summary-block-body">
|
||||
<div class="summary-editor-values">
|
||||
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
|
||||
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
|
||||
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
|
||||
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
|
||||
@dragstart=${a=>m.dragStart(a,`value:${t}:${c}`)}>${l("grip")}</span>
|
||||
<div class="summary-order">
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.up")}
|
||||
aria-label=${b("summary.up")} ?disabled=${0===c||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.down")}
|
||||
aria-label=${b("summary.down")}
|
||||
?disabled=${c===s.values.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<div class="summary-value-label">
|
||||
<input type="text" .value=${i.label} ?disabled=${d.busy}
|
||||
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
|
||||
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
|
||||
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
|
||||
</div>
|
||||
<div class="summary-source-field">
|
||||
<button class="summary-source" type="button"
|
||||
?disabled=${d.busy}
|
||||
data-summary-source-owner=${S}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
|
||||
aria-invalid=${"error"===$?.kind?"true":"false"}
|
||||
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
|
||||
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
|
||||
<span class="summary-source-caption"><strong>${g}</strong>
|
||||
${_?a`<small>${_}</small>`:e}</span>
|
||||
${l("chevron")}
|
||||
</button>
|
||||
${k&&f?a`<div class="summary-source-picker"
|
||||
@click=${a=>a.stopPropagation()}
|
||||
@keydown=${a=>{"Escape"===a.key&&(a.preventDefault(),a.stopPropagation(),m.closeSource(!0))}}>
|
||||
<label>${b("summary.search_entities")}</label>
|
||||
<input type="search" data-summary-picker-search
|
||||
?disabled=${d.busy}
|
||||
aria-label=${b("summary.search_entities")} .value=${d.entityFilter}
|
||||
@input=${a=>m.setDialog({...d,entityFilter:a.target.value})} />
|
||||
<div class="summary-source-results" role="listbox"
|
||||
aria-label=${b("summary.select_source")}>
|
||||
<div class="summary-source-group">${b("summary.system_group")}</div>
|
||||
${["device_count","total_area","datetime"].map(e=>a`
|
||||
<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`system:${e}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
|
||||
${b(`summary.system.${e}`)}
|
||||
</button>`)}
|
||||
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
|
||||
<button type="button" role="option" class="broken" aria-selected="true"
|
||||
?disabled=${d.busy}
|
||||
@click=${()=>m.setSource(s.id,i.id,p)}>
|
||||
${g}
|
||||
</button>`:e}
|
||||
<div class="summary-source-group">${b("summary.entities_group")}</div>
|
||||
${f.entries.map(e=>a`<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`entity:${e.id}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
|
||||
<span>${e.label}</span><small>${e.id}</small>
|
||||
</button>`)}
|
||||
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
|
||||
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
|
||||
</div>
|
||||
</div>`:e}
|
||||
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
|
||||
</div>
|
||||
<button class="summary-icon-button summary-value-remove" type="button"
|
||||
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
|
||||
</div>`})}
|
||||
<button class="summary-add summary-add-value" type="button"
|
||||
?disabled=${s.values.length>=20||d.busy}
|
||||
title=${s.values.length>=20?b("summary.limit_values"):""}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
|
||||
${l("plus")}${b("summary.add_value")}
|
||||
</button>
|
||||
</div>
|
||||
<footer class="summary-block-footer">
|
||||
<button class="summary-delete-block" type="button" ?disabled=${d.busy}
|
||||
@click=${()=>m.deleteBlock(t)}>${b("summary.delete_block")}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</article>`})}
|
||||
</div>
|
||||
<button class="summary-add summary-add-block" type="button"
|
||||
?disabled=${d.draft.blocks.length>=10||d.busy}
|
||||
title=${d.draft.blocks.length>=10?b("summary.limit_blocks"):""}
|
||||
@click=${()=>m.mutate(a=>{a.blocks.push(u(b))})}>
|
||||
${l("plus")}${b("summary.add_block")}
|
||||
</button>
|
||||
</section>
|
||||
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
|
||||
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
|
||||
?disabled=${d.busy} @click=${()=>m.reload()}>
|
||||
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
|
||||
</button>`:e}
|
||||
`}
|
||||
</div>
|
||||
<div class="row summary-editor-footer" slot="footer">
|
||||
<button class="btn ghost" data-hp="dialog-cancel"
|
||||
?disabled=${d.busy} @click=${k}>${b("btn.cancel")}</button>
|
||||
<button class="btn on" data-hp="dialog-confirm"
|
||||
?disabled=${d.localOnly?!$||d.busy:!p}
|
||||
@click=${()=>m.save()}>${b("btn.save")}</button>
|
||||
</div>
|
||||
</hp-dialog>`};export{m as renderSummaryPanelEditor};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{e9 as e,L as t,a2 as n,J as i,an as o,N as r,bo as s,bn as a,a1 as c,a0 as l,ax as u,ea as d,eb as f,bq as m,ec as g}from"./houseplan-card-8Ch93AMN.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,u="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,l,u=_){try{let d=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=u(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,o,r,g.roomGeom,g.multiWallNodes)||a;v=y(v,s(c,m.physicalBodies)),yield}const _=m.cellCm/o;d+=a(v)*_*_/1e4}return d}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=d(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";import{n as t,b as n}from"./zigbee-topology-CZaP3VEx.js";import{F as e}from"./houseplan-card-DEeKwv6C.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{n as t,b as n}from"./zigbee-topology-8yx_3zIp.js";import{B as e}from"./houseplan-card-8Ch93AMN.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";try{await import("./houseplan-assets/houseplan-card-DEeKwv6C.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";try{await import("./houseplan-assets/houseplan-card-8Ch93AMN.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
|
||||
@@ -445,9 +445,13 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
# The staged file already sits under files_root: hand it to
|
||||
# the quota as `incoming` only, not as stored usage too (#498).
|
||||
await hass.async_add_executor_job(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT,
|
||||
partial(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,
|
||||
),
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
|
||||
@@ -35,6 +35,7 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .radar_validation import validate_marker_radars
|
||||
from .store import HouseplanData
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA,
|
||||
@@ -96,7 +97,7 @@ _SPACE_DISPLAY_FIELDS = (
|
||||
)
|
||||
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
|
||||
_ROOM_DISPLAY_FIELDS = (
|
||||
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
|
||||
"fill_mode", "custom_fill", "glow", "temp_min", "temp_max", "name_scale", "label_scale",
|
||||
)
|
||||
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
|
||||
_DECOR_KIND_FIELDS = {
|
||||
@@ -1223,6 +1224,31 @@ def _repair_target_space_refs(
|
||||
preserve_related(
|
||||
marker_id, "marker.value_source", old_target, resolve_marker,
|
||||
)
|
||||
radar = marker.get("radar")
|
||||
if isinstance(radar, dict):
|
||||
if may_rebind_room:
|
||||
mapped = replace(
|
||||
marker_id, "marker.radar.room_id", radar.get("room_id"), resolve_room,
|
||||
)
|
||||
if mapped is not None:
|
||||
radar["room_id"] = mapped
|
||||
allowed = radar.get("allowed_room_ids")
|
||||
if isinstance(allowed, list):
|
||||
remapped_allowed: list[Any] = []
|
||||
for room_id in allowed:
|
||||
mapped = replace(
|
||||
marker_id, "marker.radar.allowed_room_ids", room_id, resolve_room,
|
||||
)
|
||||
remapped_allowed.append(mapped if mapped is not None else room_id)
|
||||
radar["allowed_room_ids"] = remapped_allowed
|
||||
else:
|
||||
preserve_related(
|
||||
marker_id, "marker.radar.room_id", radar.get("room_id"), resolve_room,
|
||||
)
|
||||
for room_id in radar.get("allowed_room_ids") or []:
|
||||
preserve_related(
|
||||
marker_id, "marker.radar.allowed_room_ids", room_id, resolve_room,
|
||||
)
|
||||
|
||||
# Destination wins collisions. Rekey only proven dead plan-owned keys;
|
||||
# opaque HA owners remain literal.
|
||||
@@ -1410,6 +1436,22 @@ def build_space_merge(
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.room_id", new_id, old_room_id,
|
||||
)
|
||||
radar = marker.get("radar")
|
||||
if isinstance(radar, dict):
|
||||
radar_room = radar.get("room_id")
|
||||
if radar_room is not None:
|
||||
old_radar_room = str(radar_room)
|
||||
radar["room_id"] = old_room_ids.get(old_radar_room, radar_room)
|
||||
if radar["room_id"] != old_radar_room:
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.radar.room_id",
|
||||
new_id, old_radar_room,
|
||||
)
|
||||
allowed_rooms = radar.get("allowed_room_ids")
|
||||
if isinstance(allowed_rooms, list):
|
||||
radar["allowed_room_ids"] = [
|
||||
old_room_ids.get(str(room_id), room_id) for room_id in allowed_rooms
|
||||
]
|
||||
vacuum = marker.get("vacuum")
|
||||
if isinstance(vacuum, dict) and isinstance(vacuum.get("segment_map"), dict):
|
||||
remapped_segments = {}
|
||||
@@ -1437,6 +1479,11 @@ def build_space_merge(
|
||||
if isinstance(value_source, dict) \
|
||||
and value_source.get("kind") == "derived_marker_state":
|
||||
dropped_marker_links += 1
|
||||
if isinstance(marker.get("radar"), dict):
|
||||
_report_reference(
|
||||
reference_report, "droppedIncomingLinks", "marker.radar",
|
||||
new_id, old_id,
|
||||
)
|
||||
marker["binding"] = "virtual"
|
||||
marker["display"] = "static_icon"
|
||||
for field in (
|
||||
@@ -1444,6 +1491,7 @@ def build_space_merge(
|
||||
"vacuum", "is_light", "use_climate_temp", "glow_color",
|
||||
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
|
||||
"value_badge", "value_source",
|
||||
"radar",
|
||||
):
|
||||
marker.pop(field, None)
|
||||
output_markers.append(marker)
|
||||
@@ -1574,6 +1622,7 @@ def build_space_merge(
|
||||
try:
|
||||
validate_marker_controls(merged_config, current_config)
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_radars(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
validate_marker_vacuum_routes(merged_config, current_config)
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
@@ -1583,6 +1632,8 @@ def build_space_merge(
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
try:
|
||||
merged_layout = LAYOUT_SCHEMA(merged_layout)
|
||||
except vol.Invalid as err:
|
||||
@@ -1868,10 +1919,13 @@ def create_preview(
|
||||
try:
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(incoming_config, validate_all=True)
|
||||
validate_marker_radars(incoming_config, validate_all=True)
|
||||
validate_marker_value_badges(incoming_config, validate_all=True)
|
||||
validate_marker_vacuum_routes(incoming_config, validate_all=True)
|
||||
except MarkerControlError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
content, confirmation = _content_state(document, same_source, config_root)
|
||||
target_config, target_layout, details = _materialize_import_candidate(
|
||||
document, current_config, current_layout,
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.73.0-beta.5"
|
||||
"version": "1.75.0"
|
||||
}
|
||||
|
||||
@@ -198,12 +198,20 @@ class QuotaError(Exception):
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def dir_usage(path: Path) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read."""
|
||||
def dir_usage(path: Path, *, exclude: Path | None = None) -> tuple[int, int]:
|
||||
"""(bytes, files) below `path`, ignoring what we cannot read.
|
||||
|
||||
`exclude` is the caller's own staged upload: it already lives under `path`
|
||||
and its size arrives separately as `incoming`, so counting it here would
|
||||
charge the same bytes and the same file twice (#498). Any *other* staged
|
||||
file stays in the count — it is about to become an attachment.
|
||||
"""
|
||||
total = count = 0
|
||||
if not path.is_dir():
|
||||
return 0, 0
|
||||
for item in path.rglob("*"):
|
||||
if exclude is not None and item == exclude:
|
||||
continue
|
||||
try:
|
||||
if item.is_file():
|
||||
total += item.stat().st_size
|
||||
@@ -213,8 +221,10 @@ def dir_usage(path: Path) -> tuple[int, int]:
|
||||
return total, count
|
||||
|
||||
|
||||
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit.
|
||||
def check_quota(
|
||||
path: Path, incoming: int, max_bytes: int, max_files: int, *, exclude: Path | None = None,
|
||||
) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit (`exclude`: see dir_usage).
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
@@ -222,7 +232,7 @@ def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> No
|
||||
"""
|
||||
import shutil
|
||||
|
||||
used, count = dir_usage(path)
|
||||
used, count = dir_usage(path, exclude=exclude)
|
||||
if count + 1 > max_files:
|
||||
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
|
||||
if used + incoming > max_bytes:
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
"""Authoritative presence-radar coordinator (#485).
|
||||
|
||||
The coordinator is deliberately the only component which interprets radar
|
||||
entities. Browsers, recordings and room outputs consume the same normalized
|
||||
frame so freshness and coordinate semantics cannot drift between features.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import (
|
||||
async_call_later,
|
||||
async_track_state_change_event,
|
||||
async_track_state_report_event,
|
||||
)
|
||||
|
||||
from .radar_geometry import (
|
||||
clipped_arc_segments,
|
||||
length_cm,
|
||||
point_in_polygon,
|
||||
polar_to_cartesian,
|
||||
polygon_is_convex,
|
||||
project_local,
|
||||
)
|
||||
from .radar_validation import (
|
||||
radar_registry_evidence,
|
||||
radar_source_entity_ids,
|
||||
validate_radar_draft,
|
||||
)
|
||||
from .store import HouseplanData
|
||||
|
||||
MAX_FRAME_HZ = 4
|
||||
PAIR_MAX_AGE_S = 3.0
|
||||
PAIR_MAX_SKEW_S = 1.5
|
||||
_EXPLICIT_ABSENT = object()
|
||||
|
||||
|
||||
def _fingerprint(value: Any) -> str:
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
return hashlib.sha256(payload.encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
def _reported_timestamp(state: Any) -> float:
|
||||
value = getattr(state, "last_reported", None) or getattr(state, "last_updated", None)
|
||||
if value is None:
|
||||
return 0.0
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.timestamp()
|
||||
|
||||
|
||||
def _numeric_state(state: Any) -> float | None:
|
||||
if state is None or str(state.state).strip().lower() in {"", "unknown", "unavailable"}:
|
||||
return None
|
||||
try:
|
||||
value = float(state.state)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def _binary_state(state: Any) -> bool | None:
|
||||
if state is None:
|
||||
return None
|
||||
value = str(state.state).lower()
|
||||
if value == "on":
|
||||
return True
|
||||
if value == "off":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
class RadarCoordinator:
|
||||
"""One bounded state-report listener graph for the whole integration."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, runtime: HouseplanData) -> None:
|
||||
self.hass = hass
|
||||
self.runtime = runtime
|
||||
self.server_session_id = secrets.token_hex(12)
|
||||
self.closed = False
|
||||
self.config_rev = 0
|
||||
self.config: dict[str, Any] = {}
|
||||
self.radars: dict[str, dict[str, Any]] = {}
|
||||
self._seq: dict[str, int] = {}
|
||||
self._frames: dict[str, dict[str, Any]] = {}
|
||||
self._last_signature: dict[str, str] = {}
|
||||
self._listeners: set[Callable[[str, dict[str, Any]], None]] = set()
|
||||
self._internal_listeners: set[Callable[[str, dict[str, Any]], None]] = set()
|
||||
self._external_cleanups: set[Callable[[], None]] = set()
|
||||
self._unsub_sources: list[Callable[[], None]] = []
|
||||
self._unsub_config: Callable[[], None] | None = None
|
||||
self._unsub_tick: Callable[[], None] | None = None
|
||||
self._pending: asyncio.TimerHandle | None = None
|
||||
self._last_publish_monotonic = 0.0
|
||||
self._immediate_source_ids: set[str] = set()
|
||||
self._last_acl_epoch = -1
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
await self.async_refresh()
|
||||
if self.closed:
|
||||
return
|
||||
|
||||
@callback
|
||||
def config_updated(_event: Event) -> None:
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
self._unsub_config = self.hass.bus.async_listen(
|
||||
"houseplan_config_updated", config_updated
|
||||
)
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
async with self._lock:
|
||||
if self.closed:
|
||||
return
|
||||
stored = await self.runtime.config_store.async_load() or {}
|
||||
if self.closed:
|
||||
return
|
||||
config = stored.get("config") or {}
|
||||
registry = radar_registry_evidence(self.hass)
|
||||
self.config = config
|
||||
self.config_rev = int(stored.get("rev", 0))
|
||||
self.radars = {}
|
||||
for marker in config.get("markers") or []:
|
||||
if not isinstance(marker, dict) or not isinstance(marker.get("radar"), dict) \
|
||||
or marker["radar"].get("version") != 1 \
|
||||
or marker["radar"].get("enabled") is not True \
|
||||
or marker.get("removed") is True:
|
||||
continue
|
||||
marker_id = str(marker.get("id"))
|
||||
try:
|
||||
validate_radar_draft(config, marker_id, marker["radar"], registry)
|
||||
except vol.Invalid:
|
||||
# Change-aware config compatibility may retain an old or
|
||||
# future-invalid block. It stays lossless but never runs.
|
||||
continue
|
||||
self.radars[marker_id] = marker
|
||||
self._resubscribe_sources()
|
||||
self._publish_all(force=True)
|
||||
|
||||
def teardown(self) -> None:
|
||||
self.closed = True
|
||||
if self._pending:
|
||||
self._pending.cancel()
|
||||
self._pending = None
|
||||
if self._unsub_tick:
|
||||
self._unsub_tick()
|
||||
self._unsub_tick = None
|
||||
if self._unsub_config:
|
||||
self._unsub_config()
|
||||
self._unsub_config = None
|
||||
for unsub in self._unsub_sources:
|
||||
unsub()
|
||||
self._unsub_sources.clear()
|
||||
# Setup subscriptions own draft-specific HA listeners which are not
|
||||
# part of the saved source graph. Closing the config entry must tear
|
||||
# those down too, even if the browser WebSocket stays connected.
|
||||
for cleanup in tuple(self._external_cleanups):
|
||||
cleanup()
|
||||
self._external_cleanups.clear()
|
||||
self._listeners.clear()
|
||||
self._internal_listeners.clear()
|
||||
self._frames.clear()
|
||||
|
||||
@callback
|
||||
def add_external_cleanup(self, cleanup: Callable[[], None]) -> Callable[[], None]:
|
||||
"""Own a setup listener for the lifetime of this coordinator."""
|
||||
self._external_cleanups.add(cleanup)
|
||||
|
||||
@callback
|
||||
def unregister() -> None:
|
||||
self._external_cleanups.discard(cleanup)
|
||||
|
||||
return unregister
|
||||
|
||||
def _resubscribe_sources(self) -> None:
|
||||
for unsub in self._unsub_sources:
|
||||
unsub()
|
||||
self._unsub_sources.clear()
|
||||
entity_ids = sorted({
|
||||
entity_id
|
||||
for marker in self.radars.values()
|
||||
for entity_id in radar_source_entity_ids(marker.get("radar"))
|
||||
})
|
||||
if not entity_ids:
|
||||
self._immediate_source_ids.clear()
|
||||
return
|
||||
immediate: set[str] = set()
|
||||
for marker in self.radars.values():
|
||||
sources = marker.get("radar", {}).get("sources") or {}
|
||||
immediate.update(
|
||||
value for key in ("occupancy_entity", "count_entity", "availability_entity")
|
||||
if isinstance((value := sources.get(key)), str)
|
||||
)
|
||||
for group in ("slots", "ranges"):
|
||||
immediate.update(
|
||||
item["presence_entity"]
|
||||
for item in sources.get(group) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("presence_entity"), str)
|
||||
)
|
||||
immediate.update(
|
||||
item["entity_id"] for item in sources.get("zones") or []
|
||||
if isinstance(item, dict) and isinstance(item.get("entity_id"), str)
|
||||
)
|
||||
self._immediate_source_ids = immediate
|
||||
|
||||
@callback
|
||||
def source_reported(event: Event) -> None:
|
||||
entity_id = str(event.data.get("entity_id") or "")
|
||||
state = event.data.get("new_state")
|
||||
immediate_transition = entity_id in self._immediate_source_ids \
|
||||
or state is None or str(getattr(state, "state", "")).lower() \
|
||||
in {"", "unknown", "unavailable"}
|
||||
self._schedule_publish(immediate=immediate_transition)
|
||||
|
||||
# state_reported catches repeated coordinate values. state_changed is
|
||||
# retained for HA versions/integrations which do not emit a report event
|
||||
# for every change; one 250 ms coalescer deduplicates the overlap and
|
||||
# enforces the public four-frames-per-second ceiling.
|
||||
self._unsub_sources.append(async_track_state_report_event(
|
||||
self.hass, entity_ids, source_reported
|
||||
))
|
||||
self._unsub_sources.append(async_track_state_change_event(
|
||||
self.hass, entity_ids, source_reported
|
||||
))
|
||||
|
||||
@callback
|
||||
def _schedule_publish(self, *, immediate: bool = False) -> None:
|
||||
if self.closed:
|
||||
return
|
||||
if self._pending is not None:
|
||||
if not immediate:
|
||||
return
|
||||
# Availability and occupancy edges must not wait behind an already
|
||||
# queued coordinate batch. Replacing that handle remains bounded:
|
||||
# the two HA event streams still collapse into this one callback.
|
||||
self._pending.cancel()
|
||||
self._pending = None
|
||||
elapsed = self.hass.loop.time() - self._last_publish_monotonic
|
||||
delay = 0 if immediate else max(0.1, 1 / MAX_FRAME_HZ - elapsed)
|
||||
self._pending = self.hass.loop.call_later(delay, self._flush_pending)
|
||||
|
||||
@callback
|
||||
def _flush_pending(self) -> None:
|
||||
self._pending = None
|
||||
self._last_publish_monotonic = self.hass.loop.time()
|
||||
self._publish_all()
|
||||
|
||||
def _ensure_tick(self) -> None:
|
||||
if self._unsub_tick or not (self._listeners or self._internal_listeners):
|
||||
return
|
||||
|
||||
@callback
|
||||
def tick(_now: datetime) -> None:
|
||||
self._unsub_tick = None
|
||||
self._publish_all()
|
||||
self._ensure_tick()
|
||||
|
||||
self._unsub_tick = async_call_later(self.hass, 1.0, tick)
|
||||
|
||||
def add_listener(
|
||||
self,
|
||||
listener: Callable[[str, dict[str, Any]], None],
|
||||
*,
|
||||
publish_initial: bool = True,
|
||||
) -> Callable[[], None]:
|
||||
self._listeners.add(listener)
|
||||
self._ensure_tick()
|
||||
if publish_initial:
|
||||
for marker_id, frame in self._frames.items():
|
||||
listener(marker_id, self.public_frame(frame))
|
||||
|
||||
@callback
|
||||
def remove() -> None:
|
||||
self._listeners.discard(listener)
|
||||
self._stop_tick_if_idle()
|
||||
return remove
|
||||
|
||||
def add_internal_listener(self, listener: Callable[[str, dict[str, Any]], None]) -> Callable[[], None]:
|
||||
self._internal_listeners.add(listener)
|
||||
self._ensure_tick()
|
||||
|
||||
@callback
|
||||
def remove() -> None:
|
||||
self._internal_listeners.discard(listener)
|
||||
self._stop_tick_if_idle()
|
||||
return remove
|
||||
|
||||
def _stop_tick_if_idle(self) -> None:
|
||||
if not (self._listeners or self._internal_listeners) and self._unsub_tick:
|
||||
self._unsub_tick()
|
||||
self._unsub_tick = None
|
||||
|
||||
def space_for_marker(self, marker_id: str) -> str | None:
|
||||
marker = self.radars.get(marker_id)
|
||||
return str(marker.get("space")) if marker and marker.get("space") else None
|
||||
|
||||
def has_space(self, space_id: str) -> bool:
|
||||
return any(str(space.get("id")) == space_id for space in self.config.get("spaces") or [])
|
||||
|
||||
def marker_config(self, marker_id: str) -> dict[str, Any] | None:
|
||||
return next(
|
||||
(marker for marker in self.config.get("markers") or []
|
||||
if isinstance(marker, dict) and str(marker.get("id")) == marker_id),
|
||||
None,
|
||||
)
|
||||
|
||||
def source_ids(self, marker_id: str) -> set[str]:
|
||||
marker = self.radars.get(marker_id)
|
||||
return radar_source_entity_ids(marker.get("radar")) if marker else set()
|
||||
|
||||
def frame(self, marker_id: str) -> dict[str, Any] | None:
|
||||
return self._frames.get(marker_id)
|
||||
|
||||
def frames_for_space(self, space_id: str) -> list[dict[str, Any]]:
|
||||
return [frame for marker_id, frame in self._frames.items()
|
||||
if self.space_for_marker(marker_id) == space_id][:32]
|
||||
|
||||
def _publish_all(self, force: bool = False) -> None:
|
||||
now = datetime.now(UTC).timestamp()
|
||||
acl_epoch = int(now // 60)
|
||||
force = force or acl_epoch != self._last_acl_epoch
|
||||
self._last_acl_epoch = acl_epoch
|
||||
live_ids = set(self.radars)
|
||||
for stale_id in set(self._frames) - live_ids:
|
||||
self._frames.pop(stale_id, None)
|
||||
self._last_signature.pop(stale_id, None)
|
||||
clear = self._base_frame(stale_id, now, "disabled")
|
||||
self._deliver(stale_id, clear)
|
||||
for marker_id, marker in self.radars.items():
|
||||
frame = self._build_frame(marker_id, marker, now)
|
||||
self._annotate_smoothing(marker_id, marker, frame)
|
||||
signature = _fingerprint({key: value for key, value in frame.items()
|
||||
if key not in {"seq", "reported_at", "expires_at"}})
|
||||
if not force and signature == self._last_signature.get(marker_id):
|
||||
continue
|
||||
self._last_signature[marker_id] = signature
|
||||
self._frames[marker_id] = frame
|
||||
self._deliver(marker_id, frame)
|
||||
|
||||
def _annotate_smoothing(
|
||||
self, marker_id: str, marker: dict[str, Any], frame: dict[str, Any],
|
||||
) -> None:
|
||||
"""Permit a cosmetic transition only where it cannot invent travel."""
|
||||
previous = self._frames.get(marker_id)
|
||||
if not previous or previous.get("health") not in {"ok", "stale"} \
|
||||
or frame.get("health") not in {"ok", "stale"} \
|
||||
or previous.get("source_generation") != frame.get("source_generation") \
|
||||
or previous.get("calibration_revision") != frame.get("calibration_revision") \
|
||||
or previous.get("_filter_revision") != frame.get("_filter_revision"):
|
||||
return
|
||||
room_exists, room_poly = self._room_polygon(marker, marker["radar"])
|
||||
if not room_exists or room_poly is not None and not polygon_is_convex(room_poly):
|
||||
return
|
||||
cell_cm = self._space_cell_cm(marker)
|
||||
if cell_cm is None:
|
||||
return
|
||||
old_targets = {
|
||||
target.get("slot"): target for target in previous.get("targets") or []
|
||||
if target.get("included") is True
|
||||
}
|
||||
for target in frame.get("targets") or []:
|
||||
old = old_targets.get(target.get("slot"))
|
||||
if not old or target.get("included") is not True \
|
||||
or float(target.get("reported_at", 0)) <= float(old.get("reported_at", 0)) \
|
||||
or float(target.get("reported_at", 0)) > float(old.get("expires_at", 0)):
|
||||
continue
|
||||
distance_cm = math.hypot(
|
||||
float(target["x"]) - float(old["x"]),
|
||||
float(target["y"]) - float(old["y"]),
|
||||
) * 240 * cell_cm
|
||||
if distance_cm <= 100:
|
||||
target["smooth"] = True
|
||||
|
||||
def _deliver(self, marker_id: str, frame: dict[str, Any]) -> None:
|
||||
for listener in tuple(self._internal_listeners):
|
||||
listener(marker_id, frame)
|
||||
public = self.public_frame(frame)
|
||||
for listener in tuple(self._listeners):
|
||||
listener(marker_id, public)
|
||||
|
||||
def _base_frame(self, marker_id: str, now: float, health: str) -> dict[str, Any]:
|
||||
self._seq[marker_id] = self._seq.get(marker_id, 0) + 1
|
||||
return {
|
||||
"server_session_id": self.server_session_id,
|
||||
"marker_id": marker_id,
|
||||
"source_generation": "",
|
||||
"calibration_revision": "",
|
||||
"seq": self._seq[marker_id],
|
||||
"reported_at": now,
|
||||
"expires_at": now,
|
||||
"health": health,
|
||||
"reported_presence": None,
|
||||
"complete": False,
|
||||
"targets": [], "ranges": [], "zones": [],
|
||||
}
|
||||
|
||||
def _build_frame(self, marker_id: str, marker: dict[str, Any], now: float) -> dict[str, Any]:
|
||||
radar = marker["radar"]
|
||||
frame = self._base_frame(marker_id, now, "ok")
|
||||
source_material = {
|
||||
"installation_id": radar.get("mount", {}).get("installation_id"),
|
||||
"mount": {key: radar.get("mount", {}).get(key) for key in ("x", "y")},
|
||||
"profile": radar.get("profile"), "sources": radar.get("sources"),
|
||||
"space": marker.get("space"),
|
||||
}
|
||||
calibration_material = {
|
||||
"heading_deg": radar.get("mount", {}).get("heading_deg"),
|
||||
"calibration": radar.get("calibration"),
|
||||
}
|
||||
frame["source_generation"] = _fingerprint(source_material)
|
||||
frame["calibration_revision"] = _fingerprint(calibration_material)
|
||||
profile = radar.get("profile")
|
||||
room_exists, room_poly = self._room_polygon(marker, radar)
|
||||
frame["_filter_revision"] = _fingerprint({
|
||||
"room_id": radar.get("room_id"), "polygon": room_poly,
|
||||
})
|
||||
if not room_exists:
|
||||
frame["health"] = "needs_setup"
|
||||
return frame
|
||||
current_cell_cm = self._space_cell_cm(marker)
|
||||
projects_geometry = profile in {
|
||||
"esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1",
|
||||
}
|
||||
if projects_geometry and (current_cell_cm is None or not math.isclose(
|
||||
float(radar.get("calibration", {}).get("cell_cm", -1)), current_cell_cm,
|
||||
rel_tol=0, abs_tol=1e-9,
|
||||
)):
|
||||
frame["health"] = "needs_setup"
|
||||
return frame
|
||||
sources = radar.get("sources") or {}
|
||||
availability = _binary_state(self.hass.states.get(sources.get("availability_entity"))) \
|
||||
if sources.get("availability_entity") else True
|
||||
occupancy = _binary_state(self.hass.states.get(sources.get("occupancy_entity"))) \
|
||||
if sources.get("occupancy_entity") else None
|
||||
frame["reported_presence"] = occupancy
|
||||
if availability is not True:
|
||||
frame["health"] = "unavailable" if availability is None else "off"
|
||||
return frame
|
||||
if occupancy is False:
|
||||
frame["complete"] = True
|
||||
frame["expires_at"] = now + 60
|
||||
frame["health"] = "off"
|
||||
return frame
|
||||
|
||||
any_stale = False
|
||||
explicit_slots = 0
|
||||
for slot in sources.get("slots") or []:
|
||||
try:
|
||||
local = self._slot_local(profile, slot, now)
|
||||
except (TypeError, ValueError):
|
||||
local = None
|
||||
if local is _EXPLICIT_ABSENT:
|
||||
explicit_slots += 1
|
||||
continue
|
||||
if local is None:
|
||||
gate = _binary_state(self.hass.states.get(slot.get("presence_entity"))) \
|
||||
if slot.get("presence_entity") else None
|
||||
if gate is False:
|
||||
explicit_slots += 1
|
||||
else:
|
||||
any_stale = True
|
||||
continue
|
||||
x_cm, y_cm, t1, t2, quality = local
|
||||
gate = _binary_state(self.hass.states.get(slot.get("presence_entity"))) \
|
||||
if slot.get("presence_entity") else True
|
||||
if gate is not True:
|
||||
if gate is False:
|
||||
explicit_slots += 1
|
||||
else:
|
||||
any_stale = True
|
||||
continue
|
||||
try:
|
||||
point = project_local(radar["mount"], radar["calibration"], x_cm, y_cm)
|
||||
except (TypeError, ValueError):
|
||||
any_stale = True
|
||||
continue
|
||||
tolerance = 0.1 / (240 * current_cell_cm) if current_cell_cm else 1e-6
|
||||
included = room_poly is None or point_in_polygon(point, room_poly, tolerance)
|
||||
frame["targets"].append({
|
||||
"slot": str(slot.get("id")), "x": point[0], "y": point[1],
|
||||
"reported_at": max(t1, t2), "expires_at": min(t1, t2) + PAIR_MAX_AGE_S,
|
||||
"pair_quality": quality, "included": included,
|
||||
"reason": None if included else "outside_room",
|
||||
# Private values are removed by public_frame and consumed only
|
||||
# by the bounded local recording/analysis services.
|
||||
"_local_x_cm": x_cm, "_local_y_cm": y_cm,
|
||||
"_component_times": [t1, t2],
|
||||
})
|
||||
explicit_slots += 1
|
||||
|
||||
for source in sources.get("ranges") or []:
|
||||
state = self.hass.states.get(source.get("entity_id"))
|
||||
value = _numeric_state(state)
|
||||
reported = _reported_timestamp(state)
|
||||
gate = _binary_state(self.hass.states.get(source.get("presence_entity"))) \
|
||||
if source.get("presence_entity") else True
|
||||
if value is None or now - reported > PAIR_MAX_AGE_S or gate is not True:
|
||||
any_stale = any_stale or gate is not False
|
||||
continue
|
||||
try:
|
||||
radius_cm = length_cm(value, source.get("unit"))
|
||||
except (TypeError, ValueError):
|
||||
any_stale = True
|
||||
continue
|
||||
radius = radius_cm / (240 * radar["calibration"]["cell_cm"])
|
||||
segments = clipped_arc_segments(
|
||||
(radar["mount"]["x"], radar["mount"]["y"]), radius,
|
||||
radar["mount"].get("heading_deg", 0), radar["mount"].get("fov_deg"),
|
||||
room_poly,
|
||||
)
|
||||
frame["ranges"].append({
|
||||
"id": str(source.get("id")), "x": radar["mount"]["x"],
|
||||
"y": radar["mount"]["y"], "radius": radius,
|
||||
"heading_deg": radar["mount"].get("heading_deg", 0),
|
||||
"fov_deg": radar["mount"].get("fov_deg"),
|
||||
"segments": segments,
|
||||
"reported_at": reported, "expires_at": reported + PAIR_MAX_AGE_S,
|
||||
})
|
||||
|
||||
for source in sources.get("zones") or []:
|
||||
state = self.hass.states.get(source.get("entity_id"))
|
||||
value = _binary_state(state) if source.get("kind") == "occupancy" else _numeric_state(state)
|
||||
frame["zones"].append({"id": str(source.get("id")), "state": value})
|
||||
|
||||
if profile == "presence_v1":
|
||||
frame["complete"] = occupancy is not None
|
||||
frame["health"] = "ok" if occupancy is not None else "unknown"
|
||||
frame["expires_at"] = None
|
||||
return frame
|
||||
if profile == "zones_v1":
|
||||
frame["complete"] = all(zone["state"] is not None for zone in frame["zones"])
|
||||
frame["health"] = "ok" if frame["complete"] else "unknown"
|
||||
frame["expires_at"] = None
|
||||
return frame
|
||||
if profile == "range_v1":
|
||||
frame["complete"] = bool(frame["ranges"]) and not any_stale
|
||||
else:
|
||||
frame["complete"] = explicit_slots == len(sources.get("slots") or []) and not any_stale
|
||||
if occupancy is True and not frame["targets"] and not frame["ranges"]:
|
||||
frame["health"] = "position_unavailable"
|
||||
elif any_stale:
|
||||
has_current_evidence = bool(frame["ranges"]) if profile == "range_v1" \
|
||||
else explicit_slots > 0
|
||||
frame["health"] = "partial" if has_current_evidence else "stale"
|
||||
expires = [item["expires_at"] for item in [*frame["targets"], *frame["ranges"]]]
|
||||
frame["expires_at"] = min(expires) if expires else now
|
||||
count = _numeric_state(self.hass.states.get(sources.get("count_entity"))) \
|
||||
if sources.get("count_entity") else None
|
||||
count_state = self.hass.states.get(sources.get("count_entity")) \
|
||||
if sources.get("count_entity") else None
|
||||
count_reported = _reported_timestamp(count_state)
|
||||
count_fresh = count is not None and now - count_reported <= PAIR_MAX_AGE_S
|
||||
if count_fresh and count == 0:
|
||||
if frame["targets"]:
|
||||
frame["health"] = "inconsistent"
|
||||
frame["complete"] = False
|
||||
elif not frame["ranges"]:
|
||||
frame["health"] = "off"
|
||||
frame["complete"] = True
|
||||
frame["expires_at"] = count_reported + PAIR_MAX_AGE_S
|
||||
return frame
|
||||
|
||||
def _slot_local(self, profile: str, slot: dict[str, Any], now: float):
|
||||
if profile == "polar_v1":
|
||||
first = self.hass.states.get(slot.get("distance_entity"))
|
||||
second = self.hass.states.get(slot.get("angle_entity"))
|
||||
a, b = _numeric_state(first), _numeric_state(second)
|
||||
t1, t2 = _reported_timestamp(first), _reported_timestamp(second)
|
||||
if a is None or b is None or now - min(t1, t2) > PAIR_MAX_AGE_S or abs(t1 - t2) > PAIR_MAX_SKEW_S:
|
||||
return None
|
||||
x, y = polar_to_cartesian(a, b, unit=slot["unit"],
|
||||
angle_unit=slot["angle_unit"],
|
||||
zero=slot["angle_zero"],
|
||||
clockwise=slot["angle_clockwise"])
|
||||
return x, y, t1, t2, "bounded_latest"
|
||||
first = self.hass.states.get(slot.get("x_entity"))
|
||||
second = self.hass.states.get(slot.get("y_entity"))
|
||||
a, b = _numeric_state(first), _numeric_state(second)
|
||||
t1, t2 = _reported_timestamp(first), _reported_timestamp(second)
|
||||
if a is None or b is None or now - min(t1, t2) > PAIR_MAX_AGE_S or abs(t1 - t2) > PAIR_MAX_SKEW_S:
|
||||
return None
|
||||
unit = "mm" if profile == "esphome_ld2450_v1" else slot["unit"]
|
||||
# The reference ESPHome LD2450 connection reports both axes as zero
|
||||
# for an unused slot. A single zero remains a legal coordinate; only
|
||||
# the verified pair is negative evidence. Generic profiles keep (0,0)
|
||||
# legal because they have no adapter-specific absence contract.
|
||||
if profile == "esphome_ld2450_v1" and a == 0 and b == 0:
|
||||
return _EXPLICIT_ABSENT
|
||||
x, y = length_cm(a, unit), length_cm(b, unit)
|
||||
if slot.get("swap_xy"):
|
||||
x, y = y, x
|
||||
return x * slot.get("x_sign", 1), y * slot.get("y_sign", 1), t1, t2, "bounded_latest"
|
||||
|
||||
def _room_polygon(
|
||||
self, marker: dict[str, Any], radar: dict[str, Any],
|
||||
) -> tuple[bool, list[Any] | None]:
|
||||
space_id = str(marker.get("space") or "")
|
||||
room_id = str(radar.get("room_id") or "")
|
||||
for space in self.config.get("spaces") or []:
|
||||
if str(space.get("id")) != space_id:
|
||||
continue
|
||||
for room in space.get("rooms") or []:
|
||||
if str(room.get("id")) == room_id:
|
||||
poly = room.get("poly")
|
||||
return True, poly if isinstance(poly, list) and len(poly) >= 3 else None
|
||||
return False, None
|
||||
|
||||
def _space_cell_cm(self, marker: dict[str, Any]) -> float | None:
|
||||
space_id = str(marker.get("space") or "")
|
||||
for space in self.config.get("spaces") or []:
|
||||
if str(space.get("id")) != space_id:
|
||||
continue
|
||||
value = space.get("cell_cm", 5)
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if math.isfinite(result) and result > 0 else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def public_frame(frame: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Projection safe for View: no raw coordinates, ids or private times."""
|
||||
public: dict[str, Any] = {}
|
||||
for key, value in frame.items():
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
if key not in {"targets", "ranges", "zones"}:
|
||||
public[key] = value
|
||||
continue
|
||||
items = value
|
||||
if key == "targets":
|
||||
items = [entry for entry in value if entry.get("included")]
|
||||
public[key] = [
|
||||
{item_key: item_value for item_key, item_value in item.items()
|
||||
if not item_key.startswith("_")}
|
||||
for item in items
|
||||
]
|
||||
return public
|
||||
|
||||
def inspect(self, marker_id: str) -> dict[str, Any]:
|
||||
marker = self.radars.get(marker_id)
|
||||
if marker is None:
|
||||
return {"marker_id": marker_id, "health": "not_configured", "config_rev": self.config_rev}
|
||||
frame = self._frames.get(marker_id) or self._build_frame(
|
||||
marker_id, marker, datetime.now(UTC).timestamp()
|
||||
)
|
||||
radar = marker["radar"]
|
||||
return {
|
||||
"marker_id": marker_id, "config_rev": self.config_rev,
|
||||
"profile": radar.get("profile"),
|
||||
"capabilities": self._capabilities(radar),
|
||||
"frame": self.public_frame(frame),
|
||||
"sources": [
|
||||
{"entity_id": entity_id,
|
||||
"state": str(self.hass.states.get(entity_id).state)
|
||||
if self.hass.states.get(entity_id) else None,
|
||||
"reported_at": _reported_timestamp(self.hass.states.get(entity_id))}
|
||||
for entity_id in sorted(radar_source_entity_ids(radar))
|
||||
],
|
||||
}
|
||||
|
||||
def inspect_draft(self, marker: dict[str, Any], radar: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return one permission-gated setup snapshot for an unsaved draft."""
|
||||
marker_id = str(marker.get("id"))
|
||||
candidate = {**marker, "radar": radar}
|
||||
frame = self._build_frame(marker_id, candidate, datetime.now(UTC).timestamp())
|
||||
result = {
|
||||
"marker_id": marker_id,
|
||||
"config_rev": self.config_rev,
|
||||
"profile": radar.get("profile"),
|
||||
"capabilities": self._capabilities(radar),
|
||||
"frame": self.public_frame(frame),
|
||||
"local_targets": [
|
||||
{
|
||||
"slot": target.get("slot"),
|
||||
"x_cm": target.get("_local_x_cm"),
|
||||
"y_cm": target.get("_local_y_cm"),
|
||||
"reported_at": target.get("reported_at"),
|
||||
"pair_quality": target.get("pair_quality"),
|
||||
}
|
||||
for target in frame.get("targets") or []
|
||||
],
|
||||
"sources": [
|
||||
{"entity_id": entity_id,
|
||||
"state": str(self.hass.states.get(entity_id).state)
|
||||
if self.hass.states.get(entity_id) else None,
|
||||
"reported_at": _reported_timestamp(self.hass.states.get(entity_id))}
|
||||
for entity_id in sorted(radar_source_entity_ids(radar))
|
||||
],
|
||||
}
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _capabilities(radar: dict[str, Any]) -> list[str]:
|
||||
profile = radar.get("profile")
|
||||
out = []
|
||||
if profile in {"esphome_ld2450_v1", "cartesian_v1", "polar_v1"}:
|
||||
out.append("coordinates")
|
||||
if profile == "range_v1":
|
||||
out.append("range")
|
||||
if profile == "zones_v1":
|
||||
out.append("zone_state")
|
||||
if (radar.get("sources") or {}).get("occupancy_entity"):
|
||||
out.append("reported_presence")
|
||||
if ((radar.get("zones") or {}).get("hardware") or {}).get("adapter") == "esphome_ld2450_numbers_v1":
|
||||
out.append("hardware_zones")
|
||||
return out
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Pure geometry shared by radar setup, live projection and analysis (#485)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
UNIT_TO_CM = {"mm": 0.1, "cm": 1.0, "m": 100.0, "in": 2.54, "ft": 30.48}
|
||||
MAX_LOCAL_CM = 10_000.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RadarFit:
|
||||
heading_deg: float
|
||||
mirror: bool
|
||||
rms_cm: float
|
||||
errors_cm: tuple[float, float]
|
||||
|
||||
|
||||
def length_cm(value: Any, unit: str) -> float:
|
||||
"""Convert one finite length without guessing or clamping its unit."""
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or unit not in UNIT_TO_CM:
|
||||
raise ValueError("invalid_radar")
|
||||
result = number * UNIT_TO_CM[unit]
|
||||
if abs(result) > MAX_LOCAL_CM:
|
||||
raise ValueError("invalid_radar")
|
||||
return result
|
||||
|
||||
|
||||
def polar_to_cartesian(distance: Any, bearing: Any, *, unit: str,
|
||||
angle_unit: str, zero: str, clockwise: bool) -> tuple[float, float]:
|
||||
distance_cm = length_cm(distance, unit)
|
||||
if distance_cm < 0:
|
||||
raise ValueError("invalid_radar")
|
||||
angle = float(bearing)
|
||||
if not math.isfinite(angle) or angle_unit not in {"degrees", "radians"}:
|
||||
raise ValueError("invalid_radar")
|
||||
angle = math.degrees(angle) if angle_unit == "radians" else angle
|
||||
if not clockwise:
|
||||
angle = -angle
|
||||
if zero == "right":
|
||||
angle += 90.0
|
||||
elif zero != "forward":
|
||||
raise ValueError("invalid_radar")
|
||||
radians = math.radians(angle)
|
||||
return distance_cm * math.sin(radians), distance_cm * math.cos(radians)
|
||||
|
||||
|
||||
def project_local(mount: dict[str, Any], calibration: dict[str, Any],
|
||||
x_cm: float, y_cm: float) -> tuple[float, float]:
|
||||
"""Project canonical local centimetres into the square normalized plan."""
|
||||
x_cm = float(x_cm)
|
||||
y_cm = float(y_cm)
|
||||
if not all(map(math.isfinite, (x_cm, y_cm))) or math.hypot(x_cm, y_cm) > MAX_LOCAL_CM:
|
||||
raise ValueError("invalid_radar")
|
||||
mx, my = float(mount["x"]), float(mount["y"])
|
||||
theta = math.radians(float(mount["heading_deg"]))
|
||||
cell_cm = float(calibration["cell_cm"])
|
||||
if not all(map(math.isfinite, (mx, my, theta, cell_cm))) or cell_cm <= 0:
|
||||
raise ValueError("invalid_radar")
|
||||
mirror = -1.0 if calibration.get("mirror") else 1.0
|
||||
scale = 240.0 * cell_cm
|
||||
return (
|
||||
mx + (mirror * x_cm * math.cos(theta) + y_cm * math.sin(theta)) / scale,
|
||||
my + (mirror * x_cm * math.sin(theta) - y_cm * math.cos(theta)) / scale,
|
||||
)
|
||||
|
||||
|
||||
def inverse_project(mount: dict[str, Any], calibration: dict[str, Any],
|
||||
x: float, y: float) -> tuple[float, float]:
|
||||
theta = math.radians(float(mount["heading_deg"]))
|
||||
dx = (float(x) - float(mount["x"])) * 240.0 * float(calibration["cell_cm"])
|
||||
dy = (float(y) - float(mount["y"])) * 240.0 * float(calibration["cell_cm"])
|
||||
mirror = -1.0 if calibration.get("mirror") else 1.0
|
||||
return (
|
||||
mirror * (dx * math.cos(theta) + dy * math.sin(theta)),
|
||||
dx * math.sin(theta) - dy * math.cos(theta),
|
||||
)
|
||||
|
||||
|
||||
def point_in_polygon(point: tuple[float, float], polygon: Iterable[Any],
|
||||
tolerance: float = 1e-9) -> bool:
|
||||
"""Boundary-inclusive even/odd test; no bounding-box fallback."""
|
||||
points = [(float(p[0] if isinstance(p, (list, tuple)) else p["x"]),
|
||||
float(p[1] if isinstance(p, (list, tuple)) else p["y"])) for p in polygon]
|
||||
if len(points) < 3:
|
||||
return True
|
||||
px, py = point
|
||||
inside = False
|
||||
for index, (ax, ay) in enumerate(points):
|
||||
bx, by = points[(index + 1) % len(points)]
|
||||
cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax)
|
||||
if abs(cross) <= tolerance and min(ax, bx) - tolerance <= px <= max(ax, bx) + tolerance \
|
||||
and min(ay, by) - tolerance <= py <= max(ay, by) + tolerance:
|
||||
return True
|
||||
if (ay > py) != (by > py):
|
||||
at_x = ax + (py - ay) * (bx - ax) / (by - ay)
|
||||
if px < at_x:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
|
||||
def polygon_is_convex(polygon: Iterable[Any]) -> bool:
|
||||
"""Whether a straight visual transition stays inside this room.
|
||||
|
||||
A chord between valid points can leave a concave room. Smoothing is only
|
||||
cosmetic, so disabling it for such rooms is safer than drawing presence
|
||||
through a wall.
|
||||
"""
|
||||
points = [(float(p[0] if isinstance(p, (list, tuple)) else p["x"]),
|
||||
float(p[1] if isinstance(p, (list, tuple)) else p["y"])) for p in polygon]
|
||||
if len(points) < 3:
|
||||
return False
|
||||
sign = 0
|
||||
for index, (ax, ay) in enumerate(points):
|
||||
bx, by = points[(index + 1) % len(points)]
|
||||
cx, cy = points[(index + 2) % len(points)]
|
||||
cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx)
|
||||
if abs(cross) <= 1e-12:
|
||||
continue
|
||||
current = 1 if cross > 0 else -1
|
||||
if sign and current != sign:
|
||||
return False
|
||||
sign = current
|
||||
return sign != 0
|
||||
|
||||
|
||||
def clipped_arc_segments(
|
||||
center: tuple[float, float], radius: float, heading_deg: float,
|
||||
fov_deg: float | None, polygon: Iterable[Any] | None,
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
"""Approximate a range arc and split it at a room boundary.
|
||||
|
||||
Range input has no bearing, so the arc is the honest geometry. Returning
|
||||
only contiguous inside runs prevents the browser from drawing the arc
|
||||
through walls or across a concave cut-out. The resolution (<=4 degrees)
|
||||
is considerably finer than the two-CSS-pixel display stroke.
|
||||
"""
|
||||
if not all(math.isfinite(value) for value in (*center, radius, heading_deg)) \
|
||||
or radius <= 0 or fov_deg is None or not math.isfinite(fov_deg) \
|
||||
or not 0 < fov_deg <= 360:
|
||||
return []
|
||||
points = list(polygon) if polygon is not None else None
|
||||
steps = max(8, min(180, math.ceil(fov_deg / 4)))
|
||||
start = heading_deg - 90 - fov_deg / 2
|
||||
samples = [
|
||||
(
|
||||
center[0] + math.cos(math.radians(start + fov_deg * index / steps)) * radius,
|
||||
center[1] + math.sin(math.radians(start + fov_deg * index / steps)) * radius,
|
||||
)
|
||||
for index in range(steps + 1)
|
||||
]
|
||||
if points is None:
|
||||
return [samples]
|
||||
segments: list[list[tuple[float, float]]] = []
|
||||
current: list[tuple[float, float]] = []
|
||||
for sample in samples:
|
||||
if point_in_polygon(sample, points, 1e-6):
|
||||
current.append(sample)
|
||||
else:
|
||||
if len(current) >= 2:
|
||||
segments.append(current)
|
||||
current = []
|
||||
if len(current) >= 2:
|
||||
segments.append(current)
|
||||
return segments
|
||||
|
||||
|
||||
def solve_two_point(mount: tuple[float, float], local_points: list[tuple[float, float]],
|
||||
plan_points: list[tuple[float, float]], cell_cm: float) -> RadarFit:
|
||||
"""Fit one rigid heading and mirror with the physical scale fixed."""
|
||||
if len(local_points) != 2 or len(plan_points) != 2 or cell_cm <= 0:
|
||||
raise ValueError("invalid_selection")
|
||||
scale = 240.0 * cell_cm
|
||||
targets = [((x - mount[0]) * scale, -(y - mount[1]) * scale) for x, y in plan_points]
|
||||
if any(math.hypot(*local) < 50 for local in local_points):
|
||||
raise ValueError("invalid_selection")
|
||||
denominator = math.hypot(*local_points[0]) * math.hypot(*local_points[1])
|
||||
angle = math.degrees(math.acos(max(-1.0, min(1.0,
|
||||
sum(a * b for a, b in zip(local_points[0], local_points[1], strict=True)) / denominator))))
|
||||
if angle < 20 or angle > 160:
|
||||
raise ValueError("invalid_selection")
|
||||
|
||||
candidates: list[RadarFit] = []
|
||||
for mirrored in (False, True):
|
||||
transformed = [((-x if mirrored else x), y) for x, y in local_points]
|
||||
dot = sum(sx * tx + sy * ty for (sx, sy), (tx, ty) in zip(transformed, targets, strict=True))
|
||||
cross = sum(sx * ty - sy * tx for (sx, sy), (tx, ty) in zip(transformed, targets, strict=True))
|
||||
rotation = math.atan2(cross, dot)
|
||||
errors = []
|
||||
for (sx, sy), (tx, ty) in zip(transformed, targets, strict=True):
|
||||
rx = sx * math.cos(rotation) - sy * math.sin(rotation)
|
||||
ry = sx * math.sin(rotation) + sy * math.cos(rotation)
|
||||
errors.append(math.hypot(rx - tx, ry - ty))
|
||||
radial_ok = all(abs(math.hypot(*local) - math.hypot(*target)) <= max(20, .15 * math.hypot(*target))
|
||||
for local, target in zip(local_points, targets, strict=True))
|
||||
rms = math.sqrt(sum(error * error for error in errors) / 2)
|
||||
if radial_ok and rms <= 20 and max(errors) <= 30:
|
||||
candidates.append(RadarFit(
|
||||
(-math.degrees(rotation)) % 360,
|
||||
mirrored,
|
||||
rms,
|
||||
(errors[0], errors[1]),
|
||||
))
|
||||
if not candidates:
|
||||
raise ValueError("invalid_selection")
|
||||
candidates.sort(key=lambda fit: fit.rms_cm)
|
||||
if len(candidates) > 1 and abs(candidates[0].rms_cm - candidates[1].rms_cm) < 10:
|
||||
raise ValueError("ambiguous_sources")
|
||||
return candidates[0]
|
||||
@@ -0,0 +1,434 @@
|
||||
"""Strict, change-aware validation for the optional #485 radar namespace.
|
||||
|
||||
Radar data is deliberately kept outside the plan geometry model. Old plans
|
||||
without this namespace remain byte-for-byte valid, while a client that creates
|
||||
or changes a known version-1 block must satisfy the complete contract before
|
||||
the config transaction can commit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from .radar_geometry import solve_two_point
|
||||
|
||||
RADAR_PROFILES = frozenset({
|
||||
"esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1",
|
||||
"zones_v1", "presence_v1",
|
||||
})
|
||||
_COMMON_SOURCE_ROLES = (
|
||||
"occupancy_entity", "count_entity", "availability_entity",
|
||||
)
|
||||
_PROFILE_SOURCE_ROLES: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"esphome_ld2450_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
|
||||
"cartesian_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
|
||||
"polar_v1": ("slots", ("distance_entity", "angle_entity", "presence_entity")),
|
||||
"range_v1": ("ranges", ("entity_id", "presence_entity")),
|
||||
"zones_v1": ("zones", ("entity_id",)),
|
||||
"presence_v1": ("", ()),
|
||||
}
|
||||
LENGTH_UNITS = frozenset({"mm", "cm", "m", "in", "ft"})
|
||||
ANGLE_UNITS = frozenset({"degrees", "radians"})
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
ENTITY_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
MAX_RADARS = 32
|
||||
CANVAS_LIMIT = 5000.0
|
||||
LD2450_MODELS = frozenset({"ld2450", "hlkld2450", "hilinkld2450"})
|
||||
|
||||
|
||||
class RadarValidationError(vol.Invalid):
|
||||
"""Stable client-visible radar validation failure."""
|
||||
|
||||
code = "invalid_radar"
|
||||
|
||||
|
||||
def _invalid(message: str) -> None:
|
||||
raise RadarValidationError(message)
|
||||
|
||||
|
||||
def _finite(value: Any, name: str, minimum: float | None = None,
|
||||
maximum: float | None = None) -> float:
|
||||
if isinstance(value, bool):
|
||||
_invalid(f"{name} must be finite")
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
_invalid(f"{name} must be finite")
|
||||
if not math.isfinite(number):
|
||||
_invalid(f"{name} must be finite")
|
||||
if minimum is not None and number < minimum:
|
||||
_invalid(f"{name} is below its minimum")
|
||||
if maximum is not None and number > maximum:
|
||||
_invalid(f"{name} is above its maximum")
|
||||
return number
|
||||
|
||||
|
||||
def _mapping(value: Any, name: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
_invalid(f"{name} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _array(value: Any, name: str, maximum: int) -> list[Any]:
|
||||
if not isinstance(value, list) or len(value) > maximum:
|
||||
_invalid(f"{name} must be an array with at most {maximum} entries")
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str) or ID_RE.fullmatch(value) is None:
|
||||
_invalid(f"{name} must be a bounded identifier")
|
||||
return value
|
||||
|
||||
|
||||
def _entity(value: Any, name: str, domains: set[str]) -> str:
|
||||
if not isinstance(value, str) or len(value) > 255 or ENTITY_RE.fullmatch(value) is None:
|
||||
_invalid(f"{name} must be an entity id")
|
||||
if value.split(".", 1)[0] not in domains:
|
||||
_invalid(f"{name} has an unsupported entity domain")
|
||||
return value
|
||||
|
||||
|
||||
def _point(value: Any, name: str) -> tuple[float, float]:
|
||||
obj = _mapping(value, name)
|
||||
return (
|
||||
_finite(obj.get("x"), f"{name}.x", -CANVAS_LIMIT, CANVAS_LIMIT),
|
||||
_finite(obj.get("y"), f"{name}.y", -CANVAS_LIMIT, CANVAS_LIMIT),
|
||||
)
|
||||
|
||||
|
||||
def _source_ids(profile: Any, sources: dict[str, Any]) -> set[str]:
|
||||
profile_roles = _PROFILE_SOURCE_ROLES.get(profile)
|
||||
if profile_roles is None:
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for key in _COMMON_SOURCE_ROLES:
|
||||
value = sources.get(key)
|
||||
if isinstance(value, str):
|
||||
out.add(value)
|
||||
group, roles = profile_roles
|
||||
if group:
|
||||
for item in sources.get(group) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key in roles:
|
||||
value = item.get(key)
|
||||
if isinstance(value, str):
|
||||
out.add(value)
|
||||
return out
|
||||
|
||||
|
||||
def radar_source_entity_ids(radar: Any) -> set[str]:
|
||||
"""Return exact source ids from one already validated radar block."""
|
||||
if not isinstance(radar, dict):
|
||||
return set()
|
||||
sources = radar.get("sources")
|
||||
out = _source_ids(radar.get("profile"), sources) \
|
||||
if isinstance(sources, dict) else set()
|
||||
return out
|
||||
|
||||
|
||||
def radar_registry_evidence(hass: Any) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Capture the small registry subset needed by verified adapters.
|
||||
|
||||
The returned plain mapping is safe to pass into executor-side validation.
|
||||
Generic/manual profiles deliberately do not depend on registry evidence.
|
||||
"""
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
entities = {
|
||||
str(entry.entity_id): {
|
||||
"device_id": str(entry.device_id) if entry.device_id else None,
|
||||
"platform": str(getattr(entry, "platform", "") or ""),
|
||||
}
|
||||
for entry in er.async_get(hass).entities.values()
|
||||
}
|
||||
devices = {
|
||||
str(entry.id): {
|
||||
"model": str(getattr(entry, "model", "") or ""),
|
||||
}
|
||||
for entry in dr.async_get(hass).devices.values()
|
||||
}
|
||||
return {"entities": entities, "devices": devices}
|
||||
|
||||
|
||||
def _canonical_model(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
|
||||
|
||||
|
||||
def _validate_verified_adapter(
|
||||
profile: str,
|
||||
sources: dict[str, Any],
|
||||
marker: dict[str, Any],
|
||||
registry: dict[str, dict[str, dict[str, Any]]] | None,
|
||||
) -> None:
|
||||
"""Require structural same-device evidence for the LD2450 adapter."""
|
||||
if profile != "esphome_ld2450_v1" or registry is None:
|
||||
return
|
||||
entities = registry.get("entities") or {}
|
||||
source_ids = _source_ids(profile, sources)
|
||||
rows = [entities.get(entity_id) for entity_id in source_ids]
|
||||
device_ids = {
|
||||
str(row.get("device_id"))
|
||||
for row in rows
|
||||
if isinstance(row, dict) and row.get("device_id")
|
||||
}
|
||||
if len(rows) != len(source_ids) or any(not isinstance(row, dict) for row in rows) \
|
||||
or len(device_ids) != 1 \
|
||||
or any(str(row.get("platform") or "") != "esphome" for row in rows if row):
|
||||
_invalid("LD2450 sources must belong to the same ESPHome device")
|
||||
device_id = next(iter(device_ids))
|
||||
model = (registry.get("devices") or {}).get(device_id, {}).get("model")
|
||||
if _canonical_model(model) not in LD2450_MODELS:
|
||||
_invalid("LD2450 adapter requires verified LD2450 device metadata")
|
||||
binding = str(marker.get("binding") or "")
|
||||
owner_device: str | None = None
|
||||
if binding.startswith("device:"):
|
||||
owner_device = binding.removeprefix("device:")
|
||||
elif binding.startswith("entity:"):
|
||||
owner = entities.get(binding.removeprefix("entity:"))
|
||||
owner_device = str(owner.get("device_id")) if isinstance(owner, dict) and owner.get("device_id") else None
|
||||
if owner_device != device_id:
|
||||
_invalid("LD2450 sources must belong to the marker device")
|
||||
|
||||
|
||||
def _validate_sources(profile: str, sources: Any) -> None:
|
||||
obj = _mapping(sources, "radar.sources")
|
||||
slots = _array(obj.get("slots", []), "radar.sources.slots", 8)
|
||||
ranges = _array(obj.get("ranges", []), "radar.sources.ranges", 2)
|
||||
zones = _array(obj.get("zones", []), "radar.sources.zones", 32)
|
||||
seen: set[str] = set()
|
||||
role_entities: set[str] = set()
|
||||
|
||||
def unique_role(entity_id: str, name: str, domains: set[str]) -> str:
|
||||
value = _entity(entity_id, name, domains)
|
||||
if value in role_entities:
|
||||
_invalid("radar coordinate/range role entities must be distinct")
|
||||
role_entities.add(value)
|
||||
return value
|
||||
|
||||
if profile in {"esphome_ld2450_v1", "cartesian_v1", "polar_v1"} and not slots:
|
||||
_invalid("coordinate radar requires at least one slot")
|
||||
if profile == "esphome_ld2450_v1" and len(slots) > 3:
|
||||
_invalid("LD2450 supports at most three slots")
|
||||
if profile == "range_v1" and not ranges:
|
||||
_invalid("range radar requires at least one range")
|
||||
if profile == "zones_v1" and not zones:
|
||||
_invalid("zone radar requires at least one zone source")
|
||||
if profile == "presence_v1" and not obj.get("occupancy_entity"):
|
||||
_invalid("presence radar requires occupancy_entity")
|
||||
|
||||
for index, slot in enumerate(slots):
|
||||
item = _mapping(slot, f"radar.sources.slots[{index}]")
|
||||
ident = _identifier(item.get("id"), "slot.id")
|
||||
if ident in seen:
|
||||
_invalid("radar source ids must be unique")
|
||||
seen.add(ident)
|
||||
if profile == "polar_v1":
|
||||
unique_role(item.get("distance_entity"), "slot.distance_entity", {"sensor"})
|
||||
unique_role(item.get("angle_entity"), "slot.angle_entity", {"sensor"})
|
||||
if item.get("unit") not in LENGTH_UNITS or item.get("angle_unit") not in ANGLE_UNITS:
|
||||
_invalid("polar units must be explicit")
|
||||
if item.get("angle_zero") not in {"forward", "right"}:
|
||||
_invalid("polar angle_zero is invalid")
|
||||
if not isinstance(item.get("angle_clockwise"), bool):
|
||||
_invalid("polar angle_clockwise must be boolean")
|
||||
else:
|
||||
unique_role(item.get("x_entity"), "slot.x_entity", {"sensor"})
|
||||
unique_role(item.get("y_entity"), "slot.y_entity", {"sensor"})
|
||||
if profile == "esphome_ld2450_v1" and item.get("unit") != "mm":
|
||||
_invalid("LD2450 Cartesian unit must be mm")
|
||||
if profile != "esphome_ld2450_v1" and item.get("unit") not in LENGTH_UNITS:
|
||||
_invalid("cartesian unit must be explicit")
|
||||
if item.get("x_sign", 1) not in {-1, 1} or item.get("y_sign", 1) not in {-1, 1}:
|
||||
_invalid("axis signs must be +1 or -1")
|
||||
if "swap_xy" in item and not isinstance(item["swap_xy"], bool):
|
||||
_invalid("swap_xy must be boolean")
|
||||
if item.get("presence_entity") is not None:
|
||||
_entity(item["presence_entity"], "slot.presence_entity", {"binary_sensor"})
|
||||
|
||||
for index, source in enumerate(ranges):
|
||||
item = _mapping(source, f"radar.sources.ranges[{index}]")
|
||||
ident = _identifier(item.get("id"), "range.id")
|
||||
if ident in seen:
|
||||
_invalid("radar source ids must be unique")
|
||||
seen.add(ident)
|
||||
unique_role(item.get("entity_id"), "range.entity_id", {"sensor"})
|
||||
if item.get("unit") not in LENGTH_UNITS:
|
||||
_invalid("range unit must be explicit")
|
||||
if item.get("presence_entity") is not None:
|
||||
_entity(item["presence_entity"], "range.presence_entity", {"binary_sensor"})
|
||||
|
||||
for index, source in enumerate(zones):
|
||||
item = _mapping(source, f"radar.sources.zones[{index}]")
|
||||
ident = _identifier(item.get("id"), "zone source id")
|
||||
if ident in seen:
|
||||
_invalid("radar source ids must be unique")
|
||||
seen.add(ident)
|
||||
kind = item.get("kind")
|
||||
if kind not in {"occupancy", "count"}:
|
||||
_invalid("zone source kind is invalid")
|
||||
_entity(item.get("entity_id"), "zone source entity", {"binary_sensor"} if kind == "occupancy" else {"sensor"})
|
||||
|
||||
if obj.get("occupancy_entity") is not None:
|
||||
_entity(obj["occupancy_entity"], "radar.sources.occupancy_entity", {"binary_sensor"})
|
||||
if obj.get("count_entity") is not None:
|
||||
_entity(obj["count_entity"], "radar.sources.count_entity", {"sensor"})
|
||||
if obj.get("availability_entity") is not None:
|
||||
_entity(obj["availability_entity"], "radar.sources.availability_entity", {"binary_sensor"})
|
||||
|
||||
|
||||
def _validate_radar(
|
||||
radar: Any,
|
||||
spaces: dict[str, dict[str, Any]],
|
||||
marker: dict[str, Any],
|
||||
registry: dict[str, dict[str, dict[str, Any]]] | None = None,
|
||||
) -> None:
|
||||
obj = _mapping(radar, "marker.radar")
|
||||
if obj.get("version") != 1:
|
||||
_invalid("unsupported radar version")
|
||||
if not isinstance(obj.get("enabled"), bool):
|
||||
_invalid("radar.enabled must be boolean")
|
||||
if obj.get("show_live") is not None and not isinstance(obj.get("show_live"), bool):
|
||||
_invalid("radar.show_live must be boolean")
|
||||
profile = obj.get("profile")
|
||||
if profile not in RADAR_PROFILES:
|
||||
_invalid("unsupported radar profile")
|
||||
_validate_sources(profile, obj.get("sources"))
|
||||
_validate_verified_adapter(profile, obj["sources"], marker, registry)
|
||||
room_id = obj.get("room_id")
|
||||
if not isinstance(room_id, str) or not room_id:
|
||||
_invalid("radar.room_id is required")
|
||||
owner_space = str(marker.get("space") or "")
|
||||
space = spaces.get(owner_space)
|
||||
if space is None or room_id not in {str(room.get("id")) for room in space.get("rooms") or []}:
|
||||
_invalid("radar room must exist in the marker space")
|
||||
mount = _mapping(obj.get("mount"), "radar.mount")
|
||||
_identifier(mount.get("installation_id"), "radar installation id")
|
||||
_finite(mount.get("x"), "radar.mount.x", -CANVAS_LIMIT, CANVAS_LIMIT)
|
||||
_finite(mount.get("y"), "radar.mount.y", -CANVAS_LIMIT, CANVAS_LIMIT)
|
||||
_finite(mount.get("heading_deg"), "radar.mount.heading_deg", 0, 359.999999999)
|
||||
if mount.get("range_cm") is not None:
|
||||
_finite(mount["range_cm"], "radar.mount.range_cm", 0.000001, 10000)
|
||||
if mount.get("fov_deg") is not None:
|
||||
_finite(mount["fov_deg"], "radar.mount.fov_deg", 0.000001, 360)
|
||||
calibration = _mapping(obj.get("calibration"), "radar.calibration")
|
||||
if calibration.get("method") not in {"manual", "two_point", "not_required"}:
|
||||
_invalid("radar calibration method is invalid")
|
||||
if not isinstance(calibration.get("mirror"), bool):
|
||||
_invalid("radar calibration mirror must be boolean")
|
||||
calibration_cell_cm = _finite(
|
||||
calibration.get("cell_cm"), "radar calibration cell_cm", 0.01, 1000,
|
||||
)
|
||||
try:
|
||||
space_cell_cm = float(space.get("cell_cm", 5))
|
||||
except (TypeError, ValueError):
|
||||
_invalid("radar owner space scale is invalid")
|
||||
if not math.isfinite(space_cell_cm) or space_cell_cm <= 0 \
|
||||
or not math.isclose(calibration_cell_cm, space_cell_cm, abs_tol=1e-9):
|
||||
_invalid("radar calibration scale does not match the owner space")
|
||||
refs = calibration.get("refs")
|
||||
if calibration["method"] == "two_point":
|
||||
if not isinstance(refs, list) or len(refs) != 2:
|
||||
_invalid("two-point calibration requires exactly two references")
|
||||
local_points: list[tuple[float, float]] = []
|
||||
plan_points: list[tuple[float, float]] = []
|
||||
for ref in refs:
|
||||
entry = _mapping(ref, "radar calibration reference")
|
||||
plan_points.append(_point(entry.get("plan"), "radar calibration plan point"))
|
||||
local = _point(entry.get("local_cm"), "radar calibration local point")
|
||||
local_points.append(local)
|
||||
if math.hypot(*local) < 50:
|
||||
_invalid("radar calibration reference is too close to the mount")
|
||||
try:
|
||||
fit = solve_two_point(
|
||||
(_finite(mount.get("x"), "radar.mount.x"),
|
||||
_finite(mount.get("y"), "radar.mount.y")),
|
||||
local_points, plan_points,
|
||||
_finite(calibration.get("cell_cm"), "radar calibration cell_cm", 0.01, 1000),
|
||||
)
|
||||
except ValueError as err:
|
||||
_invalid(str(err))
|
||||
if not math.isclose(float(mount["heading_deg"]), fit.heading_deg, abs_tol=1e-6) \
|
||||
or calibration["mirror"] is not fit.mirror:
|
||||
_invalid("radar two-point calibration parameters do not match its references")
|
||||
if calibration.get("rms_cm") is None \
|
||||
or not math.isclose(float(calibration["rms_cm"]), fit.rms_cm, abs_tol=1e-6):
|
||||
_invalid("radar two-point calibration rms does not match its references")
|
||||
elif refs not in (None, []):
|
||||
_invalid("manual calibration cannot contain captured references")
|
||||
if calibration.get("rms_cm") is not None:
|
||||
_finite(calibration["rms_cm"], "radar calibration rms_cm", 0, 30)
|
||||
allowed = obj.get("allowed_room_ids")
|
||||
if allowed is not None:
|
||||
room_ids = _array(allowed, "radar.allowed_room_ids", 32)
|
||||
known = {str(room.get("id")) for room in space.get("rooms") or []}
|
||||
if len(set(room_ids)) != len(room_ids) or any(value not in known for value in room_ids):
|
||||
_invalid("radar allowed rooms must be unique rooms in the marker space")
|
||||
|
||||
|
||||
def _validate_settings(settings: Any) -> None:
|
||||
obj = _mapping(settings, "settings.radar")
|
||||
if obj.get("version") not in (None, 1):
|
||||
_invalid("unsupported radar settings version")
|
||||
if obj.get("show_live") is not None and not isinstance(obj.get("show_live"), bool):
|
||||
_invalid("settings.radar.show_live must be boolean")
|
||||
|
||||
|
||||
def validate_marker_radars(config: dict[str, Any], previous: dict[str, Any] | None = None,
|
||||
*, validate_all: bool = False,
|
||||
registry: dict[str, dict[str, dict[str, Any]]] | None = None) -> None:
|
||||
"""Validate only newly created/changed known radar namespaces.
|
||||
|
||||
This mirrors the project's lossless compatibility doctrine: untouched
|
||||
future or malformed blocks stay inert and round-trip through unrelated
|
||||
edits, but the editor cannot create or modify invalid known data.
|
||||
"""
|
||||
spaces = {str(space.get("id")): space for space in config.get("spaces") or []}
|
||||
markers = {str(marker.get("id")): marker for marker in config.get("markers") or []}
|
||||
previous_markers = {
|
||||
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
|
||||
}
|
||||
configured = 0
|
||||
for marker_id, marker in markers.items():
|
||||
if "radar" not in marker:
|
||||
continue
|
||||
radar = marker.get("radar")
|
||||
if radar is None:
|
||||
continue
|
||||
configured += 1
|
||||
old = previous_markers.get(marker_id, {}).get("radar", object())
|
||||
if validate_all or radar != old:
|
||||
_validate_radar(radar, spaces, marker, registry)
|
||||
if configured > MAX_RADARS:
|
||||
_invalid("at most 32 radars may be configured")
|
||||
|
||||
missing = object()
|
||||
settings = (config.get("settings") or {}).get("radar", missing)
|
||||
previous_settings = ((previous or {}).get("settings") or {}).get("radar", missing)
|
||||
if settings is not missing and (validate_all or settings != previous_settings):
|
||||
_validate_settings(settings)
|
||||
|
||||
|
||||
def validate_radar_draft(
|
||||
config: dict[str, Any], marker_id: str, radar: Any,
|
||||
registry: dict[str, dict[str, dict[str, Any]]] | None = None,
|
||||
) -> tuple[dict[str, Any], set[str]]:
|
||||
"""Validate an unsaved setup block against its exact stored marker owner."""
|
||||
marker = next(
|
||||
(item for item in config.get("markers") or []
|
||||
if isinstance(item, dict) and str(item.get("id")) == marker_id),
|
||||
None,
|
||||
)
|
||||
if marker is None or marker.get("removed") is True or marker.get("binding") == "virtual":
|
||||
_invalid("radar setup marker is invalid")
|
||||
candidate = copy.deepcopy(marker)
|
||||
candidate["radar"] = copy.deepcopy(radar)
|
||||
spaces = {str(space.get("id")): space for space in config.get("spaces") or []}
|
||||
_validate_radar(candidate["radar"], spaces, candidate, registry)
|
||||
return candidate, radar_source_entity_ids(candidate["radar"])
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Bounded, permission-aware WebSocket surface for presence radars (#485)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.auth.permissions.const import POLICY_READ
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
async_track_state_report_event,
|
||||
)
|
||||
|
||||
from .auth import may_write
|
||||
from .radar import MAX_FRAME_HZ, RadarCoordinator
|
||||
from .radar_validation import (
|
||||
RadarValidationError,
|
||||
radar_registry_evidence,
|
||||
validate_radar_draft,
|
||||
)
|
||||
from .store import get_data
|
||||
|
||||
_INSPECT_CALLS: dict[str, deque[float]] = defaultdict(deque)
|
||||
_ACTIVE_SUBSCRIPTIONS: dict[str, int] = defaultdict(int)
|
||||
_ACTIVE_SETUP: set[tuple[str, str]] = set()
|
||||
_MAX_SETUP_TOTAL = 8
|
||||
|
||||
|
||||
def _user_id(connection) -> str:
|
||||
return str(getattr(getattr(connection, "user", None), "id", ""))
|
||||
|
||||
|
||||
def _validated_draft(
|
||||
coordinator: RadarCoordinator, message: dict[str, Any], connection,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], set[str]] | None:
|
||||
payload = message.get("draft_sources")
|
||||
radar = payload.get("radar") if isinstance(payload, dict) else None
|
||||
try:
|
||||
if len(json.dumps(payload, separators=(",", ":"), allow_nan=False).encode()) > 65536:
|
||||
raise vol.Invalid("radar draft is too large")
|
||||
marker, source_ids = validate_radar_draft(
|
||||
coordinator.config, message["marker_id"], radar,
|
||||
radar_registry_evidence(coordinator.hass),
|
||||
)
|
||||
except RadarValidationError:
|
||||
connection.send_error(message["id"], "invalid_radar", "invalid_radar")
|
||||
return None
|
||||
except (TypeError, ValueError, vol.Invalid):
|
||||
connection.send_error(message["id"], "invalid_selection", "invalid_selection")
|
||||
return None
|
||||
if not _can_read(connection, source_ids):
|
||||
connection.send_error(message["id"], "source_restricted", "source_restricted")
|
||||
return None
|
||||
if any(
|
||||
coordinator.hass.states.get(entity_id) is None
|
||||
or str(coordinator.hass.states.get(entity_id).state) in {"unknown", "unavailable"}
|
||||
for entity_id in source_ids
|
||||
):
|
||||
connection.send_error(message["id"], "source_unavailable", "source_unavailable")
|
||||
return None
|
||||
return marker, radar, source_ids
|
||||
|
||||
|
||||
def _coordinator(hass: HomeAssistant, connection, msg_id: int) -> RadarCoordinator | None:
|
||||
runtime = get_data(hass)
|
||||
coordinator = getattr(runtime, "radar_coordinator", None) if runtime else None
|
||||
if runtime is not None and coordinator is None:
|
||||
connection.send_error(msg_id, "unsupported_capability", "unsupported_capability")
|
||||
return None
|
||||
if not isinstance(coordinator, RadarCoordinator) or coordinator.closed:
|
||||
connection.send_error(msg_id, "not_ready", "not_ready")
|
||||
return None
|
||||
return coordinator
|
||||
|
||||
|
||||
def _can_read(connection, entity_ids: set[str]) -> bool:
|
||||
user = getattr(connection, "user", None)
|
||||
permissions = getattr(user, "permissions", None)
|
||||
return bool(permissions) and all(
|
||||
permissions.check_entity(entity_id, POLICY_READ) for entity_id in entity_ids
|
||||
)
|
||||
|
||||
|
||||
def _rate_limit(user_id: str) -> bool:
|
||||
now = time.monotonic()
|
||||
calls = _INSPECT_CALLS[user_id]
|
||||
while calls and calls[0] <= now - 60:
|
||||
calls.popleft()
|
||||
if len(calls) >= 10:
|
||||
return False
|
||||
calls.append(now)
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_radar_subscribe)
|
||||
websocket_api.async_register_command(hass, ws_radar_setup_inspect)
|
||||
websocket_api.async_register_command(hass, ws_radar_setup_subscribe)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): "houseplan/radar/subscribe",
|
||||
vol.Required("space_id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
})
|
||||
def ws_radar_subscribe(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
coordinator = _coordinator(hass, connection, msg["id"])
|
||||
if coordinator is None:
|
||||
return
|
||||
connection_key = _user_id(connection)
|
||||
if _ACTIVE_SUBSCRIPTIONS[connection_key] >= 4:
|
||||
connection.send_error(msg["id"], "rate_limited", "rate_limited")
|
||||
return
|
||||
space_id = msg["space_id"]
|
||||
if not coordinator.has_space(space_id):
|
||||
connection.send_error(msg["id"], "invalid_selection", "invalid_selection")
|
||||
return
|
||||
|
||||
@callback
|
||||
def publish(marker_id: str, frame: dict[str, Any]) -> None:
|
||||
if coordinator.space_for_marker(marker_id) != space_id:
|
||||
return
|
||||
if not _can_read(connection, coordinator.source_ids(marker_id)):
|
||||
connection.send_event(msg["id"], {
|
||||
"marker_id": marker_id,
|
||||
"server_session_id": coordinator.server_session_id,
|
||||
"seq": frame.get("seq", 0), "health": "restricted",
|
||||
"reported_presence": None, "complete": False,
|
||||
"targets": [], "ranges": [], "zones": [],
|
||||
})
|
||||
return
|
||||
connection.send_event(msg["id"], frame)
|
||||
|
||||
unsub = coordinator.add_listener(publish, publish_initial=False)
|
||||
|
||||
@callback
|
||||
def remove() -> None:
|
||||
unsub()
|
||||
_ACTIVE_SUBSCRIPTIONS[connection_key] = max(
|
||||
0, _ACTIVE_SUBSCRIPTIONS[connection_key] - 1
|
||||
)
|
||||
if not _ACTIVE_SUBSCRIPTIONS[connection_key]:
|
||||
_ACTIVE_SUBSCRIPTIONS.pop(connection_key, None)
|
||||
|
||||
_ACTIVE_SUBSCRIPTIONS[connection_key] += 1
|
||||
connection.subscriptions[msg["id"]] = remove
|
||||
connection.send_result(msg["id"], {
|
||||
"server_session_id": coordinator.server_session_id,
|
||||
"config_rev": coordinator.config_rev,
|
||||
})
|
||||
for frame in coordinator.frames_for_space(space_id):
|
||||
publish(str(frame.get("marker_id", "")), coordinator.public_frame(frame))
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): "houseplan/radar/setup/inspect",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=128)),
|
||||
vol.Optional("draft_sources"): vol.All(dict, vol.Length(max=128)),
|
||||
})
|
||||
def ws_radar_setup_inspect(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
if not may_write(hass, getattr(connection, "user", None)):
|
||||
connection.send_error(msg["id"], "unauthorized", "unauthorized")
|
||||
return
|
||||
coordinator = _coordinator(hass, connection, msg["id"])
|
||||
if coordinator is None:
|
||||
return
|
||||
user_id = _user_id(connection)
|
||||
if not _rate_limit(user_id):
|
||||
connection.send_error(msg["id"], "rate_limited", "rate_limited")
|
||||
return
|
||||
if "draft_sources" in msg:
|
||||
validated = _validated_draft(coordinator, msg, connection)
|
||||
if validated is None:
|
||||
return
|
||||
marker, radar, _source_ids = validated
|
||||
connection.send_result(msg["id"], coordinator.inspect_draft(marker, radar))
|
||||
return
|
||||
marker_id = msg["marker_id"]
|
||||
if not _can_read(connection, coordinator.source_ids(marker_id)):
|
||||
connection.send_error(msg["id"], "source_restricted", "source_restricted")
|
||||
return
|
||||
connection.send_result(msg["id"], coordinator.inspect(marker_id))
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): "houseplan/radar/setup/subscribe",
|
||||
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=128)),
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Optional("draft_sources"): vol.All(dict, vol.Length(max=128)),
|
||||
})
|
||||
def ws_radar_setup_subscribe(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
if not may_write(hass, getattr(connection, "user", None)):
|
||||
connection.send_error(msg["id"], "unauthorized", "unauthorized")
|
||||
return
|
||||
coordinator = _coordinator(hass, connection, msg["id"])
|
||||
if coordinator is None:
|
||||
return
|
||||
if msg["expected_config_rev"] != coordinator.config_rev:
|
||||
connection.send_error(msg["id"], "conflict", "conflict")
|
||||
return
|
||||
marker_id = msg["marker_id"]
|
||||
user_id = _user_id(connection)
|
||||
key = (user_id, marker_id)
|
||||
if key in _ACTIVE_SETUP or len(_ACTIVE_SETUP) >= _MAX_SETUP_TOTAL:
|
||||
connection.send_error(msg["id"], "rate_limited", "rate_limited")
|
||||
return
|
||||
draft: tuple[dict[str, Any], dict[str, Any], set[str]] | None = None
|
||||
if "draft_sources" in msg:
|
||||
draft = _validated_draft(coordinator, msg, connection)
|
||||
if draft is None:
|
||||
return
|
||||
marker, radar, source_ids = draft
|
||||
else:
|
||||
marker = coordinator.marker_config(marker_id)
|
||||
if marker_id not in coordinator.radars or marker is None:
|
||||
connection.send_error(msg["id"], "invalid_selection", "invalid_selection")
|
||||
return
|
||||
radar = marker["radar"]
|
||||
source_ids = coordinator.source_ids(marker_id)
|
||||
if not _can_read(connection, source_ids):
|
||||
connection.send_error(msg["id"], "source_restricted", "source_restricted")
|
||||
return
|
||||
|
||||
pending: asyncio.TimerHandle | None = None
|
||||
unsubs: list[Any] = []
|
||||
unregister_cleanup: Any = None
|
||||
removed = False
|
||||
|
||||
@callback
|
||||
def send_snapshot() -> None:
|
||||
nonlocal pending
|
||||
pending = None
|
||||
if not _can_read(connection, source_ids):
|
||||
connection.send_event(msg["id"], {"marker_id": marker_id, "health": "restricted"})
|
||||
return
|
||||
connection.send_event(msg["id"], coordinator.inspect_draft(marker, radar))
|
||||
|
||||
@callback
|
||||
def source_reported(_event) -> None:
|
||||
nonlocal pending
|
||||
if pending is None:
|
||||
pending = hass.loop.call_later(1 / MAX_FRAME_HZ, send_snapshot)
|
||||
|
||||
if source_ids:
|
||||
unsubs.append(async_track_state_report_event(hass, sorted(source_ids), source_reported))
|
||||
unsubs.append(async_track_state_change_event(hass, sorted(source_ids), source_reported))
|
||||
|
||||
@callback
|
||||
def remove() -> None:
|
||||
nonlocal pending, unregister_cleanup, removed
|
||||
if removed:
|
||||
return
|
||||
removed = True
|
||||
if pending is not None:
|
||||
pending.cancel()
|
||||
pending = None
|
||||
for unsub in unsubs:
|
||||
unsub()
|
||||
unsubs.clear()
|
||||
_ACTIVE_SETUP.discard(key)
|
||||
if unregister_cleanup is not None:
|
||||
unregister_cleanup()
|
||||
unregister_cleanup = None
|
||||
|
||||
_ACTIVE_SETUP.add(key)
|
||||
unregister_cleanup = coordinator.add_external_cleanup(remove)
|
||||
connection.subscriptions[msg["id"]] = remove
|
||||
connection.send_result(msg["id"], {
|
||||
"server_session_id": coordinator.server_session_id,
|
||||
"config_rev": coordinator.config_rev,
|
||||
})
|
||||
send_snapshot()
|
||||
@@ -109,6 +109,8 @@ class HouseplanData:
|
||||
# memory-only, invalidated by a rev mismatch; the previous document is
|
||||
# not re-judged on every write. Counts only — documents are never held.
|
||||
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
|
||||
# Runtime-only #485 authority; never serialized by HouseplanStore.
|
||||
radar_coordinator: Any | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
@@ -236,3 +238,154 @@ async def async_save_config_state(
|
||||
except Exception: # noqa: BLE001 - config commit already stands
|
||||
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedStorePair:
|
||||
"""One coherent config/layout read, optionally after pending recovery."""
|
||||
|
||||
config_data: dict[str, Any]
|
||||
layout_data: dict[str, Any]
|
||||
recovered_kind: str | None = None
|
||||
|
||||
|
||||
def _pending_target(
|
||||
layout_data: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a structurally usable paired-write intent, if one is present."""
|
||||
pending = layout_data.get(OPTIMIZE_PENDING)
|
||||
if not isinstance(pending, dict):
|
||||
return None
|
||||
if not isinstance(pending.get("config"), dict):
|
||||
return None
|
||||
if not isinstance(pending.get("layout"), dict):
|
||||
return None
|
||||
return pending
|
||||
|
||||
|
||||
async def async_converge_store_pair(
|
||||
runtime: HouseplanData,
|
||||
pending: dict[str, Any],
|
||||
*,
|
||||
config_data: dict[str, Any] | None = None,
|
||||
layout_data: dict[str, Any] | None = None,
|
||||
) -> ResolvedStorePair:
|
||||
"""Converge both stores on one durable paired-write intent.
|
||||
|
||||
Callers hold ``runtime.write_lock``. Store may raise after bytes reached
|
||||
disk, so each failed half is reloaded and compared with the exact payload
|
||||
before the exception is allowed to escape.
|
||||
"""
|
||||
if config_data is None:
|
||||
config_data = await runtime.config_store.async_load() or {}
|
||||
if layout_data is None:
|
||||
layout_data = await runtime.store.async_load() or {}
|
||||
|
||||
target_config = canonicalize_config_geometry(pending["config"])
|
||||
target_layout = canonicalize_layout_geometry(pending["layout"])
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
target_config_rev = int(pending.get(
|
||||
"config_rev", config_rev + (config_data.get("config") != target_config)
|
||||
))
|
||||
target_layout_rev = int(pending.get(
|
||||
"layout_rev", layout_rev + (layout_data.get("layout", {}) != target_layout)
|
||||
))
|
||||
|
||||
exact_metadata = pending.get("final_metadata")
|
||||
replace_metadata = isinstance(exact_metadata, dict)
|
||||
config_needs_write = (
|
||||
config_data.get("config") != target_config
|
||||
or (
|
||||
config_rev != target_config_rev
|
||||
if replace_metadata
|
||||
else config_rev < target_config_rev
|
||||
)
|
||||
)
|
||||
if config_needs_write:
|
||||
previous_config_rev = config_rev
|
||||
config_rev = (
|
||||
target_config_rev
|
||||
if replace_metadata
|
||||
else max(config_rev, target_config_rev)
|
||||
)
|
||||
expected_config = {"config": target_config, "rev": config_rev}
|
||||
try:
|
||||
config_data = await async_save_config_state(
|
||||
runtime,
|
||||
target_config,
|
||||
config_rev,
|
||||
previous_rev=previous_config_rev,
|
||||
)
|
||||
except Exception:
|
||||
# A Store write is allowed to fail after its atomic replacement.
|
||||
# Exact reload distinguishes that case from a half that never
|
||||
# reached disk; no inference is made from the exception itself.
|
||||
config_data = await runtime.config_store.async_load() or {}
|
||||
if config_data != expected_config:
|
||||
raise
|
||||
|
||||
layout_rev = (
|
||||
target_layout_rev
|
||||
if replace_metadata
|
||||
else max(layout_rev, target_layout_rev)
|
||||
)
|
||||
metadata = dict(exact_metadata) if replace_metadata else None
|
||||
if (
|
||||
not replace_metadata
|
||||
and not pending.get("clear_backup")
|
||||
and OPTIMIZE_BACKUP in layout_data
|
||||
):
|
||||
metadata = {OPTIMIZE_BACKUP: layout_data[OPTIMIZE_BACKUP]}
|
||||
remove_metadata = [OPTIMIZE_PENDING, OPTIMIZE_BACKUP]
|
||||
if pending.get("clear_backup"):
|
||||
# A recovered whole-plan undo replaces the complete layout; a nested
|
||||
# repair snapshot from that replaced layout must not survive it.
|
||||
remove_metadata.append("repair_backup")
|
||||
expected_layout = layout_store_payload(
|
||||
layout_data,
|
||||
target_layout,
|
||||
layout_rev,
|
||||
metadata=metadata,
|
||||
remove=tuple(remove_metadata),
|
||||
replace_metadata=replace_metadata,
|
||||
)
|
||||
try:
|
||||
layout_data = await async_save_layout_state(
|
||||
runtime,
|
||||
layout_data,
|
||||
target_layout,
|
||||
layout_rev,
|
||||
metadata=metadata,
|
||||
remove=tuple(remove_metadata),
|
||||
replace_metadata=replace_metadata,
|
||||
)
|
||||
except Exception:
|
||||
layout_data = await runtime.store.async_load() or {}
|
||||
if layout_data != expected_layout:
|
||||
raise
|
||||
|
||||
# Return fresh durable documents. A following writer must never continue
|
||||
# with pre-recovery revisions or a pre-recovery layout snapshot.
|
||||
config_data = await runtime.config_store.async_load() or {}
|
||||
layout_data = await runtime.store.async_load() or {}
|
||||
return ResolvedStorePair(
|
||||
config_data=config_data,
|
||||
layout_data=layout_data,
|
||||
recovered_kind=str(pending.get("kind") or "plan optimization"),
|
||||
)
|
||||
|
||||
|
||||
async def async_resolve_pending_pair(runtime: HouseplanData) -> ResolvedStorePair:
|
||||
"""Load a coherent pair, finishing a valid durable intent first."""
|
||||
config_data = await runtime.config_store.async_load() or {}
|
||||
layout_data = await runtime.store.async_load() or {}
|
||||
pending = _pending_target(layout_data)
|
||||
if pending is None:
|
||||
return ResolvedStorePair(config_data=config_data, layout_data=layout_data)
|
||||
return await async_converge_store_pair(
|
||||
runtime,
|
||||
pending,
|
||||
config_data=config_data,
|
||||
layout_data=layout_data,
|
||||
)
|
||||
|
||||
@@ -136,6 +136,15 @@ def _custom_fill(value: object) -> dict[str, Any] | None:
|
||||
return _copy_keys(value, ("c", "a"))
|
||||
|
||||
|
||||
# The eleven palette slots the card reads (src/logic.ts DEFAULT_FILL_COLORS).
|
||||
# The config schema stays open on purpose so older or extended configs keep
|
||||
# loading; a package must not carry a key the product never defined (#498).
|
||||
SUPPORT_FILL_COLOR_KEYS = (
|
||||
"light_on", "light_off", "light_none", "temp_cold", "temp_ok", "temp_hot",
|
||||
"lqi_low", "lqi_high", "glow_base", "glow_light", "wall_fill",
|
||||
)
|
||||
|
||||
|
||||
def _global_settings(value: object) -> dict[str, Any]:
|
||||
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
|
||||
if not isinstance(value, dict):
|
||||
@@ -145,11 +154,13 @@ def _global_settings(value: object) -> dict[str, Any]:
|
||||
out["show_room_tooltip"] = show_room_tooltip
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
out["fill_colors"] = {
|
||||
str(key): _copy_keys(item, ("c", "a"))
|
||||
for key, item in fill_colors.items()
|
||||
if isinstance(key, str) and isinstance(item, dict)
|
||||
palette = {
|
||||
key: _copy_keys(fill_colors[key], ("c", "a"))
|
||||
for key in SUPPORT_FILL_COLOR_KEYS
|
||||
if isinstance(fill_colors.get(key), dict)
|
||||
}
|
||||
if palette:
|
||||
out["fill_colors"] = palette
|
||||
style = value.get("decor_default_style")
|
||||
if isinstance(style, dict):
|
||||
out["decor_default_style"] = _copy_keys(
|
||||
|
||||
@@ -317,15 +317,53 @@ class TrailRecorder:
|
||||
A tombstone deliberately stays in config so discovery cannot resurrect
|
||||
a deleted device. For live tracking and trail ownership it is absent:
|
||||
this is the same boundary used by ``async_refresh`` above.
|
||||
|
||||
Returns the number of erased markers. Runs dropped because their route
|
||||
vanished (#162) are not counted, but they are just as durable (#495):
|
||||
they leave with the orphan transaction, or with a write of their own.
|
||||
"""
|
||||
live_marker_ids = {
|
||||
str(marker.get("id"))
|
||||
for marker in config.get("markers") or []
|
||||
if marker.get("id") is not None and marker.get("removed") is not True
|
||||
}
|
||||
# #162: a route that vanished (deleted, or re-targeted to another
|
||||
# space, which is a new identity) takes its own runs with it, while the
|
||||
# marker and its other routes stay untouched.
|
||||
fire = False
|
||||
try:
|
||||
async with self._refresh_lock:
|
||||
# #162: a route that vanished (deleted, or re-targeted to
|
||||
# another space, which is a new identity) takes its own runs
|
||||
# with it, while the marker and its other routes stay untouched.
|
||||
dropped = self._drop_unknown_routes(config)
|
||||
orphan_ids = set(self.book.data) - live_marker_ids
|
||||
if not orphan_ids and not dropped:
|
||||
return 0
|
||||
try:
|
||||
if orphan_ids:
|
||||
removed = await self._delete_many_locked(orphan_ids)
|
||||
else:
|
||||
removed = 0
|
||||
await self._save_now_locked()
|
||||
except Exception:
|
||||
# The store is the durable authority (#335): a drop that
|
||||
# never reached it must not survive only in memory, or a
|
||||
# restart brings the runs back while the card believed
|
||||
# them gone. Put them back so the next commit retries.
|
||||
self._restore_dropped(dropped)
|
||||
raise
|
||||
fire = True
|
||||
return removed
|
||||
except Exception: # noqa: BLE001 — config commit already succeeded
|
||||
_LOGGER.exception(
|
||||
"House Plan: reconciling vacuum trails after a config commit failed",
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
if fire:
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
|
||||
def _drop_unknown_routes(self, config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Forget runs of routes the config no longer has; return what was dropped."""
|
||||
dropped: dict[str, dict[str, Any]] = {}
|
||||
for marker in config.get("markers") or []:
|
||||
marker_id = str(marker.get("id"))
|
||||
if marker.get("removed") is True or marker_id not in self.book.data:
|
||||
@@ -333,56 +371,75 @@ class TrailRecorder:
|
||||
vacuum = marker.get("vacuum") or {}
|
||||
routes = effective_routes(
|
||||
marker_id, vacuum, str(marker.get("space") or ""), vacuum.get("source"))
|
||||
self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes})
|
||||
orphan_ids = set(self.book.data) - live_marker_ids
|
||||
if not orphan_ids:
|
||||
return 0
|
||||
before = {
|
||||
slot: run for slot, run in (self.book.data.get(marker_id) or {}).items()
|
||||
if slot in ("current", "previous")
|
||||
}
|
||||
if self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes}):
|
||||
after = self.book.data.get(marker_id) or {}
|
||||
dropped[marker_id] = {
|
||||
slot: run for slot, run in before.items() if slot not in after
|
||||
}
|
||||
return dropped
|
||||
|
||||
def _restore_dropped(self, dropped: dict[str, dict[str, Any]]) -> None:
|
||||
for marker_id, runs in dropped.items():
|
||||
self.book.data.setdefault(marker_id, {}).update(runs)
|
||||
|
||||
async def _save_now_locked(self) -> None:
|
||||
"""Write the book immediately, superseding a pending debounced write.
|
||||
|
||||
A failed write puts the debounce back: the points it was going to
|
||||
persist are still only in memory.
|
||||
"""
|
||||
had_pending_save = self._unsub_save is not None
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
try:
|
||||
return await self._async_delete_many(orphan_ids)
|
||||
except Exception: # noqa: BLE001 — config commit already succeeded
|
||||
_LOGGER.exception(
|
||||
"House Plan: removing orphan vacuum trails failed: markers=%s",
|
||||
sorted(orphan_ids),
|
||||
)
|
||||
return 0
|
||||
await self.store.async_save(self.book.data)
|
||||
except Exception:
|
||||
if had_pending_save:
|
||||
self._schedule_save()
|
||||
raise
|
||||
|
||||
async def _async_delete_many(self, markers: set[str]) -> int:
|
||||
"""Delete one or more books with one subscription/store transaction."""
|
||||
async with self._refresh_lock:
|
||||
# The trail book owns deletion. When it has no such marker, this
|
||||
# is a no-op and must not silently damage the live tracking graph.
|
||||
removed = {
|
||||
marker: self.book.data.pop(marker)
|
||||
for marker in markers
|
||||
if marker in self.book.data
|
||||
}
|
||||
if not removed:
|
||||
return 0
|
||||
previous_pairs = {src: list(pairs) for src, pairs in self.pairs.items()}
|
||||
had_pending_save = self._unsub_save is not None
|
||||
try:
|
||||
for src in list(self.pairs):
|
||||
kept = [pair for pair in self.pairs[src] if pair[0] not in removed]
|
||||
if kept:
|
||||
self.pairs[src] = kept
|
||||
else:
|
||||
del self.pairs[src]
|
||||
self._resubscribe()
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
await self.store.async_save(self.book.data)
|
||||
except Exception:
|
||||
# The store is the durable authority. Restore the in-memory
|
||||
# owner graph so the next successful config sync can retry
|
||||
# instead of leaving an orphan on disk forever (#335).
|
||||
self.book.data.update(removed)
|
||||
self.pairs = previous_pairs
|
||||
self._resubscribe()
|
||||
if had_pending_save:
|
||||
self._schedule_save()
|
||||
raise
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
removed = await self._delete_many_locked(markers)
|
||||
if removed:
|
||||
self.hass.bus.async_fire("houseplan_trail_updated", {})
|
||||
return removed
|
||||
|
||||
async def _delete_many_locked(self, markers: set[str]) -> int:
|
||||
"""The transaction of ``_async_delete_many``; the caller holds the lock."""
|
||||
# The trail book owns deletion. When it has no such marker, this
|
||||
# is a no-op and must not silently damage the live tracking graph.
|
||||
removed = {
|
||||
marker: self.book.data.pop(marker)
|
||||
for marker in markers
|
||||
if marker in self.book.data
|
||||
}
|
||||
if not removed:
|
||||
return 0
|
||||
previous_pairs = {src: list(pairs) for src, pairs in self.pairs.items()}
|
||||
try:
|
||||
for src in list(self.pairs):
|
||||
kept = [pair for pair in self.pairs[src] if pair[0] not in removed]
|
||||
if kept:
|
||||
self.pairs[src] = kept
|
||||
else:
|
||||
del self.pairs[src]
|
||||
self._resubscribe()
|
||||
await self._save_now_locked()
|
||||
except Exception:
|
||||
# The store is the durable authority. Restore the in-memory
|
||||
# owner graph so the next successful config sync can retry
|
||||
# instead of leaving an orphan on disk forever (#335).
|
||||
self.book.data.update(removed)
|
||||
self.pairs = previous_pairs
|
||||
self._resubscribe()
|
||||
raise
|
||||
return len(removed)
|
||||
|
||||
def _resubscribe(self) -> None:
|
||||
|
||||
@@ -180,7 +180,11 @@ 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(
|
||||
stored_drafts = any(
|
||||
"room_drafts" in space for space in previous.get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
)
|
||||
if old_model >= 10 and not stored_drafts and any(
|
||||
"room_drafts" in space for space in config.get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
):
|
||||
@@ -188,6 +192,15 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
|
||||
# projection at all. The old catalogue guard therefore cannot see the
|
||||
# destructive downgrade, but v10 must never persist the removed
|
||||
# carrier again (#478 AC2).
|
||||
#
|
||||
# #529: the submitted model number is not the tell. A stale card
|
||||
# echoes back the `model_version` it was given — the current one — so
|
||||
# the old `new_model < 10` condition let exactly the outdated client
|
||||
# through and the user met an unrelated "conflicting wall identifiers"
|
||||
# instead of "update the card and reload the page". What identifies the
|
||||
# stale writer is the carrier itself appearing over a stored plan that
|
||||
# does not have it; legacy left in storage is not a stale writer and is
|
||||
# healed by the migration instead (#529 K4).
|
||||
raise WallModelClientOutdatedError(
|
||||
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
|
||||
)
|
||||
@@ -1101,6 +1114,15 @@ def _finite(value):
|
||||
return f
|
||||
|
||||
|
||||
def _finite_number_or_none(value):
|
||||
"""A persisted optional number: do not coerce strings or booleans."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise vol.Invalid("temperature threshold must be a finite number or null")
|
||||
return _finite(value)
|
||||
|
||||
|
||||
# Persisted colours deliberately use one small, browser-independent format.
|
||||
# Keep this exact contract in sync with src/color.ts.
|
||||
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
|
||||
@@ -1280,6 +1302,8 @@ ROOM_SCHEMA = vol.All(
|
||||
vol.Optional("glow"): vol.Any(bool, None),
|
||||
vol.Optional("temp_source"): vol.Any(str, None),
|
||||
vol.Optional("hum_source"): vol.Any(str, None),
|
||||
vol.Optional("temp_min"): _finite_number_or_none,
|
||||
vol.Optional("temp_max"): _finite_number_or_none,
|
||||
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
|
||||
},
|
||||
@@ -1803,6 +1827,10 @@ MARKER_SCHEMA = vol.Schema(
|
||||
),
|
||||
}),
|
||||
),
|
||||
# #485: kept structurally open here so untouched future versions and
|
||||
# unknown siblings survive. validate_marker_radars performs the strict,
|
||||
# change-aware semantic validation at every write/import seam.
|
||||
vol.Optional("radar"): vol.Any(None, vol.Schema({}, extra=vol.ALLOW_EXTRA)),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
|
||||
vol.Optional("glow_color"): vol.Any(
|
||||
@@ -1888,7 +1916,11 @@ 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:
|
||||
# #529: пустой ключ данных не несёт и записи не стоит. Непустой
|
||||
# остаётся отказом — это последняя линия против устаревшего клиента,
|
||||
# и к ней человек приходит уже с понятным сообщением от сторожа
|
||||
# переходов.
|
||||
if model >= 10 and space.get("room_drafts"):
|
||||
raise vol.Invalid("v10 config must not contain room_drafts")
|
||||
segments = space.get("wall_segments")
|
||||
if segments is None:
|
||||
@@ -1981,6 +2013,159 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
# #437: the summary is shared configuration, so its wire shape is validated by
|
||||
# the integration rather than trusted to whichever browser happened to save it.
|
||||
def _summary_text(max_length: int):
|
||||
"""Trim user text and reject whitespace-only/raw oversized WS values."""
|
||||
def validate(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise vol.Invalid("summary text must be a string")
|
||||
text = value.strip()
|
||||
if not text or len(text) > max_length:
|
||||
raise vol.Invalid("summary text length is invalid")
|
||||
return text
|
||||
return validate
|
||||
|
||||
|
||||
_SUMMARY_ID = _summary_text(64)
|
||||
_SUMMARY_TITLE = _summary_text(48)
|
||||
_SUMMARY_LABEL = _summary_text(64)
|
||||
_SUMMARY_SCOPE = vol.Any(
|
||||
vol.Schema({vol.Required("type"): vol.Equal("all")}, extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({
|
||||
vol.Required("type"): vol.Equal("space"),
|
||||
vol.Required("space_id"): _SUMMARY_ID,
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
_SUMMARY_SOURCE = vol.Any(
|
||||
vol.Schema({
|
||||
vol.Required("type"): vol.Equal("system"),
|
||||
vol.Required("key"): vol.In(["device_count", "total_area", "datetime"]),
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({
|
||||
vol.Required("type"): vol.Equal("entity"),
|
||||
vol.Required("entity_id"): _summary_text(500),
|
||||
}, extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
_SUMMARY_VALUE_SCHEMA = vol.Schema({
|
||||
vol.Required("id"): _SUMMARY_ID,
|
||||
vol.Required("label"): _SUMMARY_LABEL,
|
||||
vol.Required("source"): _SUMMARY_SOURCE,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
_SUMMARY_BLOCK_SCHEMA = vol.Schema({
|
||||
vol.Required("id"): _SUMMARY_ID,
|
||||
vol.Required("title"): _SUMMARY_TITLE,
|
||||
vol.Required("visible"): bool,
|
||||
vol.Required("scope"): _SUMMARY_SCOPE,
|
||||
vol.Required("values"): vol.All([_SUMMARY_VALUE_SCHEMA], vol.Length(max=20)),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def _summary_panel_invariants(value: dict) -> dict:
|
||||
"""Stable block/value ids are the reference identity used by the editor."""
|
||||
block_ids: set[str] = set()
|
||||
value_ids: set[str] = set()
|
||||
for block in value.get("blocks", []):
|
||||
block_id = block["id"]
|
||||
if block_id in block_ids:
|
||||
raise vol.Invalid("summary block ids must be unique")
|
||||
block_ids.add(block_id)
|
||||
for item in block.get("values", []):
|
||||
value_id = item["id"]
|
||||
if value_id in value_ids:
|
||||
raise vol.Invalid("summary value ids must be unique")
|
||||
value_ids.add(value_id)
|
||||
return value
|
||||
|
||||
|
||||
SUMMARY_PANEL_SCHEMA = vol.All(vol.Schema({
|
||||
vol.Required("version"): vol.Equal(1),
|
||||
vol.Required("title"): _SUMMARY_TITLE,
|
||||
vol.Required("show_on_mobile"): bool,
|
||||
vol.Required("blocks"): vol.All([_SUMMARY_BLOCK_SCHEMA], vol.Length(max=10)),
|
||||
}, extra=vol.ALLOW_EXTRA), _summary_panel_invariants)
|
||||
|
||||
|
||||
def _future_summary_panel(value: object) -> dict:
|
||||
"""Keep a newer opaque namespace losslessly, with a bounded data envelope."""
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid("summary panel must be an object")
|
||||
version = value.get("version")
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version <= 1:
|
||||
raise vol.Invalid("unsupported summary panel version")
|
||||
if len(json.dumps(value, separators=(",", ":"), ensure_ascii=False)) > 128 * 1024:
|
||||
raise vol.Invalid("summary panel is too large")
|
||||
return value
|
||||
|
||||
|
||||
SUMMARY_PANEL_WIRE_SCHEMA = vol.Any(SUMMARY_PANEL_SCHEMA, _future_summary_panel)
|
||||
|
||||
|
||||
def preserve_summary_panel_namespace(config: dict, previous: dict | None) -> None:
|
||||
"""A stale client that does not know #437 cannot erase the shared panel."""
|
||||
old_settings = (previous or {}).get("settings")
|
||||
new_settings = config.get("settings")
|
||||
if not isinstance(old_settings, dict) or "summary_panel" not in old_settings:
|
||||
return
|
||||
if not isinstance(new_settings, dict):
|
||||
new_settings = {}
|
||||
config["settings"] = new_settings
|
||||
if "summary_panel" not in new_settings:
|
||||
new_settings["summary_panel"] = copy.deepcopy(old_settings["summary_panel"])
|
||||
|
||||
|
||||
def validate_summary_panel_references(
|
||||
config: dict, previous: dict | None, readable_entity_ids: set[str]
|
||||
) -> None:
|
||||
"""Only newly introduced broken refs fail; stable-id legacy refs warn in UI."""
|
||||
panel = (config.get("settings") or {}).get("summary_panel")
|
||||
if not isinstance(panel, dict) or panel.get("version") != 1:
|
||||
return
|
||||
old_panel = ((previous or {}).get("settings") or {}).get("summary_panel")
|
||||
old_blocks = {
|
||||
block.get("id"): block for block in (old_panel or {}).get("blocks", [])
|
||||
if isinstance(block, dict) and block.get("id")
|
||||
} if isinstance(old_panel, dict) and old_panel.get("version") == 1 else {}
|
||||
old_values = {
|
||||
item.get("id"): item
|
||||
for block in old_blocks.values()
|
||||
for item in block.get("values", [])
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
}
|
||||
spaces = {space.get("id") for space in config.get("spaces", []) if isinstance(space, dict)}
|
||||
for block in panel.get("blocks", []):
|
||||
scope = block.get("scope") or {}
|
||||
if scope.get("type") == "space" and scope.get("space_id") not in spaces:
|
||||
old_scope = (old_blocks.get(block.get("id"), {}).get("scope") or {})
|
||||
if old_scope.get("type") != "space" or old_scope.get("space_id") != scope.get("space_id"):
|
||||
raise vol.Invalid("new summary space reference must exist")
|
||||
for item in block.get("values", []):
|
||||
source = item.get("source") or {}
|
||||
entity_id = source.get("entity_id") if source.get("type") == "entity" else None
|
||||
if entity_id and entity_id not in readable_entity_ids:
|
||||
old_source = (old_values.get(item.get("id"), {}).get("source") or {})
|
||||
if old_source.get("type") != "entity" or old_source.get("entity_id") != entity_id:
|
||||
raise vol.Invalid("new summary entity reference must be readable")
|
||||
|
||||
|
||||
def prepare_ordinary_summary_candidate(
|
||||
candidate: dict,
|
||||
previous: dict | None,
|
||||
readable_entity_ids: set[str],
|
||||
normalize,
|
||||
) -> dict:
|
||||
"""Apply the one summary-panel contract shared by ordinary config writers.
|
||||
|
||||
`normalize` owns the writer-specific schema/migration sequence. Preservation
|
||||
must happen before it; change-aware reference validation must happen after
|
||||
it. Authoritative full import/restore intentionally does not use this helper.
|
||||
"""
|
||||
preserve_summary_panel_namespace(candidate, previous)
|
||||
checked = normalize(candidate)
|
||||
validate_summary_panel_references(checked, previous, readable_entity_ids)
|
||||
return checked
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
@@ -2011,6 +2196,8 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
vol.Optional("summary_panel"): SUMMARY_PANEL_WIRE_SCHEMA,
|
||||
vol.Optional("radar"): vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
|
||||
@@ -644,14 +644,21 @@ def _host_openings(
|
||||
opening.pop("host", None)
|
||||
|
||||
|
||||
def _migrate_room_drafts_to_partitions(
|
||||
space: dict[str, Any], initial_migration: bool,
|
||||
) -> tuple[int, int]:
|
||||
def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> 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 []
|
||||
# #529: наследие снимается, а не запирает план. Отказ здесь стоил
|
||||
# пользователю всего: карточка считает ту же миграцию своим зеркалом, так
|
||||
# что структурная правка отбивалась ещё до отправки, «Optimize plans» на
|
||||
# эту ветку не влияет, а экспорт зовёт эту же функцию — забрать бэкап и
|
||||
# починить файл руками тоже было нельзя. Защита от устаревшего клиента
|
||||
# (#478) переехала на свой слой: `validate_wall_model_transition` ловит
|
||||
# заявку с черновиками поверх чистого сохранённого конфига и отвечает
|
||||
# человеку «обновите карточку», а не загадкой про идентификаторы.
|
||||
if not drafts:
|
||||
space.pop("room_drafts", None)
|
||||
return 0, 0
|
||||
used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "partitions",
|
||||
@@ -709,9 +716,7 @@ def _migrate_room_drafts_to_partitions(
|
||||
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,
|
||||
)
|
||||
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(space)
|
||||
old: dict[str, dict[str, Any]] = {}
|
||||
for segment in space.get("wall_segments") or []:
|
||||
segment_id = str(segment.get("id", ""))
|
||||
|
||||
@@ -42,6 +42,7 @@ from .const import (
|
||||
PLAN_MODEL_VERSION,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
SUMMARY_PANEL_API_VERSION,
|
||||
SUPPORT_API_VERSION,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
@@ -80,10 +81,18 @@ from .plans import (
|
||||
reserve_filename,
|
||||
)
|
||||
from .projection import project_config, project_layout
|
||||
from .radar_validation import (
|
||||
RadarValidationError,
|
||||
radar_registry_evidence,
|
||||
validate_marker_radars,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .store import (
|
||||
LAYOUT_STORE_CORE_KEYS,
|
||||
HouseplanData,
|
||||
ResolvedStorePair,
|
||||
async_converge_store_pair,
|
||||
async_resolve_pending_pair,
|
||||
async_save_config_state,
|
||||
async_save_layout_state,
|
||||
get_data,
|
||||
@@ -109,6 +118,7 @@ from .validation import (
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
prepare_ordinary_summary_candidate,
|
||||
sanitize_filename,
|
||||
valid_space_id,
|
||||
validate_marker_controls,
|
||||
@@ -188,9 +198,51 @@ async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
|
||||
)
|
||||
|
||||
|
||||
class PairCommitFailure(Exception):
|
||||
"""A paired write failed after restoring, or while recovery stayed durable."""
|
||||
|
||||
def __init__(self, *, recovery_pending: bool) -> None:
|
||||
super().__init__("paired store commit failed")
|
||||
self.recovery_pending = recovery_pending
|
||||
|
||||
|
||||
async def _resolved_write_pair(
|
||||
hass: HomeAssistant,
|
||||
connection,
|
||||
msg_id: int,
|
||||
rt: HouseplanData,
|
||||
) -> ResolvedStorePair | None:
|
||||
"""Resolve an older pair before a writer reads revisions or state."""
|
||||
try:
|
||||
resolved = await async_resolve_pending_pair(rt)
|
||||
except Exception: # noqa: BLE001 - the pending intent must remain authoritative
|
||||
_LOGGER.exception("House Plan: an interrupted paired write is still pending recovery")
|
||||
connection.send_error(
|
||||
msg_id,
|
||||
"commit_failed",
|
||||
"A previous House Plan save is pending recovery; retry or restart Home Assistant",
|
||||
)
|
||||
return None
|
||||
if resolved.recovered_kind is not None:
|
||||
# Both durable halves are complete before either event is observable.
|
||||
hass.bus.async_fire(
|
||||
"houseplan_config_updated", {"rev": int(resolved.config_data.get("rev", 0))}
|
||||
)
|
||||
hass.bus.async_fire(
|
||||
"houseplan_layout_updated", {"rev": int(resolved.layout_data.get("rev", 0))}
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"House Plan: completed an interrupted %s before the next write",
|
||||
resolved.recovered_kind.replace("_", " "),
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
from .radar_websocket import async_register as async_register_radar
|
||||
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_delete)
|
||||
@@ -219,6 +271,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_support_preview)
|
||||
websocket_api.async_register_command(hass, ws_support_preview_discard)
|
||||
websocket_api.async_register_command(hass, ws_support_submit)
|
||||
async_register_radar(hass)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -325,23 +378,10 @@ async def _persist_pair_intent(
|
||||
|
||||
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
|
||||
"""Write both target halves and remove the durable intent last."""
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
pending["config"],
|
||||
int(pending["config_rev"]),
|
||||
)
|
||||
stored = await rt.store.async_load() or {}
|
||||
await async_save_layout_state(
|
||||
rt,
|
||||
stored,
|
||||
pending["layout"],
|
||||
int(pending["layout_rev"]),
|
||||
metadata=dict(pending.get("final_metadata") or {}),
|
||||
replace_metadata=True,
|
||||
)
|
||||
await async_converge_store_pair(rt, pending)
|
||||
|
||||
|
||||
async def _commit_import_pair(
|
||||
async def _commit_pair(
|
||||
rt: HouseplanData,
|
||||
pending: dict[str, Any],
|
||||
rollback: dict[str, Any],
|
||||
@@ -359,24 +399,22 @@ async def _commit_import_pair(
|
||||
await _converge_pair(rt, pending)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - one retry handles fail-after-write too
|
||||
_LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True)
|
||||
_LOGGER.warning("House Plan pair write failed; retrying target", exc_info=True)
|
||||
try:
|
||||
await _persist_pair_intent(rt, pending)
|
||||
await _converge_pair(rt, pending)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - target is no longer the recovery policy
|
||||
_LOGGER.exception("House Plan import target retry failed; restoring previous pair")
|
||||
_LOGGER.exception("House Plan target retry failed; restoring previous pair")
|
||||
try:
|
||||
await _persist_pair_intent(rt, rollback)
|
||||
await _converge_pair(rt, rollback)
|
||||
except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error
|
||||
_LOGGER.exception(
|
||||
"House Plan import rollback could not finish; rollback intent remains for setup"
|
||||
"House Plan rollback could not finish; rollback intent remains for recovery"
|
||||
)
|
||||
raise ImportFailure(
|
||||
"commit_failed", "Import failed; the previous plan is pending recovery"
|
||||
) from rollback_error
|
||||
raise ImportFailure("commit_failed", "Import failed and the previous plan was restored")
|
||||
raise PairCommitFailure(recovery_pending=True) from rollback_error
|
||||
raise PairCommitFailure(recovery_pending=False)
|
||||
|
||||
|
||||
# ---------------- portable backup / transfer ----------------
|
||||
@@ -498,8 +536,11 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
config_data = resolved.config_data
|
||||
layout_data = resolved.layout_data
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if (
|
||||
@@ -588,12 +629,24 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
await _commit_import_pair(rt, pending, rollback)
|
||||
# A token becomes single-use only after both durable halves land.
|
||||
get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)
|
||||
await _commit_pair(rt, pending, rollback)
|
||||
# Both halves are durable: the token is spent no matter what
|
||||
# happened to the preview registry meanwhile. Re-validating it
|
||||
# here (TTL lapsed during the write, eviction by a newer preview
|
||||
# of the same user) would report a failure for a plan that is
|
||||
# already replaced — the outcome follows the commit (#495).
|
||||
rt.import_previews.pop(msg["token"], None)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except PairCommitFailure as err:
|
||||
message = (
|
||||
"Import failed; the previous plan is pending recovery"
|
||||
if err.recovery_pending
|
||||
else "Import failed and the previous plan was restored"
|
||||
)
|
||||
connection.send_error(msg["id"], "commit_failed", message)
|
||||
return
|
||||
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
|
||||
_LOGGER.exception("House Plan import commit failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
|
||||
@@ -687,8 +740,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
data = await rt.store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
config_data = resolved.config_data
|
||||
data = resolved.layout_data
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" not in msg and current_rev:
|
||||
# #356: without the revision the client read, a wholesale write is
|
||||
@@ -746,7 +802,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# the marker. Its tombstone is the server-side authority: acknowledge
|
||||
# but ignore the late point so re-adding starts without a zombie
|
||||
# position.
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
config_data = resolved.config_data
|
||||
config = config_data.get("config") or {}
|
||||
markers = config.get("markers") or []
|
||||
deleted = any(
|
||||
@@ -768,14 +827,14 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
and not live_explicit
|
||||
)
|
||||
if deleted or orphan_virtual:
|
||||
data = await rt.store.async_load() or {}
|
||||
data = resolved.layout_data
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"ignored": "removed" if deleted else "missing_virtual",
|
||||
"rev": int(data.get("rev", 0)),
|
||||
})
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
data = resolved.layout_data
|
||||
layout = data.get("layout", {})
|
||||
if layout.get(msg["device_id"]) == msg["pos"]:
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
|
||||
@@ -830,7 +889,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
data = resolved.layout_data
|
||||
layout = data.get("layout") or {}
|
||||
current_rev = int(data.get("rev", 0))
|
||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||
@@ -1311,7 +1373,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
new_rev: int | None = None
|
||||
async with rt.write_lock:
|
||||
data = await rt.store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
data = resolved.layout_data
|
||||
layout = data.get("layout", {})
|
||||
if msg["device_id"] in layout:
|
||||
del layout[msg["device_id"]]
|
||||
@@ -1394,6 +1459,8 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# #423: protocol capability is independent from release skew.
|
||||
"support_api": SUPPORT_API_VERSION,
|
||||
"decor_assets_api": DECOR_ASSETS_API_VERSION,
|
||||
"summary_panel_api": SUMMARY_PANEL_API_VERSION,
|
||||
**({"radar_stage1_api": 1} if rt.radar_coordinator is not None else {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1499,6 +1566,22 @@ def _missing_internal_attachments(
|
||||
}
|
||||
|
||||
|
||||
def _readable_entity_ids(hass: HomeAssistant, connection) -> set[str]:
|
||||
"""Snapshot entities readable by this writer on the event loop."""
|
||||
user = getattr(connection, "user", None)
|
||||
readable: set[str] = set()
|
||||
for state in hass.states.async_all():
|
||||
try:
|
||||
if user and (
|
||||
getattr(user, "is_admin", False)
|
||||
or user.permissions.check_entity(state.entity_id, "read")
|
||||
):
|
||||
readable.add(state.entity_id)
|
||||
except Exception: # noqa: BLE001 - denied/unknown is unreadable
|
||||
continue
|
||||
return readable
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/set",
|
||||
@@ -1533,7 +1616,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
data = resolved.config_data
|
||||
current_rev = data.get("rev", 0)
|
||||
if "expected_rev" not in msg and current_rev:
|
||||
# #340: a request without the revision it read is indistinguishable
|
||||
@@ -1570,14 +1656,25 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# cached by rev, so a repeated write does not re-judge `previous`.
|
||||
baseline = rt.junction_baseline
|
||||
baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None
|
||||
readable_entity_ids = _readable_entity_ids(hass, connection)
|
||||
|
||||
radar_registry = radar_registry_evidence(hass)
|
||||
|
||||
def _validate_config_cpu():
|
||||
validate_wall_model_transition(msg["config"], data.get("config"))
|
||||
checked = CONFIG_SCHEMA(msg["config"])
|
||||
def _normalize(candidate):
|
||||
validate_wall_model_transition(candidate, data.get("config"))
|
||||
return CONFIG_SCHEMA(candidate)
|
||||
|
||||
checked = prepare_ordinary_summary_candidate(
|
||||
msg["config"], data.get("config"), readable_entity_ids, _normalize,
|
||||
)
|
||||
msg["config"].clear()
|
||||
msg["config"].update(checked)
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_radars(
|
||||
msg["config"], data.get("config"), registry=radar_registry,
|
||||
)
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
@@ -1595,6 +1692,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except RadarValidationError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_format", str(err))
|
||||
return
|
||||
@@ -1784,8 +1884,11 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
config_data = resolved.config_data
|
||||
layout_data = resolved.layout_data
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if (msg["expected_config_rev"] != config_rev
|
||||
@@ -1837,7 +1940,15 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
await _commit_import_pair(rt, pending, rollback)
|
||||
await _commit_pair(rt, pending, rollback)
|
||||
except PairCommitFailure as err:
|
||||
message = (
|
||||
"Space delete failed; the previous plan is pending recovery"
|
||||
if err.recovery_pending
|
||||
else "Space delete failed and the previous plan was restored"
|
||||
)
|
||||
connection.send_error(msg["id"], "commit_failed", message)
|
||||
return
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
@@ -1893,8 +2004,11 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
config_data = resolved.config_data
|
||||
layout_data = resolved.layout_data
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
@@ -1904,32 +2018,41 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
return
|
||||
|
||||
readable_entity_ids = _readable_entity_ids(hass, connection)
|
||||
|
||||
# Optimization is a normal configuration write with an additional
|
||||
# layout transaction. It must enforce the same marker-link semantics
|
||||
# as config/set; otherwise a crafted client can persist a new cycle.
|
||||
# #330 §4.1: the same executor treatment as config/set — Optimize
|
||||
# carries schema + a possible full migration, the costliest CPU path
|
||||
# of all writers, and it used to run on the event loop.
|
||||
optimize_radar_registry = radar_registry_evidence(hass)
|
||||
|
||||
def _validate_optimize_cpu():
|
||||
validate_wall_model_transition(msg["config"], config_data.get("config"))
|
||||
try:
|
||||
submitted_model = int(msg["config"].get("model_version", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
submitted_model = WALL_SEGMENT_MODEL_VERSION
|
||||
# Optimize is an explicit structural writer and therefore the
|
||||
# server-side v7 -> v8 barrier as well. Current v8 candidates are
|
||||
# validated verbatim: the backend must never invent lineage for a
|
||||
# graph already authored by a current client.
|
||||
candidate_config = CONFIG_SCHEMA(msg["config"])
|
||||
# Keep the established public validation contract ahead of the
|
||||
# structural migration barrier. A malformed passage can also make
|
||||
# wall-host materialisation impossible, but callers must still get
|
||||
# the actionable `invalid_passage_fields` code rather than the
|
||||
# generic wall-model blocker.
|
||||
validate_opening_passages(candidate_config, config_data.get("config"))
|
||||
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
|
||||
candidate_config, _ = commit_wall_segment_model(candidate_config)
|
||||
checked = CONFIG_SCHEMA(candidate_config)
|
||||
def _normalize(candidate):
|
||||
validate_wall_model_transition(candidate, config_data.get("config"))
|
||||
try:
|
||||
submitted_model = int(candidate.get("model_version", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
submitted_model = WALL_SEGMENT_MODEL_VERSION
|
||||
# Optimize is an explicit structural writer and therefore the
|
||||
# server-side v7 -> v8 barrier as well. Current v8 candidates are
|
||||
# validated verbatim: the backend must never invent lineage for a
|
||||
# graph already authored by a current client.
|
||||
candidate_config = CONFIG_SCHEMA(candidate)
|
||||
# Keep the established public validation contract ahead of the
|
||||
# structural migration barrier. A malformed passage can also make
|
||||
# wall-host materialisation impossible, but callers must still get
|
||||
# the actionable `invalid_passage_fields` code rather than the
|
||||
# generic wall-model blocker.
|
||||
validate_opening_passages(candidate_config, config_data.get("config"))
|
||||
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
|
||||
candidate_config, _ = commit_wall_segment_model(candidate_config)
|
||||
return CONFIG_SCHEMA(candidate_config)
|
||||
|
||||
checked = prepare_ordinary_summary_candidate(
|
||||
msg["config"], config_data.get("config"), readable_entity_ids, _normalize,
|
||||
)
|
||||
migrated_size = len(json.dumps(checked, separators=(",", ":")))
|
||||
if migrated_size > MAX_CONFIG_BYTES:
|
||||
return None, migrated_size
|
||||
@@ -1937,6 +2060,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
msg["config"].update(checked)
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_radars(
|
||||
msg["config"], config_data.get("config"),
|
||||
registry=optimize_radar_registry,
|
||||
)
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
@@ -1973,6 +2100,9 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except RadarValidationError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_format", str(err))
|
||||
return
|
||||
@@ -2004,36 +2134,50 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
original_metadata = _layout_metadata(layout_data)
|
||||
final_metadata = {
|
||||
key: value for key, value in original_metadata.items()
|
||||
if key not in {
|
||||
_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING,
|
||||
"repair_backup", "geom_pending",
|
||||
}
|
||||
}
|
||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||
pending = {
|
||||
"kind": "optimize",
|
||||
"config": canonicalize_config_geometry(msg["config"]),
|
||||
"layout": canonicalize_layout_geometry(msg["layout"]),
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": False,
|
||||
"final_metadata": final_metadata,
|
||||
}
|
||||
# Intent first. A setup-time finisher completes whichever half a crash
|
||||
# interrupted; until then the visible layout/revision remain unchanged.
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, layout_data.get("layout", {}), layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
msg["config"],
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
rollback = {
|
||||
"kind": "optimize_rollback",
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
try:
|
||||
await _commit_pair(rt, pending, rollback)
|
||||
except PairCommitFailure as err:
|
||||
message = (
|
||||
"Plan optimization failed; the previous plan is pending recovery"
|
||||
if err.recovery_pending
|
||||
else "Plan optimization failed and the previous plan was restored"
|
||||
)
|
||||
connection.send_error(msg["id"], "commit_failed", message)
|
||||
return
|
||||
# The optimized candidate is now the stored document: its junction
|
||||
# counts are the next write's baseline (#333 AC3, symmetric with
|
||||
# config/set — otherwise the next save re-judges `previous` for
|
||||
# nothing and the #330 cache loses its point).
|
||||
rt.junction_baseline = (int(new_config_rev), optimize_counts or {})
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, msg["layout"], new_layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"),
|
||||
)
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
@@ -2065,8 +2209,11 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
|
||||
restored_kind = "optimize"
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
config_data = resolved.config_data
|
||||
layout_data = resolved.layout_data
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
|
||||
@@ -2089,29 +2236,45 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
original_metadata = _layout_metadata(layout_data)
|
||||
final_metadata = {
|
||||
key: value for key, value in original_metadata.items()
|
||||
if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"}
|
||||
}
|
||||
pending = {
|
||||
"kind": "import_undo" if restored_kind == "import" else "optimize_undo",
|
||||
"config": restored_config,
|
||||
"layout": restored_layout,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": True,
|
||||
"final_metadata": final_metadata,
|
||||
}
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, layout_data.get("layout", {}), layout_rev,
|
||||
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
|
||||
)
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
restored_config,
|
||||
new_config_rev,
|
||||
previous_rev=config_rev,
|
||||
)
|
||||
await async_save_layout_state(
|
||||
rt, layout_data, restored_layout, new_layout_rev,
|
||||
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
|
||||
)
|
||||
rollback = {
|
||||
"kind": (
|
||||
"import_undo_rollback"
|
||||
if restored_kind == "import"
|
||||
else "optimize_undo_rollback"
|
||||
),
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
try:
|
||||
await _commit_pair(rt, pending, rollback)
|
||||
except PairCommitFailure as err:
|
||||
message = (
|
||||
"Plan undo failed; the previous plan is pending recovery"
|
||||
if err.recovery_pending
|
||||
else "Plan undo failed and the previous plan was restored"
|
||||
)
|
||||
connection.send_error(msg["id"], "commit_failed", message)
|
||||
return
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
|
||||
@@ -294,6 +294,41 @@ try {
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
// #520 diagnostics: where the extra half second of model readiness goes.
|
||||
// Counts Lit update cycles and model builds; printed, never budgeted.
|
||||
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
|
||||
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
|
||||
// what rebuilds the model a second time (#520).
|
||||
let diagEpoch = 0;
|
||||
Object.defineProperty(card, '_cfgEpoch', {
|
||||
configurable: true,
|
||||
get: () => diagEpoch,
|
||||
set: (next) => {
|
||||
if (next !== diagEpoch) {
|
||||
const frames = (new Error().stack || '').split('\n').slice(1, 4)
|
||||
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
|
||||
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
|
||||
}
|
||||
diagEpoch = next;
|
||||
},
|
||||
});
|
||||
const diagPerform = card.performUpdate.bind(card);
|
||||
card.performUpdate = function () {
|
||||
const started = performance.now();
|
||||
const result = diagPerform();
|
||||
card.__diag.updates += 1;
|
||||
card.__diag.updateMs += performance.now() - started;
|
||||
return result;
|
||||
};
|
||||
const diagBuild = card._buildModel.bind(card);
|
||||
card._buildModel = function () { card.__diag.models += 1; return diagBuild(); };
|
||||
if (typeof card._adoptStructuralResponses === 'function') {
|
||||
const legacy = card._adoptStructuralResponses.bind(card);
|
||||
card._adoptStructuralResponses = function (...args) { card.__diag.adopts += 1; return legacy(...args); };
|
||||
} else if (typeof card._adoptAuthoritative === 'function') {
|
||||
const gated = card._adoptAuthoritative.bind(card);
|
||||
card._adoptAuthoritative = function (...args) { card.__diag.adopts += 1; return gated(...args); };
|
||||
}
|
||||
card.setConfig({
|
||||
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
|
||||
});
|
||||
@@ -363,6 +398,14 @@ try {
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const bootDiag = {
|
||||
updates: card.__diag.updates,
|
||||
updateMs: Number(card.__diag.updateMs.toFixed(1)),
|
||||
models: card.__diag.models,
|
||||
adopts: card.__diag.adopts,
|
||||
cfgEpoch: card._cfgEpoch,
|
||||
epochs: card.__diag.epochs.slice(0, 8),
|
||||
};
|
||||
const initialProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && initialProjection !== 'iso')
|
||||
@@ -1096,6 +1139,7 @@ try {
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
bootDiag,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
@@ -1165,7 +1209,16 @@ try {
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
// #520: диагностика печатается в лог прогона и в запись не попадает.
|
||||
const { bootDiag, ...measured } = row;
|
||||
if (bootDiag) {
|
||||
console.log(`#520 diag sample ${row.sample}: updates=${bootDiag.updates}`
|
||||
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
|
||||
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
|
||||
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
|
||||
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
|
||||
}
|
||||
if (measuredSample >= 0) rows.push(measured);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/** Explicit #505 diagnostic evidence, not a golden-baseline producer or smoke. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { HA_DIALOG_PIN, prepareHaDialogAssets } from './helpers/ha-dialog-assets.mjs';
|
||||
import { launchHaDialogFixture } from './helpers/ha-dialog-fixture.mjs';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const options = { output: join(repo, 'artifacts/summary-panel-505') };
|
||||
for (let index = 2; index < process.argv.length; index++) {
|
||||
const arg = process.argv[index];
|
||||
if (arg === '--probe-only') options.probeOnly = true;
|
||||
else if (['--output', '--ha-cache', '--ha-wheel', '--only'].includes(arg)) {
|
||||
const value = process.argv[++index];
|
||||
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
|
||||
if (arg === '--only') options.only = value.split(',');
|
||||
else options[{ '--output': 'output', '--ha-cache': 'cacheDir', '--ha-wheel': 'wheelPath' }[arg]] = resolve(value);
|
||||
} else throw new Error(`Unknown option ${arg}`);
|
||||
}
|
||||
// Fail before the optional 124 MB download. Captures never bypass freshness.
|
||||
const fingerprint = sourceFingerprint(repo);
|
||||
if (!options.probeOnly) {
|
||||
const manifest = JSON.parse(readFileSync(join(repo, 'demo/srv/assets/houseplan-assets.json')));
|
||||
assert.equal(manifest.fingerprint, fingerprint, 'Stale demo bundle: run npm run bundle:sync before capturing');
|
||||
}
|
||||
mkdirSync(options.output, { recursive: true });
|
||||
const report = {
|
||||
issue: 505, diagnosticOnly: true, platform: process.platform,
|
||||
sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(),
|
||||
sourceFingerprint: fingerprint, probeOnly: Boolean(options.probeOnly),
|
||||
selectedCases: options.only || 'full matrix',
|
||||
haFrontend: HA_DIALOG_PIN.version, wheelSha256: HA_DIALOG_PIN.sha256,
|
||||
boundaries: 'Loopback synthetic demo only. No core/auth/HA server. Official app loader exposed at runtime; component factories/CSS unmodified.',
|
||||
comparison: 'Equivalent one block, three default system rows and one entity row. Reference row text is normalized to observed product values; reference CSS/files are unchanged. Floor-plan/fake HA chrome pixels are not parity targets.',
|
||||
captures: [],
|
||||
};
|
||||
const saveReport = () => writeFileSync(join(options.output, 'report.json'), JSON.stringify(report, null, 2));
|
||||
const assets = await prepareHaDialogAssets(options);
|
||||
|
||||
async function probe() {
|
||||
const fixture = await launchHaDialogFixture({ assets });
|
||||
try {
|
||||
const { page } = fixture;
|
||||
report.chromium = fixture.browser.version();
|
||||
await page.goto(`${fixture.url}/ha505-probe.html`);
|
||||
await page.waitForFunction(() => document.querySelector('ha-dialog')?.shadowRoot?.querySelector('wa-dialog')?.shadowRoot?.querySelector('dialog')?.matches(':modal'));
|
||||
await page.waitForTimeout(300);
|
||||
const read = () => page.evaluate(() => {
|
||||
const ha = document.querySelector('ha-dialog');
|
||||
const wa = ha.shadowRoot.querySelector('wa-dialog');
|
||||
const surface = wa.shadowRoot.querySelector('dialog');
|
||||
return { width: surface.getBoundingClientRect().width, modal: surface.matches(':modal'),
|
||||
radius: getComputedStyle(surface).borderRadius, preset: ha.width,
|
||||
inheritedMd: getComputedStyle(ha).getPropertyValue('--ha-dialog-width-md') };
|
||||
});
|
||||
const small = await read();
|
||||
await page.evaluate(async () => { const ha = document.querySelector('ha-dialog'); ha.width = 'medium'; await ha.updateComplete; });
|
||||
await page.waitForTimeout(50);
|
||||
const medium = await read();
|
||||
await page.evaluate(() => document.querySelector('#parent').style.setProperty('--ha-dialog-width-md', '920px'));
|
||||
await page.waitForTimeout(50);
|
||||
const inherited = await read();
|
||||
await page.screenshot({ path: join(options.output, 'authentic-ha-width-920.png') });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(100);
|
||||
const mobile = await read();
|
||||
await page.screenshot({ path: join(options.output, 'authentic-ha-mobile-390.png') });
|
||||
assert.equal(small.width, 320); assert.equal(medium.width, 580);
|
||||
assert.equal(inherited.width, 920); assert.equal(mobile.width, 390);
|
||||
await fixture.assertClean();
|
||||
report.probe = { small, medium, inherited, mobile, provenance: fixture.provenance,
|
||||
pageErrors: fixture.errors, externalRequests: fixture.externalRequests, websocketAttempts: fixture.websocketAttempts };
|
||||
saveReport();
|
||||
} finally { await fixture.close(); }
|
||||
}
|
||||
|
||||
const rowLabels = ['Количество устройств', 'Общая площадь комнат', 'Текущие дата и время', 'Температура кухни'];
|
||||
const productConfig = {
|
||||
version: 1, title: 'Сводная информация', show_on_mobile: true,
|
||||
blocks: [{ id: 'block-1', title: 'Общее', visible: true, scope: { type: 'all' },
|
||||
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
|
||||
source: index < 3 ? { type: 'system', key: ['device_count', 'total_area', 'datetime'][index] }
|
||||
: { type: 'entity', entity_id: 'sensor.kitchen_temperature' } })) }],
|
||||
};
|
||||
const referenceConfig = { title: productConfig.title, showOnMobile: true, blocks: [{
|
||||
...productConfig.blocks[0], scope: { type: 'all', spaceId: null },
|
||||
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
|
||||
source: index < 3 ? { type: 'system', key: ['device_count', 'room_area', 'date_time'][index], name: label }
|
||||
: { type: 'entity', entityId: 'sensor.kitchen_temperature' } })),
|
||||
}] };
|
||||
const panelCases = [
|
||||
{ name: 'desktop-light', width: 1600, height: 1000, theme: 'light' },
|
||||
{ name: 'desktop-dark', width: 1600, height: 1000, theme: 'dark' },
|
||||
{ name: 'bottom-portrait', width: 800, height: 1100, theme: 'light' },
|
||||
{ name: 'kiosk', width: 1600, height: 1000, theme: 'light', kiosk: true },
|
||||
{ name: 'mobile-view', width: 390, height: 844, theme: 'light' },
|
||||
];
|
||||
const settingCases = ['native', 'real-ha'].flatMap((shell) => [
|
||||
{ name: 'desktop', width: 1600, height: 1000 },
|
||||
{ name: 'narrow-320', width: 320, height: 844 },
|
||||
{ name: 'narrow-390', width: 390, height: 844 },
|
||||
].flatMap((size) => ['light', 'dark'].map((theme) => ({ ...size,
|
||||
name: `settings-${shell}-${size.name}-${theme}`, theme, shell, settings: true }))));
|
||||
const edgeCases = ['native', 'real-ha'].flatMap((shell) => [
|
||||
{ name: `edge-${shell}-effective-zoom-200`, width: 800, height: 500,
|
||||
adaptation: '800×500 effective viewport proxy for 1600×1000 at 200%; not actual browser zoom' },
|
||||
{ name: `edge-${shell}-text-200-long-de`, width: 1600, height: 1000, textSize: 32, language: 'de',
|
||||
adaptation: 'Root font-size 32px enlarged-text stress, long German entity name/id; not browser zoom' },
|
||||
{ name: `edge-${shell}-text-200-long-de-390`, width: 390, height: 844, textSize: 32, language: 'de',
|
||||
adaptation: '390px plus root font-size32px and long German source' },
|
||||
].map((scenario) => ({ ...scenario, shell, settings: true, theme: 'light' })));
|
||||
|
||||
function scenarioConfig(scenario, reference = false) {
|
||||
const config = structuredClone(reference ? referenceConfig : productConfig);
|
||||
if (scenario.language === 'de') {
|
||||
config.title = 'Zusammenfassung'; config.blocks[0].title = 'Allgemein';
|
||||
const row = config.blocks[0].values[3];
|
||||
row.label = 'Küchentemperatur und langfristiger Messwert';
|
||||
row.source = { type: 'entity', [reference ? 'entityId' : 'entity_id']:
|
||||
'sensor.kueche_erdgeschoss_langfristige_temperaturmessung_mit_besonders_langer_quellenkennung_505' };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function configureProduct(page, scenario) {
|
||||
await page.waitForFunction(() => window.__card?._summary && window.__card?._model?.length > 0);
|
||||
await assertFreshDemoBundle(page, repo);
|
||||
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading);
|
||||
await page.evaluate(async ({ scenario, config }) => {
|
||||
const card = window.__card;
|
||||
Object.assign(document.querySelector('#host').style, { width: '100%', height: '100vh', padding: '0', margin: '0', maxWidth: 'none' });
|
||||
Object.assign(document.body.style, { margin: '0', padding: '0' });
|
||||
card.panelHost = true;
|
||||
card.setAttribute('panel-host', '');
|
||||
Object.assign(card.style, { width: '100%', height: '100%' });
|
||||
card.setConfig({ ...card._config, language: scenario.language || 'ru', kiosk: Boolean(scenario.kiosk) });
|
||||
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, summary_panel: config } };
|
||||
card._cfgEpoch++;
|
||||
card._haSummaryPanelApi = 1;
|
||||
card._serverCanWrite = true;
|
||||
card.narrow = scenario.width <= 450;
|
||||
const dark = scenario.theme === 'dark';
|
||||
const colors = dark ? ['#3ea6ff', '#e6e7eb', '#9aa4ad', '#202126', '#3a3d45', '#11151b', '#2b2d33']
|
||||
: ['#0b73b8', '#202124', '#5f6368', '#ffffff', '#d7d9de', '#eef1f4', '#e7eaee'];
|
||||
['primary-color', 'primary-text-color', 'secondary-text-color', 'card-background-color', 'divider-color', 'primary-background-color', 'secondary-background-color']
|
||||
.forEach((key, index) => document.documentElement.style.setProperty(`--${key}`, colors[index]));
|
||||
document.documentElement.style.setProperty('--ha-card-background', colors[3]);
|
||||
document.documentElement.style.setProperty('--app-header-background-color', colors[3]);
|
||||
document.documentElement.style.setProperty('--app-header-text-color', colors[1]);
|
||||
document.documentElement.style.setProperty('--text-primary-color', '#ffffff');
|
||||
document.documentElement.style.colorScheme = scenario.theme;
|
||||
if (scenario.textSize) document.documentElement.style.fontSize = `${scenario.textSize}px`;
|
||||
document.body.style.background = colors[5];
|
||||
const entityId = config.blocks[0].values[3].source.entity_id;
|
||||
card.hass = { ...card.hass, language: scenario.language || 'ru', locale: { ...card.hass.locale, language: scenario.language || 'ru' },
|
||||
config: { ...card.hass.config, time_zone: 'Europe/Moscow', unit_system: { ...card.hass.config?.unit_system, length: 'm' } },
|
||||
themes: { ...card.hass.themes, darkMode: dark },
|
||||
states: { ...card.hass.states, [entityId]: { entity_id: entityId, state: '20.6', attributes: {
|
||||
friendly_name: scenario.language === 'de' ? 'Küchentemperatur im Erdgeschoss mit langfristiger Messwertüberwachung und besonders langer Quellenbezeichnung' : 'Kitchen temperature', unit_of_measurement: '°C' } } },
|
||||
};
|
||||
card._summary.updated();
|
||||
card._summary.saveLocal({ show: true });
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._summary.resized();
|
||||
}, { scenario, config: scenarioConfig(scenario) });
|
||||
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading && window.__card._devices.length > 0);
|
||||
if (!scenario.settings) await page.locator('.summary-overlay').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(350);
|
||||
if (scenario.settings) {
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
if (card._ensureEditorRuntime) assertRuntime(await card._ensureEditorRuntime());
|
||||
function assertRuntime(ready) { if (!ready) throw new Error('Editor runtime failed to load'); }
|
||||
await card._summary.openDialog();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await page.locator('hp-dialog[data-kind="summary"] .summary-editor').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
async function capture(scenario) {
|
||||
console.log(`Capturing ${scenario.name}`);
|
||||
const fixture = await launchHaDialogFixture({ assets, authentic: scenario.shell === 'real-ha',
|
||||
viewport: { width: scenario.width, height: scenario.height }, colorScheme: scenario.theme });
|
||||
const record = { ...scenario, files: {} };
|
||||
try {
|
||||
const { page } = fixture;
|
||||
// Let Date.now advance: the real card boot-veil quiescence uses it.
|
||||
await page.clock.setSystemTime(new Date('2026-09-09T09:00:00Z'));
|
||||
await page.goto(`${fixture.url}/product.html`);
|
||||
await configureProduct(page, scenario);
|
||||
const values = await page.evaluate((config) => config.blocks[0].values.map((value) => window.__card._summary.value(value)), scenarioConfig(scenario));
|
||||
assert.equal(values.length, 4, 'Equivalent four-row product fixture');
|
||||
record.product = await page.evaluate((settings) => {
|
||||
const root = window.__card.shadowRoot;
|
||||
const box = (node) => node ? { ...node.getBoundingClientRect().toJSON(), scrollWidth: node.scrollWidth, clientWidth: node.clientWidth } : null;
|
||||
const panel = root.querySelector('.summary-overlay');
|
||||
const hp = root.querySelector('hp-dialog[data-kind="summary"]');
|
||||
const ha = hp?.shadowRoot.querySelector('ha-dialog');
|
||||
const wa = ha?.shadowRoot.querySelector('wa-dialog');
|
||||
const surface = wa?.shadowRoot.querySelector('dialog') || hp?.shadowRoot.querySelector('.surface');
|
||||
const footer = hp?.querySelector('[slot="footer"]');
|
||||
const editor = hp?.querySelector('.summary-editor');
|
||||
const insideViewport = (node) => { const r = node?.getBoundingClientRect(); return !!r && r.left >= -1 && r.right <= innerWidth + 1 && r.top >= -1 && r.bottom <= innerHeight + 1; };
|
||||
return { panel: box(panel), control: box(root.querySelector('.summary-control')), stage: box(root.querySelector('.stage')),
|
||||
side: panel?.classList.contains('bottom') ? 'bottom' : 'right', rows: panel?.querySelectorAll('.summary-value').length,
|
||||
settings: settings ? { surface: box(surface), editor: box(editor), footer: box(footer),
|
||||
actualHA: Boolean(wa), modal: surface?.matches(':modal'), radius: getComputedStyle(surface).borderRadius,
|
||||
border: getComputedStyle(surface).border, shadow: getComputedStyle(surface).boxShadow,
|
||||
rootFont: getComputedStyle(document.documentElement).fontSize,
|
||||
titleFont: getComputedStyle(hp.shadowRoot.querySelector('.title')).fontSize,
|
||||
sourceRows: [...editor.querySelectorAll('.summary-source')].map((node) => ({ box: box(node), text: node.textContent.trim() })),
|
||||
checks: { surfaceInsideViewport: insideViewport(surface), footerVisible: insideViewport(footer),
|
||||
editorNoHorizontalOverflow: editor.scrollWidth <= editor.clientWidth + 1,
|
||||
surfaceNoHorizontalOverflow: surface.scrollWidth <= surface.clientWidth + 1,
|
||||
footerNoHorizontalOverflow: footer.scrollWidth <= footer.clientWidth + 1 },
|
||||
close: box(ha?.shadowRoot.querySelector('ha-icon-button') || hp.shadowRoot.querySelector('.close')) } : null };
|
||||
}, Boolean(scenario.settings));
|
||||
const shot = async (side, area, locator) => {
|
||||
const name = `${scenario.name}-${side}-${area}.png`;
|
||||
if (locator) await locator.screenshot({ path: join(options.output, name) });
|
||||
else await page.screenshot({ path: join(options.output, name) });
|
||||
record.files[`${side}-${area}`] = name;
|
||||
};
|
||||
await shot('product', 'viewport');
|
||||
if (scenario.settings) await shot('product', 'dialog', page.locator(scenario.shell === 'real-ha'
|
||||
? 'hp-dialog[data-kind="summary"] ha-dialog wa-dialog dialog' : 'hp-dialog[data-kind="summary"] .surface'));
|
||||
else {
|
||||
await shot('product', 'panel', page.locator('.summary-overlay'));
|
||||
await shot('product', 'control', page.locator('.summary-control'));
|
||||
}
|
||||
if (scenario.language === 'de') {
|
||||
// The genuine HA owns scrolling in its shadow .body; the native wrapper
|
||||
// owns it in the editor. Follow the composed tree instead of assuming.
|
||||
const measureSource = async (align) => page.locator('.summary-source').last().evaluate((source, align) => {
|
||||
const label = source.querySelector('strong');
|
||||
(align === 'start' ? label : source).scrollIntoView({ block: align, inline: 'nearest', behavior: 'instant' });
|
||||
const hp = source.closest('hp-dialog');
|
||||
const ha = hp.shadowRoot.querySelector('ha-dialog');
|
||||
const header = ha?.shadowRoot.querySelector('ha-dialog-header') || hp.shadowRoot.querySelector('.header');
|
||||
const footer = hp.querySelector('[slot="footer"]');
|
||||
let bandTop = Math.max(0, header?.getBoundingClientRect().bottom || 0);
|
||||
let bandBottom = Math.min(innerHeight, footer.getBoundingClientRect().top);
|
||||
const visible = (node) => { const box = node.getBoundingClientRect().toJSON();
|
||||
const visibleHeight = Math.max(0, Math.min(box.bottom, bandBottom) - Math.max(box.top, bandTop));
|
||||
return { box, visibleHeight, visibleFraction: visibleHeight / box.height }; };
|
||||
const scrollOwners = [];
|
||||
for (let node = source; node; node = node.assignedSlot || node.parentElement || node.getRootNode()?.host) {
|
||||
if (node.scrollHeight > node.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(node).overflowY)) {
|
||||
const clip = node.getBoundingClientRect();
|
||||
bandTop = Math.max(bandTop, clip.top); bandBottom = Math.min(bandBottom, clip.bottom);
|
||||
scrollOwners.push({ tag: node.localName, class: node.className, scrollTop: node.scrollTop,
|
||||
scrollHeight: node.scrollHeight, clientHeight: node.clientHeight });
|
||||
}
|
||||
}
|
||||
return { align, bandTop, bandBottom, label: visible(label), source: visible(source), scrollOwners };
|
||||
}, align);
|
||||
record.product.longSourceStart = await measureSource('start');
|
||||
assert.ok(record.product.longSourceStart.label.visibleHeight > 0, 'Long source label must actually appear between header and footer');
|
||||
await shot('product', 'long-source-scrolled');
|
||||
record.product.longSourceEnd = await measureSource('end');
|
||||
assert.ok(record.product.longSourceEnd.source.visibleHeight > 0, 'Long source end must actually be visible');
|
||||
await shot('product', 'long-source-end');
|
||||
}
|
||||
await fixture.assertClean();
|
||||
await page.addInitScript(({ config, theme, kiosk, textSize }) => {
|
||||
localStorage.setItem('houseplan-dashboard5-config', JSON.stringify(config));
|
||||
localStorage.setItem('houseplan-dashboard5-enabled', 'true');
|
||||
localStorage.setItem('houseplan-dashboard5-theme', theme);
|
||||
localStorage.setItem('houseplan-dashboard5-kiosk', String(Boolean(kiosk)));
|
||||
if (textSize) document.addEventListener('DOMContentLoaded', () => { document.documentElement.style.fontSize = `${textSize}px`; });
|
||||
}, { config: scenarioConfig(scenario, true), theme: scenario.theme, kiosk: scenario.kiosk, textSize: scenario.textSize });
|
||||
await page.goto(`${fixture.url}/reference/index.html`);
|
||||
await page.locator('#dashboard3-panel').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('.dashboard3-value dd').evaluateAll((nodes, text) => {
|
||||
if (nodes.length !== text.length) throw new Error('Equivalent reference row count mismatch');
|
||||
nodes.forEach((node, index) => { node.textContent = text[index]; });
|
||||
}, values);
|
||||
if (scenario.settings) {
|
||||
await page.locator('.dashboard3-settings-toggle').click();
|
||||
await page.locator('.dashboard3-settings-dialog__window').waitFor({ state: 'visible' });
|
||||
}
|
||||
record.reference = await page.evaluate(() => {
|
||||
const box = (selector) => document.querySelector(selector)?.getBoundingClientRect().toJSON();
|
||||
return { panel: box('#dashboard3-panel'), control: box('.dashboard5-summary-control'),
|
||||
dialog: box('.dashboard3-settings-dialog__window'), normalizedValueText: [...document.querySelectorAll('.dashboard3-value dd')].map((node) => node.textContent) };
|
||||
});
|
||||
await shot('reference', 'viewport');
|
||||
if (scenario.settings) await shot('reference', 'dialog', page.locator('.dashboard3-settings-dialog__window'));
|
||||
else {
|
||||
await shot('reference', 'panel', page.locator('#dashboard3-panel'));
|
||||
await shot('reference', 'control', page.locator('.dashboard5-summary-control'));
|
||||
}
|
||||
await fixture.assertClean();
|
||||
record.pageErrors = fixture.errors; record.externalRequests = fixture.externalRequests;
|
||||
record.websocketAttempts = fixture.websocketAttempts;
|
||||
report.captures.push(record); saveReport();
|
||||
} finally { await fixture.close(); }
|
||||
}
|
||||
|
||||
try {
|
||||
await probe();
|
||||
const cases = [...panelCases, ...settingCases, ...edgeCases];
|
||||
if (options.only) for (const name of options.only) assert.ok(cases.some((scenario) => scenario.name === name), `Unknown case ${name}`);
|
||||
if (!options.probeOnly) for (const scenario of cases.filter((scenario) => !options.only || options.only.includes(scenario.name))) await capture(scenario);
|
||||
report.status = 'complete'; saveReport();
|
||||
console.log(`Diagnostic evidence: ${join(options.output, 'report.json')}`);
|
||||
} catch (error) {
|
||||
report.status = 'failed'; report.error = error.stack || String(error); saveReport(); throw error;
|
||||
}
|
||||
@@ -43,6 +43,17 @@ radial spokes visible instead of hiding them under a translucent room fill.
|
||||
CI turns green"; the second stops an empty or clipped frame from becoming the
|
||||
contract unseen (#350).
|
||||
|
||||
## Displayed version (#512)
|
||||
|
||||
Frames never show the real `CARD_VERSION`. The harness sets the test seam
|
||||
`window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before any card is created and
|
||||
feeds the same constant wherever it plays the backend (`integration_version`,
|
||||
support facts), so the about dialog, the version-recovery banner and the support
|
||||
and backup previews print `0.0.0-golden` in every baseline. A beta bump therefore
|
||||
changes no golden frame; the version-mismatch scenarios keep their own
|
||||
`0.0.0-golden-backend` and still exercise the frontend≠backend relation. The
|
||||
product never sets the seam (`src/card-version.ts`).
|
||||
|
||||
## Workflow
|
||||
|
||||
Build and copy the exact current source first:
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,59 +1,59 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 60,
|
||||
"acceptedAt": "2026-09-07T22:36:44.292Z",
|
||||
"sourceFingerprint": "8e7650990368dac8b2651e684ac8ae9c578ba2a166396aa678bc3bf1ad9519ea",
|
||||
"matrixVersion": 61,
|
||||
"acceptedAt": "2026-09-11T20:05:33.184Z",
|
||||
"sourceFingerprint": "b3e08d50a47faaba7562dc080f267ca13de845113735892a9115a6cdd991392a",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"witnesses": {
|
||||
"count": 139,
|
||||
"count": 129,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"panel-wide-view-light-en": "5d81a6c410c4289f82b994e7b6c9af729b56ceea59bf5823e35f638f54b5bc30",
|
||||
"panel-wide-view-light-en": "2c93497871bc40f6401f6cb7e65e73bf98d0064bd2755ca2026135975bd2d9f3",
|
||||
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
|
||||
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
|
||||
"version-mismatch-desktop-dark-en": "ad08f293c728913549b0a71a1a514a60e3030d885acfb0c9951f1d9a1125a1d3",
|
||||
"version-mismatch-touch-light-ru": "bf511c2e7e8714e30a12126c0c0b7f7ea7f5885e8c8ee0c475b364f6510c8702",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
|
||||
"version-mismatch-desktop-dark-en": "b8059b768284208f605329618799d13491655e488e9d739a40b85cdc0eba5888",
|
||||
"version-mismatch-touch-light-ru": "df42d0158025371b82eabd522e0dd7d0b9121a9ba685970db3d7839d97fce50f",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "2e3eaf2fb13d1cca77abe97938374b3e25235fc7db87bb6f7a2e72f1cafe4a7e",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
|
||||
"coincident-partition-thin-dark": "36f506ed50b92df11ab36b022a98d8fd8f3526b081bd634e6a8a38269f661051",
|
||||
"coincident-partition-thick-dark": "4beacb2fdf3ab4cfdf8bb50c9a96bbee2de88d72ba7bec8924862f9a707461b1",
|
||||
"coincident-partition-virtual-dark": "f37e7b03d99afdc0c588e256053965f404f31c14ea4c53bb95db5a0f9d2d465f",
|
||||
"span-over-door-migrated-dark": "5e37e627eaecb455baa5e279d23f8c6d09f9cbdddfb58976c3b26383fce6df64",
|
||||
"split-corner-wall-before-dark": "14407cf23f6c29c3ba7161be5445acb3606a59116fdfd03e33acb87155b3518f",
|
||||
"split-corner-wall-thin-dark": "9a509f0fa7ee8503c54a508c313b47e10f8044f0a9931791d1cab83467dc3f90",
|
||||
"split-corner-wall-thick-dark": "51ac5e7594386e306f5ebc94e50d6c6d428fd6a4f26a447b28e1cbdd429afda7",
|
||||
"split-zero-divider-taper-dark": "eb43a7ba81ebd8b1f7c7f76306bb6205ea77a5cef3c21739d40ca540f0f7a1bf",
|
||||
"isometric-geometry-view-dark": "52b01bf23b31353900ea9174fdb4b9d27a586f8370fb9b00c4db6b9bbe7f73f2",
|
||||
"isometric-geometry-view-light": "6ee98e56782feaabf784eea7d927ea35bdde054d8605484f4a5e92ea93f7c82a",
|
||||
"isometric-live-layers-dark": "0d123ab0285fe3ad8487fb5263c3d8074fe2d2490091868ed648009039c04a3b",
|
||||
"isometric-no-borders-dark": "7cd9e7d9797c920e7f3564a24b1fd6143b4da94a342af3b34d5a1cd8d584f11b",
|
||||
"isometric-touch-kiosk-dark": "a6515349431d53688415e42a7711e8adf052eacf5e63a74f892fed60002c6d11",
|
||||
"isometric-large-warm-remount-dark": "91f24287e8c3148fd6470ab4abc4f856f2863c2ae7372cd90a3770ddee48f209",
|
||||
"isometric-stage3-overlays-light": "efb39c86776c507b25a8f1955f7371086cdca92e0df0ecb4c4127577fb925ba9",
|
||||
"isometric-stage3-overlays-dark": "af2af4520240ebec87d84727fccc249c5a008b0ce4403bb9c2d37aa317d8a3e8",
|
||||
"isometric-stage3-openings-dark": "a387ad33e2d238cd5f5088f9de2be8797f8b724d40599ef21eaae4ad0ff27b33",
|
||||
"isometric-stage3-forced-colors-dark": "36bd5a28a336b2be2d33640dc57d8eb3992198c812c6cfb5c20bf7daa6a611f2",
|
||||
"isometric-stage3-no-filter-dark": "277e79c005736ed81135f70517902ccea4d4300ee886e0cf05eb027e7b46d687",
|
||||
"geometry-view-dark-fit": "1ea111a2f68d71c2d2269ee65ac70b586b7d8a3a76f550bb1caf8d154bf42b26",
|
||||
"geometry-view-light-fit": "80bbb618619731dcb9dbd5e872785f32f1e52ff71ce989a2154c5f1218812eda",
|
||||
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
|
||||
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
|
||||
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
|
||||
"vacuum-trail-smoothing-dark": "b33ddbfaed72f51e601576bbf87664a08578fbce5c22c69a11d83cc2644ff312",
|
||||
"room-label-parity-view-dark": "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",
|
||||
"coincident-partition-before-dark": "32d6d7e32db1bb579b33c783833ce986d10a81589e0783c5e7fae4b8c1e2755e",
|
||||
"coincident-partition-thin-dark": "a5bd00dfd6a085b81cf5e213b34669d60edf5c03451ec2943ce31c735497cf8c",
|
||||
"coincident-partition-thick-dark": "848021570e9cdf98bbfa3e9035ac09fe70695ab6b48964f8f947e30ae5695141",
|
||||
"coincident-partition-virtual-dark": "9ec5a77dbc5f7d21225e7c51ac5acf2ad844b439532c44999e33f0d3a808d657",
|
||||
"span-over-door-migrated-dark": "5915557d0c9c5a90e78e36bf08ca30f3319efc41e067f175f44b359faef0c968",
|
||||
"split-corner-wall-before-dark": "5efad39d25a94b204302058853a70702b2afbb53a41d6f0fb55d0d7f5ca2d9af",
|
||||
"split-corner-wall-thin-dark": "0a8da611ecb6061852cc26ee53c00d12433cf8539681bd91dac3adad1d7e9e67",
|
||||
"split-corner-wall-thick-dark": "2efc65ddaa8777ebffe63205e2a961fa7053c91d3cd8c0af59810bef01ecd498",
|
||||
"split-zero-divider-taper-dark": "f7f9ee435da00dcae043c693127561a5e050675ca4d6f6f0d916ac5dbe24a594",
|
||||
"isometric-geometry-view-dark": "b061d186ffa614b97e9b0a2ba5f4b5378df54d75e168e5734d94c90289b4cfb0",
|
||||
"isometric-geometry-view-light": "585fffbb49dab8de8ca2c2af7d4d290c5355b9060029ea9bf38c72af594f2d5f",
|
||||
"isometric-live-layers-dark": "bf5dbe438ba74fada375966757620a6f8fc4641901f69000862145ca806085ed",
|
||||
"isometric-no-borders-dark": "792c221f9412825b80c56f5a195f896e3459298caf1623caebb3ad448463fe8d",
|
||||
"isometric-touch-kiosk-dark": "e609a2abba29e5441d57452b4f9d16a582f8f466f1a6f08cf81ef9f2a5391203",
|
||||
"isometric-large-warm-remount-dark": "fadfe8985736d3fdec62c971caceb34a91f9ba5698e27380e014dcd93f54a29b",
|
||||
"isometric-stage3-overlays-light": "9461cb2030234125b73beecc222a6119fdbd7f9d22b8dcec2bad6712210982bd",
|
||||
"isometric-stage3-overlays-dark": "ef589656803ebbb6436228b068df1ed99fc46c4313f15dc75940835506714f4f",
|
||||
"isometric-stage3-openings-dark": "7816adef15f9ea83a70572274baa6083875851fdeb17d9a8a77a4fe4feb92faa",
|
||||
"isometric-stage3-forced-colors-dark": "dd52ebc54962541c934b94dd7f436183ed7a65445bc90f201a2f9d2eefa93fa1",
|
||||
"isometric-stage3-no-filter-dark": "b2eeda001fa1d72c74aa0a06a996df205a7398dfc9f729406939a49a4c009a21",
|
||||
"geometry-view-dark-fit": "454da1248f1fe13b8f60c589ae9ebee5e852995e9bc5175d3c5692c445f7cb94",
|
||||
"geometry-view-light-fit": "de5f6a41552447ca909d20471ed6665f558fb6e111c1f333eeba55866517d5a5",
|
||||
"pdf-export-geometry-light": "c403a141cefb73e4653f2ffb9c346b4cfaa3de339b6c39c227ef1552ebde0048",
|
||||
"pdf-export-polish-light": "ed6685900355e20a4618e931d42d2e45a39ad44e4a2ab044fa5ee64ff56f12f4",
|
||||
"pdf-export-stepped-dimensions-light": "b9732b8b9c38153c564d0c93fbfbf44a8dd348c8667d2f8c2d14492e08e04f3e",
|
||||
"vacuum-trail-smoothing-dark": "f8eecc726a3e8bdaba414584ad6afd48908ee9d96ea969a1e37758f4e76843e2",
|
||||
"room-label-parity-view-dark": "a188d4db84533d605cb6fe822d20c34d164e3bfc8458a55beaad564854eef169",
|
||||
"room-label-parity-plan-dark": "07e896d4f6dad384bd52dc60f786f042e1cfcd26ddb1f7b12b72d40bb4824324",
|
||||
"room-label-parity-view-light": "dd96a224c4d951720031ff1ba793a1ba560951e0441cb4f61ee82215aff1d8cc",
|
||||
"room-label-parity-plan-light": "91b7aed42c3a677e387b26c405a04dcc327957f9368625c7c3ad3cca758f1fb1",
|
||||
"washer-active-cycle-dark": "0d683619c27747b3dd6a1e6c7cdc5e15dd886f4f2db4b85b7ca5a5c529e9ec52",
|
||||
"washer-idle-cycle-dark": "0138902daa8dabb0588904507ef5a44f5257f7859b4f1198191bfca3dd4130b7",
|
||||
"day-cycle-dawn-dark": "96bd5ef84345976bb83d35d418e7d247503629eceb732b6e926f2217f3b9faa4",
|
||||
"day-cycle-day-dark": "605849bbada8d3c3a02a2c88c7b21c81c675a7a89b72c4c560df635d63005e67",
|
||||
"day-cycle-dusk-dark": "d19cc97b75014d9f0661705df50d5f30a3a574661957c04c5dab96e26c5e3364",
|
||||
"day-cycle-night-dark": "39a6a959676295eb4ead02703533641f57901ad0ce1359933e1d47f48867196a",
|
||||
"geometry-plan-editor-dark": "6fa29c51ac37449e53ff76afe17760a86325e160f53040c737619aae1095599d",
|
||||
"safe-resize-handles-clamp-light": "98db89eb1ec29aa6297ac0bc10653c8efce16175833091d0e76dd033ad9f4a8e",
|
||||
"safe-resize-handles-clamp-dark": "581f85ca93197ba796e72daa67402eebb7c64f13d4722b5d33943577837c8980",
|
||||
@@ -63,27 +63,27 @@
|
||||
"plan-snap-line-gaps-dark": "e4e5e15a98e35ac26a5b0fe7ef674341fb55d8445587ccc25a81603e1ef92774",
|
||||
"wall-junctions-plan-preview-light": "f6f450768912f37b3d91730cc38f43b399eab781e585bdbe6c9bb29458b56f4c",
|
||||
"wall-junctions-plan-t-dark": "6c3748e931407079e3aad3ade77c46069ef75444c27405549d1610670a33ad95",
|
||||
"wall-junctions-view-dark": "ad23e5fb0ca9ba23f58ee4839cec489e7d05a28ef193d424b451a4020ef8ce0b",
|
||||
"wall-junctions-view-dark": "b3385dd544e87aeacbc85020621de96ea20feecccf907bc5de3d2c045860a1cd",
|
||||
"junction-patch-resilience-plan-dark": "8ad87deb245097cd090f301d259710323cf4ea577cd8edaf16607d6ad8a3d29d",
|
||||
"junction-patch-resilience-view-dark": "4ba710fd6859f958ad6c576a01d0b240a3b1723fe9df54f8cabd971414a943a6",
|
||||
"wall-union-isolation-view-light": "426ef2ada0e69febd73ab305aa99b600a95e69db68dc30dae4c5288ee1151604",
|
||||
"wall-union-isolation-view-dark": "215ed9d8cba31c5de4cb9d0e2b347bf0c96c8d1bba4d35feca57d6f66fc25322",
|
||||
"multiwall-junction-bevel-view-dark": "d64a9549ecb9bc166f99283901411f08651389ebe5ae3420c935a677c02d4b5c",
|
||||
"orthogonal-strip-cell-5-view-dark": "05cdd744d09025719eda4c5e68cf3ebc5d7d201afd3028da91262d0f4874c126",
|
||||
"orthogonal-strip-cell-1-view-dark": "9c0f91b11d47f1c5df4476774ae6028db21dc15cc6fb81d9f80009e0fa86bffe",
|
||||
"wall-key-roundtrip-view-dark": "cc6f755c03b1a586684627fc1dc27eb1aa28d2673a6c79bcb687da94a6bf9b3b",
|
||||
"isometric-wall-junctions-dark": "af692b2c01293c59cc74e5731309e6612c913ca1c8384572fd1cb67a19d0f902",
|
||||
"junction-patch-resilience-view-dark": "db72b14098dd26132521b71adc87cf5798e5d48faa1f449c294ca2df81077849",
|
||||
"wall-union-isolation-view-light": "2fffbcef2001b24b4ce7d104f0457231044be7ee0718e7f31b6fc13b024f2bc9",
|
||||
"wall-union-isolation-view-dark": "7dce51897ce87e96b7f43d62d68ac859aa662a2aa1227cc853bc86e5bbf321e4",
|
||||
"multiwall-junction-bevel-view-dark": "3f6d192bc734ecbb4cc5273c99dc3353e39207276cf9ebb8d595bbc0d8f4d8ec",
|
||||
"orthogonal-strip-cell-5-view-dark": "267a3eab1f4ad7d8d484eb2a58b3e38031b0516ca8c46c93e272bfaf9ac409b8",
|
||||
"orthogonal-strip-cell-1-view-dark": "7c9bab0789f9ec9019edf3c49176415af83e8a325e833f98a0e6324e7f06fe4c",
|
||||
"wall-key-roundtrip-view-dark": "74fd46cffefd32d805296d844c162dbe6f15b13e86c4d6c7f6974293b253e1b4",
|
||||
"isometric-wall-junctions-dark": "2249ee0effc2c96c90add934071ccab302f9233920b43496a48c8d8ecad298d1",
|
||||
"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": "e2ead596818f6182bd2478dc3823f31312d361080c99afbb9c7f7d1862cf781c",
|
||||
"opening-symbol-room-wall-light": "dc3f6513a08b809a77df6822f59fdb30c912f320e55d47d749963678fbb8f417",
|
||||
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
|
||||
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
|
||||
"isometric-opening-symbol-parity-dark": "4f745055f3de80c01d7144cdc1610be73c78a98f69506c8d6e62bc3fef509471",
|
||||
"geometry-devices-editor-dark": "e273c4fe49619a5856a9212ff6261803640e5afa79b841edfccf5a4056e0264d",
|
||||
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
|
||||
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
|
||||
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
|
||||
"device-inbox-desktop-en-light": "2556b2ca3916c016bec58767acc3eab445e32ef29ae59e8ef3580f27cdee9f21",
|
||||
"device-inbox-desktop-ru-dark": "f6da7aca353c904e37f2aae9bda9ef79b0c102926f9b9dbab5f6d9b7a14bbfb3",
|
||||
"device-inbox-narrow-ru-dark": "48336c685b2c6a25cae422912973b1e0876e38c2d2ddc92a643e6d121410af35",
|
||||
"geometry-decor-editor-dark": "2f8a3742166c10c0a5aa1239c9b6b7d908e7e578dc43229274b17d015c47503c",
|
||||
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
|
||||
"tray-wide-tool-ru": "99abefe9c5b0f21356e87b46fc6d6d615cf9b799614c14bf8fffa0261fd9059a",
|
||||
@@ -94,87 +94,89 @@
|
||||
"furniture-variants-dark": "aa15801cc56ab488e9563e05d15694db48294dd4f3d7e5bc238b4f79ec8d9b7b",
|
||||
"furniture-variants-light": "286269d08ddcc086747d0703a59cd03bac29b11b69839364c01cb8492a00602d",
|
||||
"furniture-placement-preview-light": "f983eda1bf42e27d17afb78eada8b96b192a7e91bebb0c70e6766994000aabe3",
|
||||
"furniture-plan-art-dark": "e83a8c40add2bf33af304f86ad29fc77d8d47bd7228f17c152e962f3431e0c50",
|
||||
"furniture-plan-art-dark": "777c85db2db6a50f190629b92b394fd34a31e442be5c67f2db298444d2c580eb",
|
||||
"furniture-transform-light": "3e37e03457bac87c9d7eaf6bbeb0f4054fb6e6a66a7da5953714446a92ccf296",
|
||||
"furniture-transform-dark": "15d7d719a2de7b8b142c8745f262da198a57ec92e3bdeecd39339414118cb367",
|
||||
"furniture-transform-dark": "7d988e7f7efb294ff86a3585642a09b2d2b5b5cf1d41f5d7f1d6bc9d24ff8628",
|
||||
"tray-narrow-tool-ru": "fa8f7125ea9f5c43f47ad090cf78ab970950a03dc5a9fa1c348c880ced7c0c4c",
|
||||
"geometry-diagonal-45-opening-dark": "0ddd027a7c899b5772c80555ac8de37c33e8539cc5d911ba454f562e787f6853",
|
||||
"openings-thick-wall-dark": "5dfbd731752515ece69b39d39e6b0dcf43eb5c5d8eadc1bb5f55d43f94b82992",
|
||||
"openings-filled-tunnel-dark": "362802a9b810e506b81eb4bd26b883592c0d1d5aa491823d6b81888deb96fb3f",
|
||||
"decor-over-opaque-hover-light": "23cf9d8aaa544fea547b189bac77e8e8f41521ac98bd0508f96737311857d3c0",
|
||||
"decor-over-glow-base-dark": "49f09a452295d8e281fbe3a4ed56517ac4039454fcc680bc76ead7c222095c42",
|
||||
"openings-hidden-view-dark": "bd66bea4249806cc4fb02ae8813d97e8373a12063b734d9e32a538adf086e9da",
|
||||
"lighting-glow-sun-dark": "7cc688674888ff726f5e834b37d100e065afaa6c7c1ee6b83600de93172bd8bc",
|
||||
"device-value-badge-positions-dark": "0e94d408ea27669d0b227504bf5ce5a637e19e4d16bbd8314b9b49acad32c271",
|
||||
"device-value-face-cover-source-dark": "df67d07b96fad02d62b6a7354e31c4be7c8fcf965de1709011f08d7c9d094c36",
|
||||
"device-icon-state-table-light": "588daaee4d3747aae9f026a728d2769fdbb89f7a1a4b045dd8295dbe8dfdd680",
|
||||
"device-icon-state-table-dark": "9b2e03ce937f92e63a87e215f6c3a7663c199a6f9d8f33ea438fecbd6466e18a",
|
||||
"device-text-shell-long-light": "5b254c2c9c2fc1d977e7417cfcaae146e0dab30b6ee29ae6860650e8ee27b61c",
|
||||
"device-text-shell-long-dark": "bad52d0c18e2d0255566c6a144d6267d2ccaf06739e11e9f7a3071ed2d04f889",
|
||||
"lighting-sun-window-state-only-dark": "df3d1f55b9036edd9787327e705f4218157bc9a954ac36742809aa74ffbf7e97",
|
||||
"lighting-fill-light-axis-split-dark": "62757164bb514acd4cda3a51166136d2199480a5341b75b2f7df5324276f094a",
|
||||
"lighting-fill-temp-axis-split-dark": "bf0ec890e47087f117f14509954e3a2cf1a0d093014580f6e3bce6833c00c8f4",
|
||||
"lighting-fill-lqi-axis-split-dark": "58f9d537812ff3cddafab305478e76ae1adee6368d735eb0ebb56be7ee287cc0",
|
||||
"lighting-temp-glow-dark": "9a4d24c2a2a11fd08083aba0de97fe43ff53698b8893ab13f76910315b4f1119",
|
||||
"lighting-temp-glow-light": "3fa32baec780312465ebb2237c97c486e65b19a1d93caff0e34a5a9f6c1f731e",
|
||||
"lighting-custom-glow-dark": "ac97ac02b8c2de217cf0ddb81e91ec0c3ce9f2572b79d89e10e8c3077e22b60f",
|
||||
"lighting-opaque-glow-two-doorways-dark": "9a8e3495c474019693c8e8eece4aa78a51889c66ca2ef1de882a1ecf1963282c",
|
||||
"lighting-custom-glow-light": "99700dfc1ed1f29e72977e033d54814b41aa1f36ec57fd2b0d586dadd685d3df",
|
||||
"lighting-temp-glow-no-sources-dark": "9828ecb9ecf6368b9e3b394cbcb0c87bf2185b79ad2ed2021543bd2a4ff39016",
|
||||
"lighting-temp-glow-room-override-dark": "929af750e3d6fc09942fde9cfa8234e73953c6dfd287136af3d471d7bd7ce5ac",
|
||||
"lighting-manual-auto-spill-overlap-dark": "992de7a2bc21955ce8bf79ec84506463094d5d88392e7f2dd06bdd10dced31b1",
|
||||
"hover-over-glow-dark": "32cb8ee5a15eb6a1eb65e3b89429976c553e75e628a49308911ff5aefd00e76f",
|
||||
"hover-nested-room-dark": "5733d93d50b2e8a715174c8138ddd88739db12f8ef2cbaa913501612c8ceb0b2",
|
||||
"junction-t-90-equal15-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
|
||||
"junction-t-90-bar50-leg15-dark": "8f9e9f8d73106918c2d85782ede967bb1db554a4d09ed57cb1b062497fb7d502",
|
||||
"junction-t-90-bar70-leg15-dark": "9b9110cebdb141b326c9a4863306f0026d574191e28a9642b238008cfecea38d",
|
||||
"junction-x-90-equal15-dark": "e973bbedfb3816a14ee26469187345bed73cfcaa4249d152a638ab9135e007f4",
|
||||
"junction-x-90-mixed-dark": "c06ca8cf7f54b05278769945477834fb97a1bc679ab821a6b3a2543c9dfaccae",
|
||||
"junction-x-45-alternating-dark": "2f407f8ae20f83b1fbf394a58ec75bbaf8c18136b08998e593c5b06512e1db96",
|
||||
"junction-star5-equal15-dark": "0ee20d4710260ff0dc186fa6dace9232affc9df77f1ef9debbf70066ccf2cd87",
|
||||
"junction-y-60-equal50-dark": "dd34fb276b09413e63769f17e4b9e11ad4635f5af5ebe28530ed65f27c871418",
|
||||
"junction-acute30-mixed-dark": "314d1b732259f68f06b68b0c0ea84325962e29083c136202b3d785902003300f",
|
||||
"junction-acute15-equal50-dark": "d3cd930fecd30d71b6cf2feb993de0ebe05fe8028bf8a4351bcc9d58e99f50fd",
|
||||
"junction-splay10-170-dark": "bc5b5ce280135badf598d5b2995ab40b55689284af20e429abfe7e24bf198fc7",
|
||||
"junction-t-virtual-arm-dark": "3e56730afa1b74c17a15007e46fd9d5bcd2b63643421db317a54cba6cd43ef2c",
|
||||
"junction-x-virtual-through-dark": "4f03f100ab2f25059629fe3724f5612d2a91988360c6eaa1092dacc14d4ddfe9",
|
||||
"junction-column-node-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
|
||||
"junction-draft-end-node-dark": "e9b60c1a4a62b0ac27068a5980ef17fab37eb98f6ee310f239a54d6c9b752f3f",
|
||||
"junction-309-step-dark": "ede9b8f9c75504b677f2c60a8ab5c72db9e989e86d347b685a676f6d225592a2",
|
||||
"junction-309-spike-dark": "9e4df39fa724ad63c87f49fa221c8c4f3011246c69a6aafc648e5864acb26b58",
|
||||
"junction-309-hump-dark": "b5cad52f701423f421d8561efb542e0bb1f2352570de36f6ccd1a6e9838121f8",
|
||||
"sharp-apex-legacy-dark": "8112eda510a11993bbfda5b1febbbd8bf3a8ea0786afc276b3fbe85e6963bf00",
|
||||
"junction-owner-repro-dark": "a4744295587d362ff58a57a68be202e69be67adc3d0504c9fd7c4d9e767494f8",
|
||||
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
|
||||
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
|
||||
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
|
||||
"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",
|
||||
"geometry-diagonal-45-opening-dark": "a083c9a5b39124f8f6fa8123562f1a5e24c884fa47c54c99786b24164e8bfa78",
|
||||
"openings-thick-wall-dark": "10b672a9965a994928111911760c987b45c7166c60e6bb0cbe02b2c11c59c211",
|
||||
"openings-filled-tunnel-dark": "87edc3afb2720f8b75deb02c2b2817bee9534b7908e4dab8e8fee26cc6daf99d",
|
||||
"decor-over-opaque-hover-light": "90317c690ff3dfb6fbd5cdc9345d0069571982ba8f916cc1ef677a31b439378a",
|
||||
"decor-over-glow-base-dark": "021013905dcb9e4841e76898200671822e7e06a8196d962e643b579dc4fd38df",
|
||||
"openings-hidden-view-dark": "209a6a52993a6840d85126f59f408fac68dcb9b7117e489161de96d63c834fae",
|
||||
"lighting-glow-sun-dark": "9021fa10f6606d2307f75f8024473be3e2d6e94d1c48ab149551c2bbdf129578",
|
||||
"device-value-badge-positions-dark": "f4ae3548d65e8fd0377b7c2b446065d922473c40f85537a8ce99d3219978223c",
|
||||
"device-value-face-cover-source-dark": "18aa90881fe7794c98c52142b73fb1db33bf57d390c24b6286b7b7db6312bcfa",
|
||||
"device-icon-state-table-light": "7967b87a5179837988708884418d914b446c6ebf5c61d5834f299c0662ce1eb5",
|
||||
"device-icon-state-table-dark": "4f42ac7b3e7ea5a5ee17bdf9ceebfd2520603e30662d6f9ac4d4d116951dff32",
|
||||
"device-text-shell-long-light": "ceca1de08ae34f610bc532ab7851855582bfd00b987922941a3253ea61aac44c",
|
||||
"device-text-shell-long-dark": "0a8ed12b7167744eebb59adbd0a4c81efb9259c64305e9977b059c05db497cf5",
|
||||
"lighting-sun-window-state-only-dark": "1ed25dcc1b2efa7c2622b16c16ec8b551cf67c5c12202fd483b2377dcc47ab50",
|
||||
"lighting-fill-light-axis-split-dark": "9061d4cb28884190cb116f9fdec6028af7f2a3758a171fcd3404fbc65aee3a34",
|
||||
"lighting-fill-temp-axis-split-dark": "4338d916728ab8ea44e7c7686378034cfb04d96f8bb05acfdcb452dd176edb7a",
|
||||
"lighting-fill-lqi-axis-split-dark": "8486a5b02a74ef820c70651bb4bce2ab2f00277f7c6d01dc7689fd561d111727",
|
||||
"lighting-temp-glow-dark": "f8ab48f73ae7fcb3920515adc7919e1c08132ba564869d861aeca5d67a297025",
|
||||
"lighting-temp-glow-light": "3e44c5ab7aa279dcd18a8b4a2df6f2f225d8f4f1aeacb2961519ab789894f5ae",
|
||||
"lighting-custom-glow-dark": "4b536f66660e78f40b1512c03ef05b8b41863c86cf13b79fb98f5b4fa2860af3",
|
||||
"lighting-opaque-glow-two-doorways-dark": "dd63d3e3772fbbf1e27a18d44f8e1fb8646b53f357fcced97b7988abaa472444",
|
||||
"lighting-custom-glow-light": "61270a5369921b2122075ef7fb8b4f633032a0e0ca67f53db79db060761718bf",
|
||||
"lighting-temp-glow-no-sources-dark": "362b4650a22f2cf4eae91c3f426aaf2b132ec220c1a4b0f1114fbb651156cc82",
|
||||
"lighting-temp-glow-room-override-dark": "8edd138b859669e1faf60964fd801306991cfae0f77c98d795c9929cb104e897",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6ebc10970e48f208ecda1e7b2bb86a26c90c903dade43e57c46cca720b41075d",
|
||||
"hover-over-glow-dark": "eb4126af72e86b7a0b3ea0ea11a666fef3438b37ca574882f036ea5d44e6e938",
|
||||
"hover-nested-room-dark": "b5337cb4c505f3b4983869bd7d4efd0a5be0d01e34dfa9f8619aba46b805dbef",
|
||||
"junction-t-90-equal15-dark": "5930af307f64db4105b0bcfbabb454643f5a21ce7095c3f20c629d09c20cd212",
|
||||
"junction-t-90-bar50-leg15-dark": "bc87ae653f8d249be5cec9c35d157c4420529d0dfc94e4b2716293c140828d58",
|
||||
"junction-t-90-bar70-leg15-dark": "f73508baaab86915e4f667477116ac3b1ef68040caa6aaa932ffcd0e007b20e3",
|
||||
"junction-x-90-equal15-dark": "f01ecc2b28d457fbff0581681dc8c1a1555558baf4c0ac8e2bd4bdd3637f51b4",
|
||||
"junction-x-90-mixed-dark": "1218a50ad968eae651e61637eeaed8238d0598d77749d3c9cdb7bdd7c0d6a063",
|
||||
"junction-x-45-alternating-dark": "08de6b9d3a0e6d0164efc014fb1845cdd70cccb95b05b0d55175f2bca89c378b",
|
||||
"junction-star5-equal15-dark": "4bc9337b4bd2ac4bcb8d168166330faad20184c8b3e51b153ef91d0ad76124c1",
|
||||
"junction-y-60-equal50-dark": "89994b12fd168b3e35ee47520fafa502f904391f98cec909fccac19b7b0a90e5",
|
||||
"junction-acute30-mixed-dark": "bc4f61f377711df71950ea6a8bea7d185c94b5e03bfc2e8fc4930fa9adc9ca02",
|
||||
"junction-acute15-equal50-dark": "3883df35644df7cc169cacb96a5ee8391d64878930c69a0d511cd0cca0f2a04e",
|
||||
"junction-splay10-170-dark": "4dd7a3947d11b1dcbd1b922058ac18d08bc5b19949925b8e19373d71429d81f3",
|
||||
"junction-t-virtual-arm-dark": "e0accff2bb3c39354c9aa139921f31fa08d311f57dcc2d6e9bacdbba3b45e671",
|
||||
"junction-x-virtual-through-dark": "a7ace42d135f24e49a504072fdc2bc360c2a23f7e95e4c47b1d9df0727af32d5",
|
||||
"junction-column-node-dark": "5930af307f64db4105b0bcfbabb454643f5a21ce7095c3f20c629d09c20cd212",
|
||||
"junction-draft-end-node-dark": "7652ec1a96a399b4985c256c62369d325894024710b47e0f1271a4a43a82467b",
|
||||
"junction-309-step-dark": "86c837457d3774c8d16a9ad673156e5d758e8805c321d8d6366a0656febab35e",
|
||||
"junction-309-spike-dark": "17b80d7b8f976e01d7ce4b366c2ef57f1b6e88fc4c23e915667e444a7fc9b164",
|
||||
"junction-309-hump-dark": "2402ad1af472be47c2834452047e8f7ca703814990f5c1d59c1462c50905ce91",
|
||||
"sharp-apex-legacy-dark": "a7d3694789f0c981c33f600b1773fc55c49289b03fdf1d3d4ebcd79c6f4c6f1e",
|
||||
"junction-owner-repro-dark": "4d8dd271c2ae6ca6dbdd00e1d0917209b693c94f3c48b2d91de752d541c18cec",
|
||||
"large-house-zoom-040-dark": "84f9e93b5fd25caaf031dc7758553c29459d4dda31e086b7d79e904c9b3d6230",
|
||||
"large-house-zoom-250-dark": "fa901d097023320a599c1c80be9f9c65a0ce6245bb7182bc0f3d90d679668c17",
|
||||
"large-house-warm-remount-dark": "6ed51a1c76cd7c62875eadafc169f7c6fc25604e64edabd9594a3664c886987d",
|
||||
"device-dialog-desktop-en": "b748923af66c4bc69785189f3f394961e4b7afb6528a538f0be845bb1261a3fd",
|
||||
"device-dialog-mobile-ru": "3a27e161cd2a2b791c38e74c24606d2a8deb47c45d8dd64aa14a0311b05b1dd3",
|
||||
"device-dialog-desktop-de": "385f8f36de1112862c8b9b10a627155a2c6c9b24f76aea45eb2a79e00397a07c",
|
||||
"room-temperature-dialog-desktop-en": "f047924f25f7b05edf76e3acdd8e76af5b14928fd9273fa0643cda815af9fe7b",
|
||||
"room-temperature-dialog-mobile-ru": "722ad71b8ebc482a3c11a088baa54aefdfa36f5d61aaf7e8c2551cdb7bddb3b9",
|
||||
"german-view-mobile-light": "8c079d1574a6fbec478eacc872a46800f4348a9ce6d4dc1b1a11f88df8688e19",
|
||||
"toggle-entity-dialog-desktop-en": "799c95295a5f321fb512f30f6787c3057c059cd790337f46985736a16f4f0943",
|
||||
"toggle-entity-dialog-mobile-ru": "d293db2db8773d72c78936c0c82a57791811d8c2bfc9cb3859743d5e20d5ab35",
|
||||
"device-help-popover-light-ru": "dac475aa03f90a084ff558c8a187e2bd8c7ca01743bc401a511efb665ad8e815",
|
||||
"decor-color-popover-mobile-ru": "de167d5a363758b26108f08f7e3f0ba7a44c8daf7461d1e54d719698d20ab208",
|
||||
"decor-color-popover-desktop-en": "ce6e85df6417dc8912ff7119c9851a7e15c4f28ed4097aa21e973e9da8d1ca0e",
|
||||
"general-color-popover-desktop-en": "8dd1aac5beb37d942325c566dd1fedd6303a74d97882202cca92516fc4bd7982",
|
||||
"settings-help-zoom-200-en-light": "d4c0ca1d0a6807707bc5f3366f4da8d8bba13297d09339d36917fd77f9fbe923",
|
||||
"settings-help-zoom-200-ru-dark": "5128090ce7ed3196b3cf81e95634c11c9a42160959cac682792920353f8405c7",
|
||||
"support-desktop-empty-light-en": "ec60104cb98b646db22089c93f70f78c3a13cdb7001782b2981ce19800ce3cee",
|
||||
"support-desktop-preview-dark-en": "37707b251c117eb381f7ddd6ad13a235be82c139a4d92dc07789a76cc425e946",
|
||||
"support-phone-validation-light-ru": "1699dde634aa583ce6aaa30252f6cc01c9d822ff2718d0d77b179c7c38216ea2",
|
||||
"support-phone-success-dark-en": "21146b3a221010cdc45e1a83c299395772a9f214550a296b78c5ecb72ddab332",
|
||||
"support-relay-error-light-en": "e63e4e3aec111aa8044f6c99a8d3331801505a0024399293d8d5d53776346b76",
|
||||
"support-tablet-preview-dark-ru": "969398341e722f54d56a14084d18c4943f644a98b7772222ee7b601d46ddd866",
|
||||
"device-ripple-color-popover-mobile-ru": "efd4b19a2a516f46998a0824e0ab018f9d6d146a2f351c99eb8eb781a4bb3a6a",
|
||||
"space-room-color-popover-desktop-ru": "2576975a4959e04a52dbf1887f55593d3f34e47872f3ea0cf4562e29703783fc",
|
||||
"backup-full-preview-desktop-en": "935adb062c28385a89d34716616406e2434d8cdf3daf9cb7abead13df3c6cb81",
|
||||
"backup-plan-only-export-desktop-en": "b017b78e75968123011777c27550bf82da6e50267086339b963d8571e8dca679",
|
||||
"backup-space-preview-mobile-ru": "5ee8c2651d9814be9d7c76b7692fcf65f4ce341fb4cde1fc3e854df4568994dc",
|
||||
"optimize-preflight-dialog-dark-en": "be8c9ddaf4eb07d83594d836ea20f5fb0c142d3c0f53a5a5b32e69814df27ae8",
|
||||
"optimize-preflight-dialog-light-ru": "75a42a6dfb8134d7030466a730bd32ed08b7f50a72d2e9e081f9a3d853898856",
|
||||
"optimize-orphan-references-dark-en": "d8a33ed94935d3b09fd9317729dc014c38c9baf4ef425947365d8aab186c45c2",
|
||||
"optimize-orphan-references-light-ru": "d68e22a3cd9b2f047dc017217593a9e1a9df79fb9014070890d4f2df52f38cc2",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 415 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |