Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce07db748e | ||
|
|
5ff97858f8 | ||
|
|
855b62729d | ||
|
|
0a955f5df5 | ||
|
|
198484f762 | ||
|
|
2558d68f06 | ||
|
|
7ab12294fc | ||
|
|
23c65e449e | ||
|
|
1856302cd3 | ||
|
|
523bd4569d | ||
|
|
f6956fa472 | ||
|
|
fc0c616698 | ||
|
|
daccaf7ea5 | ||
|
|
12e5728a17 | ||
|
|
65d38b2403 | ||
|
|
21a1993015 | ||
|
|
dc032dcb3e | ||
|
|
152a6ca5cc | ||
|
|
fcf0ce80eb | ||
|
|
a4fe939f63 | ||
|
|
248c926a17 | ||
|
|
0f5c3d8967 | ||
|
|
ea2ec19b71 | ||
|
|
282e52cc73 | ||
|
|
46d43a29be | ||
|
|
0466a02dfd | ||
|
|
69207f5757 | ||
|
|
6b77d598fd | ||
|
|
fcc4125c6e | ||
|
|
41e6eef941 | ||
|
|
13ab4b12b7 | ||
|
|
34d53bf2d1 | ||
|
|
04a1de091c | ||
|
|
9bdf531c1a | ||
|
|
1ef21037a8 | ||
|
|
16b4c2d6d3 | ||
|
|
9ee60993ff | ||
|
|
dfb4efaa1c | ||
|
|
5d09e722e4 | ||
|
|
5763c6aec0 | ||
|
|
570da3dd1a | ||
|
|
ca2a00231b | ||
|
|
833c9a2a33 | ||
|
|
50bc1d6955 | ||
|
|
ba47395337 | ||
|
|
33f16dd3cc | ||
|
|
ef5bfa4bcd | ||
|
|
b4898583d7 | ||
|
|
7320937f62 | ||
|
|
d80805acec | ||
|
|
896ceecf3a | ||
|
|
7cb1eee386 | ||
|
|
da22b87fe1 | ||
|
|
41b8e71248 | ||
|
|
58b1651570 | ||
|
|
793d06c75e | ||
|
|
d13ecf1237 | ||
|
|
a63aef0239 | ||
|
|
b04e4aacce | ||
|
|
c81d1d9775 | ||
|
|
6af6d7b388 | ||
|
|
7dd5b4798a | ||
|
|
5108202419 | ||
|
|
2c7879131f | ||
|
|
6dd9fb03c3 | ||
|
|
b286d7ba43 | ||
|
|
fe0c2e0c83 | ||
|
|
aac9232bbc | ||
|
|
de30066be7 | ||
|
|
6ef0804bc4 | ||
|
|
356b260035 | ||
|
|
3d0fe7d756 | ||
|
|
5adada070f | ||
|
|
5bd0c4a033 | ||
|
|
588f1188c3 | ||
|
|
17bfdaaed0 | ||
|
|
07ba2ffbd1 | ||
|
|
5692a2880f | ||
|
|
d7e97687ce | ||
|
|
444562e47c | ||
|
|
046efe9613 | ||
|
|
d513f13afa | ||
|
|
a7bee0f5fb | ||
|
|
4b6b4abc70 | ||
|
|
8d787530b1 | ||
|
|
fe03eae213 | ||
|
|
cb68492c99 | ||
|
|
f8b3445dc3 | ||
|
|
1850eb18b5 | ||
|
|
5254fb9ef5 | ||
|
|
67c000f17b | ||
|
|
8ef0b6a1fa | ||
|
|
5d594e70c8 | ||
|
|
c0d61ca3d2 | ||
|
|
137e160f1a | ||
|
|
66c2eec1f9 | ||
|
|
338700bdcb | ||
|
|
029aba6b88 | ||
|
|
0cd5145df0 | ||
|
|
5784a0e271 | ||
|
|
2a883217c1 | ||
|
|
d5e114524c | ||
|
|
9e7ae520a8 | ||
|
|
4b443d8cf5 | ||
|
|
dc76c5b2b8 | ||
|
|
8d8263202f | ||
|
|
cc9387163e | ||
|
|
f70a973834 | ||
|
|
36b4e1cf31 | ||
|
|
5016aa01cc | ||
|
|
28a4cb5eec | ||
|
|
9e56842bc1 | ||
|
|
1d8edf4a01 | ||
|
|
aa51ed2bf5 | ||
|
|
a158d75712 | ||
|
|
05ef318133 | ||
|
|
b39f99b3a4 | ||
|
|
85a064430b | ||
|
|
070c276e26 | ||
|
|
1a7488d695 | ||
|
|
1ce62613ed | ||
|
|
b8a31e90ca | ||
|
|
5da19f1793 | ||
|
|
a8ccb2b90f | ||
|
|
8935a736cb | ||
|
|
411a0a20a9 | ||
|
|
89647c10bd | ||
|
|
ed4ac972b3 | ||
|
|
ee9b28ed98 | ||
|
|
658f284b44 | ||
|
|
169fed01f0 | ||
|
|
7230e4248a | ||
|
|
0ec0785dfd | ||
|
|
e846833ed6 | ||
|
|
ed0fdd4859 | ||
|
|
112ec0eb66 | ||
|
|
775e55f0ed | ||
|
|
3f9ef807d8 | ||
|
|
f0ed526a59 | ||
|
|
4203a01b04 | ||
|
|
aab5c3ca95 | ||
|
|
a2867df9a2 | ||
|
|
2202b20730 | ||
|
|
85702afc0c | ||
|
|
e5587aed7c | ||
|
|
aed0b5b65a | ||
|
|
c53702cdfa | ||
|
|
a6a3df9310 | ||
|
|
ec9824f227 | ||
|
|
260767e2f8 | ||
|
|
3a1f2b2d86 | ||
|
|
6eea4d1c7a | ||
|
|
a54addb694 | ||
|
|
6a7ef48ae8 | ||
|
|
6190234c51 | ||
|
|
39ba4e2b88 | ||
|
|
15cfa96553 | ||
|
|
4aad9facdf | ||
|
|
81026539d3 | ||
|
|
f672de674f | ||
|
|
68992d5582 | ||
|
|
f29c4c5b79 | ||
|
|
d5bfa0e3e1 | ||
|
|
f62dd6b632 | ||
|
|
6b07b70d7c | ||
|
|
62ecb8566e | ||
|
|
818ad2d2bb | ||
|
|
6c668d4bfe | ||
|
|
a1fbea07d2 | ||
|
|
effb36295d | ||
|
|
7c4cb187fe | ||
|
|
5a7c98264a | ||
|
|
a0f0a52410 | ||
|
|
3c40580396 | ||
|
|
e30c73d5c7 | ||
|
|
081cdbc6f3 | ||
|
|
04e3f5ee43 | ||
|
|
317df72fc8 | ||
|
|
a6d0c0b980 | ||
|
|
eb5e15fb6f | ||
|
|
f6879b3788 | ||
|
|
560ca214b0 | ||
|
|
4c485ed647 | ||
|
|
582066d017 | ||
|
|
3a88f3ad6c | ||
|
|
215d59823b | ||
|
|
4bd30423f2 | ||
|
|
f6cd99cef4 | ||
|
|
2695307ef0 | ||
|
|
b7b805f2af | ||
|
|
1e5f33aa64 | ||
|
|
cebd20a9b5 | ||
|
|
83201da15d | ||
|
|
0d8cfffb92 | ||
|
|
32417d3ae7 | ||
|
|
a488e89889 | ||
|
|
a33203e7a1 | ||
|
|
75478905d5 | ||
|
|
0ea9c0f576 | ||
|
|
fa41d2981c | ||
|
|
79e922203a | ||
|
|
ecf2cc9222 | ||
|
|
53f91d974a | ||
|
|
49f69f60db | ||
|
|
4b8cf6e04d | ||
|
|
fe18f80fc2 | ||
|
|
8e2892b95f | ||
|
|
4032b810b7 | ||
|
|
c9c22916be | ||
|
|
26b0b58320 | ||
|
|
9899d628ac | ||
|
|
9809ec7a04 | ||
|
|
51b4cc7e2c | ||
|
|
567b456890 | ||
|
|
ca6ebc3035 | ||
|
|
fec99b907b | ||
|
|
c38501ef50 | ||
|
|
9126e5b430 | ||
|
|
a48bde0b1d | ||
|
|
4c56b389ca | ||
|
|
dd2b5d743c | ||
|
|
086b3682e9 | ||
|
|
3fafbb392d | ||
|
|
5409f0b2e3 | ||
|
|
c958e2431f | ||
|
|
53847e3997 | ||
|
|
0f513ac88b | ||
|
|
735710f16b | ||
|
|
173707d82d | ||
|
|
cc546ea66d | ||
|
|
d5ad0371aa | ||
|
|
5f90eaf4ef | ||
|
|
d4dd027b0a | ||
|
|
a9fc2321a9 | ||
|
|
694db13760 | ||
|
|
ea01e3a683 | ||
|
|
44a38b3378 | ||
|
|
ff42d650ca | ||
|
|
3a6ceeea54 | ||
|
|
a265830e3b | ||
|
|
294a70b6be | ||
|
|
cadcbcc5f1 | ||
|
|
b3bcd3df8f | ||
|
|
e56f0f297b | ||
|
|
590d178e29 | ||
|
|
fe8970ee84 | ||
|
|
dc95563d96 | ||
|
|
09accce808 | ||
|
|
25f750378b | ||
|
|
52333c73d0 | ||
|
|
d4ecace64c | ||
|
|
f399ad547b | ||
|
|
84b5f63cbb | ||
|
|
3df0f1646b | ||
|
|
4eae3398a6 | ||
|
|
e438c25ebe | ||
|
|
a1e1748b2d | ||
|
|
0f5161164f | ||
|
|
74ad675a24 | ||
|
|
653b9a7632 | ||
|
|
d59ceee905 | ||
|
|
8ed8ecc3fd | ||
|
|
70805c9c6b | ||
|
|
9811674195 | ||
|
|
921d0f4c0d | ||
|
|
8de9ea008a | ||
|
|
09c410000a | ||
|
|
cfa5c7790f | ||
|
|
3576cc2ea7 | ||
|
|
4a7de3370a | ||
|
|
2038ab91b9 | ||
|
|
766bf33cec | ||
|
|
7102994dc6 | ||
|
|
c730b288ab | ||
|
|
f4b425f3c0 | ||
|
|
03cc606396 | ||
|
|
fac1f8840e | ||
|
|
7f9b919888 | ||
|
|
c409ec6a61 | ||
|
|
340c770b92 | ||
|
|
c5d9775c7a | ||
|
|
8215b61f4d | ||
|
|
567a45ab6b | ||
|
|
489e289f79 | ||
|
|
ce3e92b22b | ||
|
|
153dc3f794 | ||
|
|
1e479154f7 | ||
|
|
a3fb360ae1 | ||
|
|
080eb5ceff | ||
|
|
2b978d63ef | ||
|
|
e3073f6840 | ||
|
|
b71bc39b34 | ||
|
|
ad4cce0dda | ||
|
|
59ea93b4ba | ||
|
|
e46220f3eb |
@@ -77,8 +77,17 @@ jobs:
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: Capture
|
||||
run: node demo/docs/capture.mjs
|
||||
# Съёмка идёт дважды и сравнивается по хешам (#422). Прежний замер
|
||||
# (`--stability`) отвечает на вопрос «плавает ли кадр от времени внутри
|
||||
# страницы» и остаётся ниже; дефект #410 был по другой оси — обрезка
|
||||
# плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы
|
||||
# всегда. Проверка, объявленная гарантией воспроизводимости, на
|
||||
# собственном инциденте промолчала бы.
|
||||
#
|
||||
# Второй прогон заодно оставляет в `docs/images` кадры, которые и уедут в
|
||||
# артефакт: сравнивать хеши и публиковать разные файлы было бы странно.
|
||||
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
|
||||
run: node scripts/capture-determinism.mjs
|
||||
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
|
||||
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
|
||||
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
|
||||
@@ -94,8 +103,9 @@ jobs:
|
||||
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
|
||||
# если кадр снова начнёт зависеть от времени.
|
||||
#
|
||||
# Проверка стоит ПЕРЕД съёмкой набора: смысла публиковать артефакт,
|
||||
# снятый недетерминированной съёмкой, нет.
|
||||
# Шаг стоит ПОСЛЕ съёмки и до вердикта: публикацию артефакта он всё равно
|
||||
# блокирует падением job'а, а снимать набор третий раз ради порядка строк
|
||||
# в логе незачем.
|
||||
- name: "Кадр не плавает внутри одного состояния (#410)"
|
||||
run: node demo/docs/capture.mjs --stability=3
|
||||
- name: Хеши кадров
|
||||
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
- large-house
|
||||
- isometric
|
||||
- plan-snap
|
||||
- interaction
|
||||
- blend
|
||||
- overlay
|
||||
- space-default
|
||||
@@ -190,6 +191,10 @@ jobs:
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
|
||||
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:large-house-interaction -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/interaction-baseline.json
|
||||
npm run benchmark:large-house-interaction -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/interaction-candidate.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
@@ -235,6 +240,9 @@ jobs:
|
||||
plan-snap)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
|
||||
;;
|
||||
interaction)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-interaction.json --baseline=../artifacts/performance/interaction-baseline.json --candidate=../artifacts/performance/interaction-candidate.json --output=../artifacts/performance/interaction-comparison.json
|
||||
;;
|
||||
blend)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
;;
|
||||
|
||||
@@ -43,9 +43,19 @@ jobs:
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
# Мелкий checkout: guard остаётся лёгким, но ему нужен
|
||||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||||
# inline-shell не покрывается тестами (#454). Node на раннере
|
||||
# предустановлен, setup-node не нужен.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
- id: decide
|
||||
env:
|
||||
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') }}
|
||||
@@ -87,19 +97,78 @@ jobs:
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
# имени документа — раньше по подстроке маркера в теле комментария,
|
||||
# теперь по имени документа ЭТОЙ задачи, `<MARKER>-<NUM>`: голая
|
||||
# подстрока протекала на прозе. #454 поймала это на себе — разбор
|
||||
# чужих задач в комментарии содержал `CODE-REVIEW`, и первый же
|
||||
# код-ревью получил заход r3.
|
||||
#
|
||||
# Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ
|
||||
# ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария,
|
||||
# только если ревьюер сам назвал имя файла, — то есть прежний счёт
|
||||
# зависел от формулировки. На #449 первый спек-вердикт файла не
|
||||
# назвал, заход r2 получил номер r1, и документ второго раунда лёг
|
||||
# ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна
|
||||
# ровно здесь: номер захода входит в имя файла, и повтор номера — не
|
||||
# лишний заход, а потеря артефакта.
|
||||
#
|
||||
# Поэтому рядом встаёт второй источник — опубликованные документы:
|
||||
# их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся
|
||||
# МАКСИМУМ двух источников: недосчёт возможен только при отказе
|
||||
# обоих, перерасчёт невозможен по построению.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
if [ -n "$stage" ]; then
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
comments=$(mktemp)
|
||||
gh issue view "$NUM" --repo "$REPO" --json comments > "$comments"
|
||||
|
||||
# Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту.
|
||||
# Её нет у задач, размеченных до появления конвейера; тогда счёт по
|
||||
# файлам даёт ноль и работает страховка по комментариям.
|
||||
branch=""; newest=""
|
||||
for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \
|
||||
--jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do
|
||||
date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true)
|
||||
if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then
|
||||
newest="$date"; branch="$ref"
|
||||
fi
|
||||
done
|
||||
target="${branch:-dev}"
|
||||
names=$(mktemp); docs=$(mktemp -d)
|
||||
if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \
|
||||
> "$names" 2>/dev/null; then
|
||||
: > "$names"
|
||||
echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён"
|
||||
fi
|
||||
# Каталог перечисляется одним ответом до 1000 записей; за этой
|
||||
# границей ответ молча обрежется, и счёт по файлам занизится.
|
||||
if [ "$(grep -c . "$names")" -ge 1000 ]; then
|
||||
echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен"
|
||||
fi
|
||||
# Тела нужны только своим документам этапа: их единицы.
|
||||
for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do
|
||||
gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \
|
||||
-H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name"
|
||||
done
|
||||
list=$(mktemp)
|
||||
counters=$(node scripts/review-doc-guard.mjs --counters \
|
||||
--marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \
|
||||
--comments="$comments" --spent-list="$list")
|
||||
spent_list=$(cat "$list")
|
||||
# Пустой ответ означает, что скрипт не отработал. Тогда остаются
|
||||
# значения по умолчанию (заход 1, циклов 0): guard обязан
|
||||
# продолжить работу, а не встать.
|
||||
new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p')
|
||||
new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p')
|
||||
new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p')
|
||||
case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac
|
||||
case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac
|
||||
# Перечень учтённого обязан сходиться с числом: если цикл виден
|
||||
# только документом, ссылка на комментарий его не объяснит.
|
||||
if [ -n "$new_blocking" ]; then
|
||||
spent_list="$spent_list
|
||||
- документы: $new_blocking"
|
||||
fi
|
||||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
|
||||
@@ -489,6 +489,7 @@ jobs:
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||||
@@ -556,10 +557,14 @@ jobs:
|
||||
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
|
||||
ran=$((ran + 1))
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
if timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
else
|
||||
status=$?
|
||||
echo "FAIL $name"
|
||||
if [ "$status" -eq 124 ]; then
|
||||
echo "diagnostic smoke-timeout: $name exceeded 180s"
|
||||
fi
|
||||
# Диагностические строки смока печатаются раньше вердикта, и
|
||||
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
|
||||
# разошлись кадры (введено в #302), а в логе прогона осталось
|
||||
@@ -756,6 +761,8 @@ jobs:
|
||||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||||
- name: Бюджеты ограничений стыков (#330)
|
||||
run: npm run benchmark:junction-limits
|
||||
- name: Быстрый commit точки стены (#461)
|
||||
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -344,9 +344,27 @@ whole review round that a local run would have caught immediately.
|
||||
|
||||
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) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
the beta gate is CI at the exact SHA.
|
||||
|
||||
**Verifying and capturing are different things (#455).** `golden:verify` is
|
||||
advisory and legal anywhere, Windows included: it reports differences and
|
||||
accepts nothing. **Capturing** frames is refused outside Linux
|
||||
before the browser even starts — `golden:capture` through
|
||||
`demo/golden/policy.mjs`, documentation screenshots through
|
||||
`npm run docs:capture` (use that script, not a bare `node
|
||||
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
|
||||
capture script invalidates the committed screenshot index). The refusal prints
|
||||
the WSL command. The reason is not policy but physics: Windows
|
||||
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
|
||||
so no frame ever matches an accepted baseline byte for byte, no environment
|
||||
witness can exist, and acceptance would refuse anyway (#401 accepts any
|
||||
environment that proves itself with byte-identical undeclared frames — Linux is
|
||||
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.
|
||||
|
||||
**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.
|
||||
@@ -399,21 +417,20 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
|
||||
reproduces against the pristine committed bundle, so treat it as
|
||||
pre-existing/pixel-precision, not a regression you introduced.
|
||||
|
||||
## Labs flags
|
||||
## Alpha experiments
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
|
||||
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
|
||||
YAML/config switch for a Labs-only experiment. A new entry needs a unique
|
||||
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
|
||||
unit/browser coverage. Invalid or duplicate registry entries fail closed.
|
||||
experiments. One browser-local switch controls the complete set known to the
|
||||
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
|
||||
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
|
||||
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
|
||||
`houseplan_card_labs_v1` storage are ignored and never migrated.
|
||||
|
||||
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
|
||||
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
|
||||
experiment or graduate it through its own reviewed issue; never extend expiry as
|
||||
an incidental change. Labs may alter presentation only and must not gate data,
|
||||
migrations, stores, HA actions or network calls. Current renderer details are in
|
||||
`docs/ISOMETRIC.md`.
|
||||
A new registry entry needs a unique lowercase id, issue, summary and
|
||||
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
|
||||
entries fail closed. Alpha capabilities may alter presentation only and must not
|
||||
gate data, migrations, stores, HA actions or network calls. Current renderer
|
||||
details are in `docs/ISOMETRIC.md`.
|
||||
|
||||
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
|
||||
page directly in a browser renders the floor plan but **device icons only appear
|
||||
|
||||
@@ -193,6 +193,35 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
|
||||
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
|
||||
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
|
||||
команда или имя теста) **· чем краснеет** — мутация, снятая защита или
|
||||
отрицательная проба, с результатом прогона. Пустой третий столбец — находка
|
||||
Medium, а не примечание.
|
||||
|
||||
«Тест умеет падать» без названной мутации и её вывода доказательством не
|
||||
является. Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест
|
||||
оставался зелёным на снятой защите; все пять прошли код-ревью как доказанные,
|
||||
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
|
||||
то, чего они не проверяли (#430).
|
||||
|
||||
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
|
||||
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
|
||||
Считаются **защитные AC без названного свидетеля**, а не мутанты на
|
||||
подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов
|
||||
принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся
|
||||
тест, спрашивавший регулярку, находит ли она подстроку, которую сам же и
|
||||
вырезал.
|
||||
|
||||
Правило не распространяется на AC, не заявляющие защиту (расположение, текст,
|
||||
формат вывода): там свидетель — обычное сравнение ожидаемого с фактическим, и
|
||||
третий столбец превратился бы в ритуал. И не отменяет «проверено чтением»:
|
||||
тогда во втором столбце стоит «чтением», а не имя теста, и читатель ревью
|
||||
видит разницу.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
|
||||
@@ -94,17 +94,46 @@ House Plan is in the HACS default catalog — no custom repository needed.
|
||||
2. Restart Home Assistant.
|
||||
3. Open **Settings → Devices & services → Add integration → House Plan**.
|
||||
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
The card is registered automatically. After installing or updating House Plan,
|
||||
restart Home Assistant and fully reload the page: `Ctrl+F5` on Windows/Linux or
|
||||
`Cmd+Shift+R` on macOS.
|
||||
|
||||
#### Storage mode (Home Assistant default)
|
||||
|
||||
No YAML is normally needed. If automatic registration did not make the card
|
||||
available, open **Settings → Dashboards → menu ⋮ → Resources → Add
|
||||
resource**, enter `/houseplan_files/houseplan-card.js`, and select **JavaScript
|
||||
module**.
|
||||
|
||||
#### YAML resources mode (Home Assistant 2026.2+)
|
||||
|
||||
To manage resources in `configuration.yaml` independently of the dashboard
|
||||
mode, use:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
#### Legacy Home Assistant 2024.6–2026.1
|
||||
|
||||
Only for a full-YAML dashboard that is already managed in YAML, use:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` changes the dashboard itself to YAML mode. Do not switch a storage
|
||||
dashboard to legacy YAML just for House Plan; use the Storage mode instructions
|
||||
above instead. Do not use the on-disk path inside `custom_components`; Home
|
||||
Assistant does not serve that path as a JavaScript module.
|
||||
|
||||
### Manual installation
|
||||
|
||||
|
||||
@@ -98,17 +98,46 @@ House Plan входит в основной каталог HACS — пользо
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
Карточка регистрируется автоматически. После установки или обновления
|
||||
House Plan перезапустите Home Assistant и полностью перезагрузите
|
||||
страницу: `Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS.
|
||||
|
||||
#### Режим Storage (по умолчанию в Home Assistant)
|
||||
|
||||
Обычно YAML не нужен. Если авторегистрация не сделала карточку доступной,
|
||||
откройте **Настройки → Панели управления → меню ⋮ → Ресурсы → Добавить
|
||||
ресурс**, укажите `/houseplan_files/houseplan-card.js` и выберите тип
|
||||
**JavaScript-модуль**.
|
||||
|
||||
#### YAML-ресурсы в Home Assistant 2026.2+
|
||||
|
||||
Чтобы управлять ресурсами в `configuration.yaml` независимо от режима
|
||||
самой панели, используйте:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
lovelace:
|
||||
resource_mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
#### Home Assistant 2024.6–2026.1: устаревший режим
|
||||
|
||||
Только для панели, которая уже полностью управляется через YAML:
|
||||
|
||||
```yaml
|
||||
lovelace:
|
||||
mode: yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
`mode: yaml` переводит в YAML-режим саму панель. Не переключайте
|
||||
storage-панель в устаревший YAML только ради House Plan; используйте
|
||||
инструкцию для Storage выше. Не используйте путь к файлу внутри
|
||||
`custom_components`: Home Assistant не публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
|
||||
@@ -6,18 +6,20 @@ import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
FRONTEND_URL,
|
||||
PLANS_DIR,
|
||||
VERSION,
|
||||
)
|
||||
from .frontend_registration import (
|
||||
async_remove_frontend_registration,
|
||||
async_setup_frontend_registration,
|
||||
)
|
||||
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
|
||||
from .plans import collect_attachments, collect_plans, sweep_upload_temps
|
||||
@@ -39,11 +41,13 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
from .frontend_assets import HouseplanFrontendAssetView
|
||||
from .http_api import (
|
||||
HouseplanContentView,
|
||||
HouseplanDecorAssetUploadView,
|
||||
HouseplanImportPreviewView,
|
||||
HouseplanUploadView,
|
||||
)
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanDecorAssetUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
hass.http.register_view(HouseplanFrontendAssetView())
|
||||
@@ -87,53 +91,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
card_path = Path(__file__).parent / "frontend" / "houseplan-card.js"
|
||||
plans_path = Path(hass.config.path(PLANS_DIR))
|
||||
files_path = Path(hass.config.path(FILES_DIR))
|
||||
assets_path = Path(hass.config.path(ASSETS_DIR))
|
||||
await hass.async_add_executor_job(
|
||||
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
|
||||
lambda: (
|
||||
plans_path.mkdir(parents=True, exist_ok=True),
|
||||
files_path.mkdir(parents=True, exist_ok=True),
|
||||
assets_path.mkdir(parents=True, exist_ok=True),
|
||||
)
|
||||
)
|
||||
|
||||
# Static paths cannot be unregistered — register once per HA run.
|
||||
if not hass.data[DOMAIN].get("static_registered"):
|
||||
hass.data[DOMAIN]["static_registered"] = True
|
||||
static_paths = []
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
if card_path.exists():
|
||||
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
|
||||
# NOTE (audit B1): plans and marker files are NO LONGER static.
|
||||
# They are served by HouseplanContentView, which requires auth.
|
||||
# Only the entry and manifest-gated JS chunks stay public —
|
||||
# Lovelace modules must be loadable without an auth header.
|
||||
if static_paths:
|
||||
await hass.http.async_register_static_paths(static_paths)
|
||||
except ImportError: # very old HA versions
|
||||
if card_path.exists():
|
||||
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
|
||||
|
||||
if not card_path.exists():
|
||||
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
|
||||
return True
|
||||
|
||||
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
|
||||
# resources before rendering dashboards, so the card is available even on a cold
|
||||
# start of the mobile app). If the resource registry is unavailable (YAML-mode
|
||||
# Lovelace, old versions) — fall back to extra_module_url.
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}"
|
||||
registered = await _register_lovelace_resource(hass, module_url)
|
||||
if not registered:
|
||||
add_extra_js_url(hass, module_url)
|
||||
# Tell the user exactly where the card lives — the #1 support issue is people adding a
|
||||
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
|
||||
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
|
||||
if registered:
|
||||
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
|
||||
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
|
||||
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
|
||||
module_url, module_url,
|
||||
)
|
||||
# NOTE (audit B1): plans and marker files are no longer static. They are
|
||||
# served by HouseplanContentView, which requires auth. Only the manifest-
|
||||
# gated frontend bundle stays public so Lovelace can load it. Registration
|
||||
# failures are observable and recoverable, but never skip storage migration,
|
||||
# repairs or housekeeping below.
|
||||
await async_setup_frontend_registration(hass, entry, card_path)
|
||||
|
||||
# One-time move to the square canvas (v1.48.0). Coordinates used to be
|
||||
# normalised against a per-space aspect ratio; the canvas is now always
|
||||
@@ -311,61 +283,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
|
||||
"""Clean up on integration removal: drop our Lovelace resource entry."""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
|
||||
await resources.async_delete_item(item["id"])
|
||||
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
|
||||
except Exception as err: # noqa: BLE001 — best-effort cleanup
|
||||
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
|
||||
"""Register (or update) the card in the Lovelace resource registry.
|
||||
|
||||
Returns True on success. Idempotent: if a resource with our path exists —
|
||||
update the URL on version change; otherwise create it. Any exception → False
|
||||
(fall back to extra_module_url).
|
||||
"""
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return False
|
||||
# the resource registry must be loaded
|
||||
if hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
elif hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
# only storage mode allows creating items
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return False
|
||||
base = FRONTEND_URL
|
||||
existing = [
|
||||
item for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == base
|
||||
]
|
||||
if existing:
|
||||
item = existing[0]
|
||||
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
|
||||
await resources.async_update_item(item["id"], {"url": module_url})
|
||||
return True
|
||||
await resources.async_create_item({"res_type": "module", "url": module_url})
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 — any failure → fallback
|
||||
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
|
||||
return False
|
||||
async def async_remove_entry(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Clean up frontend registration and notice on integration removal."""
|
||||
await async_remove_frontend_registration(hass, entry)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Bounded, shared integrity verification for content-addressed decor assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import stat
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ASSET_INTEGRITY_CACHE_ENTRIES = 256
|
||||
ASSET_HASH_CHUNK_BYTES = 64 * 1024
|
||||
ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS = 30.0
|
||||
_HASS_DATA_KEY = "asset_integrity_verifier"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSignature:
|
||||
"""File version facts available without reading its content."""
|
||||
|
||||
size: int
|
||||
mtime_ns: int
|
||||
ctime_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CacheEntry:
|
||||
signature: FileSignature
|
||||
digest: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Flight:
|
||||
event: threading.Event
|
||||
digest: str | None = None
|
||||
|
||||
|
||||
def _signature(path: Path) -> FileSignature:
|
||||
current = path.stat()
|
||||
if not stat.S_ISREG(current.st_mode):
|
||||
raise OSError("asset is not a regular file")
|
||||
return FileSignature(
|
||||
size=current.st_size,
|
||||
mtime_ns=current.st_mtime_ns,
|
||||
ctime_ns=current.st_ctime_ns,
|
||||
)
|
||||
|
||||
|
||||
def _stream_sha256(path: Path) -> str:
|
||||
"""Hash a blob without retaining its bytes in memory."""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(ASSET_HASH_CHUNK_BYTES):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
class AssetIntegrityVerifier:
|
||||
"""Thread-safe LRU digest cache with per-file-version single-flight."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_entries: int = ASSET_INTEGRITY_CACHE_ENTRIES,
|
||||
*,
|
||||
hasher: Callable[[Path], str] | None = None,
|
||||
event_factory: Callable[[], threading.Event] | None = None,
|
||||
) -> None:
|
||||
if max_entries < 1:
|
||||
raise ValueError("max_entries must be positive")
|
||||
self._max_entries = max_entries
|
||||
self._hasher = hasher or _stream_sha256
|
||||
self._event_factory = event_factory or threading.Event
|
||||
self._lock = threading.Lock()
|
||||
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
|
||||
self._inflight: dict[tuple[str, FileSignature], _Flight] = {}
|
||||
|
||||
def verify(self, path: Path, expected_digest: str) -> bool:
|
||||
"""Return whether one stable file version has the expected digest."""
|
||||
try:
|
||||
canonical = str(path.resolve())
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
try:
|
||||
before = _signature(path)
|
||||
except OSError:
|
||||
with self._lock:
|
||||
self._cache.pop(canonical, None)
|
||||
return False
|
||||
|
||||
key = (canonical, before)
|
||||
with self._lock:
|
||||
cached = self._cache.get(canonical)
|
||||
if cached is not None and cached.signature == before:
|
||||
self._cache.move_to_end(canonical)
|
||||
return cached.digest == expected_digest
|
||||
if cached is not None:
|
||||
self._cache.pop(canonical, None)
|
||||
flight = self._inflight.get(key)
|
||||
owner = flight is None
|
||||
if owner:
|
||||
flight = _Flight(self._event_factory())
|
||||
self._inflight[key] = flight
|
||||
|
||||
assert flight is not None
|
||||
if not owner:
|
||||
if not flight.event.wait(ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS):
|
||||
return False
|
||||
return flight.digest == expected_digest
|
||||
|
||||
digest: str | None = None
|
||||
stable = False
|
||||
try:
|
||||
digest = self._hasher(path)
|
||||
# Never publish a digest for bytes that changed while they were read.
|
||||
stable = _signature(path) == before
|
||||
except Exception as err: # noqa: BLE001 - filesystem/hash seam fails dark
|
||||
_LOGGER.debug("House Plan asset integrity check failed: %s", err)
|
||||
finally:
|
||||
with self._lock:
|
||||
if stable and digest is not None:
|
||||
self._cache[canonical] = _CacheEntry(before, digest)
|
||||
self._cache.move_to_end(canonical)
|
||||
while len(self._cache) > self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
flight.digest = digest
|
||||
self._inflight.pop(key, None)
|
||||
flight.event.set()
|
||||
return stable and digest == expected_digest
|
||||
|
||||
|
||||
def get_asset_integrity_verifier(hass: Any) -> AssetIntegrityVerifier:
|
||||
"""Return the single verifier shared by HTTP and WS on this HA instance."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
verifier = domain_data.get(_HASS_DATA_KEY)
|
||||
if not isinstance(verifier, AssetIntegrityVerifier):
|
||||
verifier = AssetIntegrityVerifier()
|
||||
domain_data[_HASS_DATA_KEY] = verifier
|
||||
return verifier
|
||||
@@ -29,6 +29,9 @@ MAX_PLANS_FILES = 200
|
||||
MAX_PLANS_LISTED = 60
|
||||
MAX_FILES_BYTES = 1024 * 1024 * 1024
|
||||
MAX_FILES_COUNT = 1000
|
||||
MAX_DECOR_ASSET_BYTES = 2 * 1024 * 1024
|
||||
MAX_DECOR_ASSETS_BYTES = 256 * 1024 * 1024
|
||||
MAX_DECOR_ASSETS_COUNT = 200
|
||||
# Refuse to write when the disk is nearly full: filling the config partition
|
||||
# breaks .storage, the recorder and backups, not just this card.
|
||||
MIN_FREE_BYTES = 512 * 1024 * 1024
|
||||
@@ -46,8 +49,10 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.70.0"
|
||||
VERSION = "1.72.0"
|
||||
DECOR_ASSETS_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
|
||||
@@ -55,6 +60,7 @@ VERSION = "1.70.0"
|
||||
# reviewed release change, not a Home Assistant option.
|
||||
SUPPORT_RELAY_URL = "https://support.houseplan.tech/v1/reports"
|
||||
SUPPORT_PREVIEW_TTL_S = 10 * 60
|
||||
SUPPORT_API_VERSION = 1
|
||||
MAX_SUPPORT_PREVIEWS_PER_USER = 3
|
||||
MAX_SUPPORT_PREVIEWS_TOTAL = 3
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES = 8 * 1024 * 1024
|
||||
@@ -65,7 +71,7 @@ MAX_SUPPORT_CONTACT_CODEPOINTS = 320
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
# user have different compatibility lifecycles.
|
||||
PLAN_MODEL_VERSION = 9
|
||||
EXPORT_VERSION = 1
|
||||
EXPORT_VERSION = 2
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
MAX_IMPORT_PREVIEWS_PER_USER = 3
|
||||
@@ -91,11 +97,12 @@ ERROR_CODES: frozenset[str] = frozenset({
|
||||
"capacity_exceeded", "commit_failed", "conflict",
|
||||
"content_confirmation_required", "duplicate_marker_control",
|
||||
"future_model", "in_use", "invalid_config", "invalid_content",
|
||||
"invalid_data", "invalid_format", "invalid_json", "invalid_layout",
|
||||
"invalid_data", "invalid_format", "invalid_image", "invalid_json", "invalid_layout",
|
||||
"invalid_light_entity", "invalid_marker_control", "invalid_name",
|
||||
"invalid_partition_opening_host",
|
||||
"invalid_partition_opening_jamb_margin", "invalid_passage_fields",
|
||||
"invalid_space_id", "invalid_toggle_entity", "invalid_value_badge",
|
||||
"invalid_space_id", "invalid_toggle_entity", "invalid_vacuum_map_route",
|
||||
"invalid_value_badge",
|
||||
"invalid_value_badge_attribute", "invalid_value_badge_position",
|
||||
"invalid_value_badge_source", "invalid_value_source",
|
||||
"invalid_value_source_attribute", "io_error", "marker_control_cycle",
|
||||
@@ -106,7 +113,7 @@ ERROR_CODES: frozenset[str] = frozenset({
|
||||
"support_invalid_message", "support_package_too_large",
|
||||
"support_preview_expired", "support_rate_limited", "support_rejected",
|
||||
"support_unavailable",
|
||||
"too_large", "unauthorized", "unsupported_export_version",
|
||||
"too_large", "unauthorized", "unsupported_export_version", "unsupported_image",
|
||||
"value_badge_source_required", "wall_model_client_outdated",
|
||||
"wall_model_migration_blocked",
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
LATTICE_NOISE_STEPS = 1e-4
|
||||
DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
@@ -150,7 +151,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
elif kind in DECOR_BOX_KINDS:
|
||||
_lattice_fields(decor, ("x", "y", "w", "h"))
|
||||
_scalar_fields(decor, ("angle",))
|
||||
elif kind == "text":
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Content-addressed custom images for the decorative layer (#51).
|
||||
|
||||
Upload plumbing hands this module untrusted bytes. It either returns one fully
|
||||
validated canonical asset or rejects the whole file; SVG is never repaired or
|
||||
partially stripped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.parsers import expat
|
||||
|
||||
from .const import CONTENT_URL, MAX_DECOR_ASSET_BYTES
|
||||
|
||||
ASSET_ID_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
ASSET_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".svg"})
|
||||
MIME_BY_EXT = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".svg": "image/svg+xml",
|
||||
}
|
||||
EXT_BY_MIME = {
|
||||
"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
MAX_RASTER_DIMENSION = 16_384
|
||||
MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4
|
||||
MAX_SVG_ELEMENTS = 5_000
|
||||
MAX_SVG_DEPTH = 64
|
||||
MAX_SVG_ATTR_CHARS = 512_000
|
||||
MAX_SVG_ATTR_VALUE_CHARS = 65_536
|
||||
_SVG_TAGS = frozenset({
|
||||
"svg", "g", "defs", "title", "desc", "path", "rect", "circle", "ellipse",
|
||||
"line", "polyline", "polygon", "clipPath", "mask", "linearGradient",
|
||||
"radialGradient", "stop",
|
||||
})
|
||||
_SVG_ATTRS = frozenset({
|
||||
"xmlns", "viewBox", "width", "height", "x", "y", "x1", "y1", "x2", "y2",
|
||||
"cx", "cy", "r", "rx", "ry", "d", "points", "transform", "fill", "fill-rule",
|
||||
"fill-opacity", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin",
|
||||
"stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "opacity", "offset",
|
||||
"stop-color", "stop-opacity", "gradientUnits", "gradientTransform", "id",
|
||||
"clip-path", "mask", "preserveAspectRatio", "href",
|
||||
})
|
||||
_LOCAL_REF = re.compile(r"^url\(#[A-Za-z_][A-Za-z0-9_.:-]*\)$")
|
||||
_LENGTH = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*(?:px)?\s*$", re.I)
|
||||
_SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
||||
_SVG_UNIT_INTERVAL_ATTRS = frozenset({
|
||||
"opacity", "fill-opacity", "stroke-opacity", "stop-opacity", "offset",
|
||||
})
|
||||
|
||||
|
||||
class DecorAssetError(ValueError):
|
||||
"""Stable validation failure suitable for an API error response."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidatedAsset:
|
||||
data: bytes
|
||||
mime: str
|
||||
ext: str
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@property
|
||||
def asset_id(self) -> str:
|
||||
return hashlib.sha256(self.data).hexdigest()
|
||||
|
||||
|
||||
def _check_size(data: bytes) -> None:
|
||||
if not data:
|
||||
raise DecorAssetError("invalid_image", "The image is empty")
|
||||
if len(data) > MAX_DECOR_ASSET_BYTES:
|
||||
raise DecorAssetError("too_large", "The image exceeds the 2 MiB limit")
|
||||
|
||||
|
||||
def _raster_dimensions(data: bytes, ext: str) -> tuple[int, int]:
|
||||
try:
|
||||
if ext == ".png":
|
||||
if (len(data) < 45 or data[:8] != b"\x89PNG\r\n\x1a\n"
|
||||
or data[12:16] != b"IHDR" or b"IDAT" not in data
|
||||
or data[-8:-4] != b"IEND"):
|
||||
raise ValueError
|
||||
return struct.unpack(">II", data[16:24])
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
if len(data) < 4 or data[:2] != b"\xff\xd8":
|
||||
raise ValueError
|
||||
pos = 2
|
||||
while pos + 4 <= len(data):
|
||||
if data[pos] != 0xFF:
|
||||
pos += 1
|
||||
continue
|
||||
marker = data[pos + 1]
|
||||
pos += 2
|
||||
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
|
||||
continue
|
||||
length = int.from_bytes(data[pos:pos + 2], "big")
|
||||
if length < 2 or pos + length > len(data):
|
||||
raise ValueError
|
||||
if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
|
||||
if length < 7:
|
||||
raise ValueError
|
||||
return (
|
||||
int.from_bytes(data[pos + 5:pos + 7], "big"),
|
||||
int.from_bytes(data[pos + 3:pos + 5], "big"),
|
||||
)
|
||||
pos += length
|
||||
raise ValueError
|
||||
if ext == ".webp":
|
||||
if len(data) < 30 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
|
||||
raise ValueError
|
||||
kind = data[12:16]
|
||||
if kind == b"VP8X":
|
||||
return (
|
||||
1 + int.from_bytes(data[24:27], "little"),
|
||||
1 + int.from_bytes(data[27:30], "little"),
|
||||
)
|
||||
if kind == b"VP8 ":
|
||||
idx = data.find(b"\x9d\x01\x2a", 20)
|
||||
if idx < 0 or idx + 7 > len(data):
|
||||
raise ValueError
|
||||
return (
|
||||
int.from_bytes(data[idx + 3:idx + 5], "little") & 0x3FFF,
|
||||
int.from_bytes(data[idx + 5:idx + 7], "little") & 0x3FFF,
|
||||
)
|
||||
if kind == b"VP8L" and data[20] == 0x2F:
|
||||
bits = int.from_bytes(data[21:25], "little")
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
raise ValueError
|
||||
except (IndexError, struct.error, ValueError) as err:
|
||||
raise DecorAssetError(
|
||||
"invalid_image", "The raster image is corrupt or has the wrong type",
|
||||
) from err
|
||||
raise DecorAssetError("unsupported_image", "Unsupported image type")
|
||||
|
||||
|
||||
def _validate_dimensions(width: float, height: float) -> tuple[int, int]:
|
||||
if not math.isfinite(width) or not math.isfinite(height) or width <= 0 or height <= 0:
|
||||
raise DecorAssetError("invalid_image", "The image has no usable dimensions")
|
||||
w, h = int(round(width)), int(round(height))
|
||||
if w <= 0 or h <= 0:
|
||||
raise DecorAssetError("invalid_image", "The image has no usable dimensions")
|
||||
if w > MAX_RASTER_DIMENSION or h > MAX_RASTER_DIMENSION or w * h > MAX_RASTER_PIXELS:
|
||||
raise DecorAssetError("too_large", "Decoded image dimensions exceed the safety limit")
|
||||
return w, h
|
||||
|
||||
|
||||
def _svg_number(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
match = _LENGTH.fullmatch(value)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _validate_svg_unit_interval(name: str, value: str) -> None:
|
||||
"""Reject non-finite and out-of-range opacity/gradient values."""
|
||||
raw = value.strip()
|
||||
percent = raw.endswith("%")
|
||||
try:
|
||||
number = float(raw[:-1] if percent else raw)
|
||||
except ValueError as err:
|
||||
raise DecorAssetError("invalid_image", f"The SVG {name} value is invalid") from err
|
||||
limit = 100 if percent else 1
|
||||
if not math.isfinite(number) or number < 0 or number > limit:
|
||||
raise DecorAssetError("invalid_image", f"The SVG {name} value is out of range")
|
||||
|
||||
|
||||
def _reject_svg_declarations(data: bytes) -> None:
|
||||
"""Parse with encoding-aware expat guards before building an XML tree."""
|
||||
parser = expat.ParserCreate()
|
||||
|
||||
def reject(*_args: Any) -> None:
|
||||
raise DecorAssetError(
|
||||
"invalid_image", "DTD, entities and processing instructions are forbidden",
|
||||
)
|
||||
|
||||
parser.StartDoctypeDeclHandler = reject
|
||||
parser.EntityDeclHandler = reject
|
||||
parser.ExternalEntityRefHandler = reject
|
||||
parser.ProcessingInstructionHandler = reject
|
||||
try:
|
||||
parser.Parse(data, True)
|
||||
except DecorAssetError:
|
||||
raise
|
||||
except (expat.ExpatError, UnicodeError) as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err
|
||||
|
||||
|
||||
def _validate_svg(data: bytes) -> ValidatedAsset:
|
||||
# Byte-substring checks are bypassable with UTF-16/UTF-32. Expat detects
|
||||
# the declared/input encoding first and our handlers reject declarations
|
||||
# before entity expansion can allocate an ElementTree.
|
||||
_reject_svg_declarations(data)
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except (ET.ParseError, UnicodeError) as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG is not valid XML") from err
|
||||
if root.tag.rsplit("}", 1)[-1] != "svg":
|
||||
raise DecorAssetError("invalid_image", "Only an SVG root is accepted")
|
||||
count = attr_chars = 0
|
||||
ids: set[str] = set()
|
||||
refs: list[str] = []
|
||||
ref_graph: dict[str, set[str]] = {}
|
||||
stack: list[tuple[ET.Element, int, str | None]] = [(root, 1, None)]
|
||||
while stack:
|
||||
node, depth, inherited_owner = stack.pop()
|
||||
count += 1
|
||||
if count > MAX_SVG_ELEMENTS or depth > MAX_SVG_DEPTH:
|
||||
raise DecorAssetError("too_large", "The SVG structure exceeds the safety limit")
|
||||
if node.tag.startswith("{"):
|
||||
namespace, tag = node.tag[1:].split("}", 1)
|
||||
if namespace != _SVG_NAMESPACE:
|
||||
raise DecorAssetError("unsupported_image", "Unknown SVG namespace")
|
||||
else:
|
||||
raise DecorAssetError("unsupported_image", "The SVG namespace is required")
|
||||
if tag not in _SVG_TAGS:
|
||||
raise DecorAssetError("unsupported_image", f"Unsupported SVG element: {tag}")
|
||||
node_id = node.attrib.get("id")
|
||||
owner = node_id or inherited_owner
|
||||
if node.text and node.text.strip():
|
||||
if tag not in {"title", "desc"} or len(node.text) > 4096:
|
||||
raise DecorAssetError("unsupported_image", "Unsupported or oversized SVG text")
|
||||
for raw_name, value in node.attrib.items():
|
||||
if len(value) > MAX_SVG_ATTR_VALUE_CHARS:
|
||||
raise DecorAssetError("too_large", "An SVG attribute exceeds the safety limit")
|
||||
if raw_name.startswith("{"):
|
||||
raise DecorAssetError("unsupported_image", "Namespaced SVG attributes are unsupported")
|
||||
name = raw_name
|
||||
attr_chars += len(raw_name) + len(value)
|
||||
if attr_chars > MAX_SVG_ATTR_CHARS:
|
||||
raise DecorAssetError("too_large", "The SVG attributes exceed the safety limit")
|
||||
if name.lower().startswith("on") or name not in _SVG_ATTRS:
|
||||
raise DecorAssetError("unsupported_image", f"Unsupported SVG attribute: {name}")
|
||||
if name in _SVG_UNIT_INTERVAL_ATTRS:
|
||||
_validate_svg_unit_interval(name, value)
|
||||
low = value.strip().lower()
|
||||
if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):
|
||||
raise DecorAssetError("invalid_image", "External SVG resources are forbidden")
|
||||
if "url(" in low:
|
||||
if not _LOCAL_REF.fullmatch(value.strip()):
|
||||
raise DecorAssetError("invalid_image", "Only local SVG references are allowed")
|
||||
ref = value.strip()[5:-1]
|
||||
refs.append(ref)
|
||||
if owner:
|
||||
ref_graph.setdefault(owner, set()).add(ref)
|
||||
if name == "href":
|
||||
if not re.fullmatch(r"#[A-Za-z_][A-Za-z0-9_.:-]*", value.strip()):
|
||||
raise DecorAssetError("invalid_image", "Only local SVG references are allowed")
|
||||
ref = value.strip()[1:]
|
||||
refs.append(ref)
|
||||
if owner:
|
||||
ref_graph.setdefault(owner, set()).add(ref)
|
||||
if name == "id":
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.:-]*", value) or value in ids:
|
||||
raise DecorAssetError("invalid_image", "SVG ids must be unique and well formed")
|
||||
ids.add(value)
|
||||
stack.extend((child, depth + 1, owner) for child in node)
|
||||
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)
|
||||
view_box = root.attrib.get("viewBox")
|
||||
width = _svg_number(root.attrib.get("width"))
|
||||
height = _svg_number(root.attrib.get("height"))
|
||||
if view_box:
|
||||
try:
|
||||
parts = [float(part) for part in re.split(r"[\s,]+", view_box.strip())]
|
||||
if len(parts) != 4:
|
||||
raise ValueError
|
||||
width, height = parts[2], parts[3]
|
||||
except ValueError as err:
|
||||
raise DecorAssetError("invalid_image", "The SVG viewBox is invalid") from err
|
||||
w, h = _validate_dimensions(width or 0, height or 0)
|
||||
ET.register_namespace("", _SVG_NAMESPACE)
|
||||
canonical = ET.tostring(
|
||||
root, encoding="utf-8", xml_declaration=False, short_empty_elements=True,
|
||||
)
|
||||
_check_size(canonical)
|
||||
try:
|
||||
ET.fromstring(canonical)
|
||||
except ET.ParseError as err: # pragma: no cover - serializer invariant
|
||||
raise DecorAssetError("invalid_image", "Canonical SVG could not be reparsed") from err
|
||||
return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h)
|
||||
|
||||
|
||||
def validate_asset(
|
||||
data: bytes, filename: str, declared_mime: str | None = None,
|
||||
) -> ValidatedAsset:
|
||||
"""Validate and canonicalise one complete upload."""
|
||||
_check_size(data)
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext not in ASSET_EXTENSIONS:
|
||||
raise DecorAssetError("unsupported_image", "Use PNG, JPEG, WebP or SVG")
|
||||
expected_mime = MIME_BY_EXT[ext]
|
||||
claimed = str(declared_mime or "").split(";", 1)[0].strip().lower()
|
||||
if claimed and claimed != "application/octet-stream" and claimed != expected_mime:
|
||||
raise DecorAssetError("invalid_format", "The filename, MIME type and image bytes disagree")
|
||||
if ext == ".svg":
|
||||
return _validate_svg(data)
|
||||
width, height = _validate_dimensions(*_raster_dimensions(data, ext))
|
||||
# Home Assistant ships Pillow. Header parsing above keeps this helper
|
||||
# independently testable, while a full decode here catches valid-looking
|
||||
# but truncated/corrupt payloads before they enter the authenticated store.
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
image.load()
|
||||
if image.size != (width, height):
|
||||
raise DecorAssetError("invalid_image", "Image dimensions are inconsistent")
|
||||
if getattr(image, "is_animated", False):
|
||||
raise DecorAssetError("unsupported_image", "Animated images are unsupported")
|
||||
except ImportError:
|
||||
# The Home Assistant runtime includes Pillow. Keeping the pure parser
|
||||
# usable without the full HA dependency set lets repository-local
|
||||
# security tests still exercise signatures, dimensions and SVG.
|
||||
pass
|
||||
except DecorAssetError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - decoder failures are one public error
|
||||
raise DecorAssetError("invalid_image", "The raster image cannot be decoded") from err
|
||||
mime = expected_mime
|
||||
return ValidatedAsset(data, mime, EXT_BY_MIME[mime], width, height)
|
||||
|
||||
|
||||
def asset_refs(config: dict[str, Any] | None) -> dict[str, list[dict[str, str]]]:
|
||||
"""Return server-authoritative references grouped by asset id."""
|
||||
out: dict[str, list[dict[str, str]]] = {}
|
||||
for space in (config or {}).get("spaces") or []:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
sid = str(space.get("id") or "")
|
||||
for shape in space.get("decor") or []:
|
||||
if not isinstance(shape, dict) or shape.get("kind") != "image":
|
||||
continue
|
||||
aid = shape.get("asset_id")
|
||||
if isinstance(aid, str) and ASSET_ID_RE.fullmatch(aid):
|
||||
out.setdefault(aid, []).append({
|
||||
"space_id": sid, "decor_id": str(shape.get("id") or ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def asset_meta_path(root: Path, asset_id: str) -> Path:
|
||||
return root / f"{asset_id}.json"
|
||||
|
||||
|
||||
def _physical_asset_inventory(root: Path) -> list[tuple[Path, int]]:
|
||||
"""Observe regular promoted blobs once, tolerating concurrent removals."""
|
||||
try:
|
||||
if not root.is_dir():
|
||||
return []
|
||||
entries = list(root.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
inventory: list[tuple[Path, int]] = []
|
||||
for path in entries:
|
||||
if path.suffix not in ASSET_EXTENSIONS or not ASSET_ID_RE.fullmatch(path.stem):
|
||||
continue
|
||||
try:
|
||||
current = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if stat.S_ISREG(current.st_mode):
|
||||
inventory.append((path, current.st_size))
|
||||
return sorted(inventory, key=lambda item: item[0].name)
|
||||
|
||||
|
||||
def physical_asset_blobs(root: Path) -> list[Path]:
|
||||
"""Return exact promoted blob files, independently from their sidecars."""
|
||||
return [path for path, _size in _physical_asset_inventory(root)]
|
||||
|
||||
|
||||
def physical_asset_usage(root: Path) -> tuple[int, int]:
|
||||
"""Return promoted blob count and actual bytes used for quota checks."""
|
||||
inventory = _physical_asset_inventory(root)
|
||||
return len(inventory), sum(size for _path, size in inventory)
|
||||
|
||||
|
||||
def _read_catalog_row(root: Path, path: Path) -> dict[str, Any] | None:
|
||||
"""Read one sidecar through the validation shared by list and resolve."""
|
||||
try:
|
||||
row = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
aid = str(row.get("asset_id") or "")
|
||||
ext = row.get("ext")
|
||||
blob = root / f"{aid}{ext}"
|
||||
if (
|
||||
path.stem != aid
|
||||
or not ASSET_ID_RE.fullmatch(aid)
|
||||
or ext not in ASSET_EXTENSIONS
|
||||
or not blob.is_file()
|
||||
):
|
||||
return None
|
||||
return row
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_asset(root: Path, asset_id: str) -> dict[str, Any] | None:
|
||||
"""Read one exact catalog row without scanning unrelated sidecars."""
|
||||
if not ASSET_ID_RE.fullmatch(asset_id):
|
||||
return None
|
||||
return _read_catalog_row(root, asset_meta_path(root, asset_id))
|
||||
|
||||
|
||||
def read_catalog(root: Path) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not root.is_dir():
|
||||
return rows
|
||||
for path in root.glob("*.json"):
|
||||
row = _read_catalog_row(root, path)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
return sorted(
|
||||
rows,
|
||||
key=lambda row: (str(row.get("created_at", "")), str(row["asset_id"])),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def public_asset(
|
||||
row: dict[str, Any], used_by: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
aid = str(row["asset_id"])
|
||||
ext = str(row["ext"])
|
||||
return {
|
||||
"asset_id": aid,
|
||||
"name": row.get("name", "image"),
|
||||
"mime": row.get("mime"),
|
||||
"width": row.get("width"),
|
||||
"height": row.get("height"),
|
||||
"bytes": row.get("bytes"),
|
||||
"url": f"{CONTENT_URL}/assets/_/{aid}{ext}",
|
||||
"used_by": used_by or [],
|
||||
}
|
||||
@@ -1,125 +1,182 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "d90076d3f49d110c3959a6c08a86895a6b98ca810fe122b37b76e22adc38ad73",
|
||||
"fingerprint": "0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39",
|
||||
"entry": "houseplan-card.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-HIu1PXGU.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 290460,
|
||||
"initialViewGzipBytes": 300154,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-CI0weKTj.js",
|
||||
"houseplan-assets/de-Db5jQSzb.js",
|
||||
"houseplan-assets/editor-DrFXz_Hr.js",
|
||||
"houseplan-assets/fr-gYskL0hZ.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BHxJWPq7.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CXZ19w7L.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"houseplan-assets/live-interaction-runtime-BuROtNwW.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
],
|
||||
"lazyGzipBytes": 205822,
|
||||
"lazyGzipBytes": 250869,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-CI0weKTj.js",
|
||||
"houseplan-assets/editor-DrFXz_Hr.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BHxJWPq7.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 148716,
|
||||
"lazyEditorGzipBytes": 186626,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-CI0weKTj.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CXZ19w7L.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14032,
|
||||
"lazyOnboardingGzipBytes": 14325,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-Db5jQSzb.js",
|
||||
"houseplan-assets/fr-gYskL0hZ.js"
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 50146,
|
||||
"lazyLocaleGzipBytes": 50148,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-CI0weKTj.js",
|
||||
"sha256": "36f0fbfeccd0ac6981be68fc7446f7b548a5cb40a542b48e07331f2791e7f555",
|
||||
"rawBytes": 20636,
|
||||
"gzipBytes": 7072,
|
||||
"path": "houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"sha256": "acd02f075bc18ca361cb543749461208b9f2dea2433aa3950a1296c31759de6b",
|
||||
"rawBytes": 21592,
|
||||
"gzipBytes": 7357,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-HIu1PXGU.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-Db5jQSzb.js",
|
||||
"sha256": "412aaaff8a26a9d7fd6d5e4335711e821ea5ad38cc095b61a3ae56ece4c0a7e9",
|
||||
"rawBytes": 83981,
|
||||
"gzipBytes": 25344,
|
||||
"path": "houseplan-assets/de-BRjMWzox.js",
|
||||
"sha256": "fdba581311125cadbec7b827e8a4b87f3aba4eab7ff12d4dbf8af58517e9e9a8",
|
||||
"rawBytes": 84232,
|
||||
"gzipBytes": 25351,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DrFXz_Hr.js",
|
||||
"sha256": "cba3f4d097d84c823946d966677f02fcae841233c3a683e2deda6c7084f1f7f2",
|
||||
"rawBytes": 3826,
|
||||
"gzipBytes": 1582,
|
||||
"path": "houseplan-assets/editor-DIlG_iy6.js",
|
||||
"sha256": "8c28088bcc9df5581948eeda9518f43eb73ca8ed9918ab0961f3eea114e2f3c9",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-HIu1PXGU.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-gYskL0hZ.js",
|
||||
"sha256": "6fe8d00dfa1f3b29b669aa81b205547f49778c86441e5723ab3982b970af09b0",
|
||||
"rawBytes": 86212,
|
||||
"gzipBytes": 24802,
|
||||
"path": "houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"sha256": "95cf4e535042c78eb00bedc08c8c2a9e779236847f3e12dca4757fca435629b7",
|
||||
"rawBytes": 86273,
|
||||
"gzipBytes": 24797,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-HIu1PXGU.js",
|
||||
"sha256": "d43a961780a297835fa6b3d8b3ff534897b8376128afa9e7c1db7829dee74ee4",
|
||||
"rawBytes": 1032568,
|
||||
"gzipBytes": 289663,
|
||||
"path": "houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"sha256": "03a04aa491c26e42f8adea0126c8bb970b54ec7a12bbd9475f9279a91a2b7fc0",
|
||||
"rawBytes": 1069135,
|
||||
"gzipBytes": 299358,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-Db5jQSzb.js",
|
||||
"houseplan-assets/editor-DrFXz_Hr.js",
|
||||
"houseplan-assets/fr-gYskL0hZ.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BHxJWPq7.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-CXZ19w7L.js"
|
||||
"houseplan-assets/de-BRjMWzox.js",
|
||||
"houseplan-assets/editor-DIlG_iy6.js",
|
||||
"houseplan-assets/fr-CB9Rh5BY.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"houseplan-assets/live-interaction-runtime-BuROtNwW.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BHxJWPq7.js",
|
||||
"sha256": "df085c635b6b02fa11c1b0b5d9f8e87fc56309ed905de32f6eaee5b8e7c10af4",
|
||||
"rawBytes": 543265,
|
||||
"gzipBytes": 140062,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-CVgleQRG.js",
|
||||
"sha256": "fc089a1e4a1df49c3e1c8e78620a3bd23f3ba7270213db663cd018854c22fe55",
|
||||
"rawBytes": 639580,
|
||||
"gzipBytes": 170261,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CI0weKTj.js",
|
||||
"houseplan-assets/houseplan-card-HIu1PXGU.js"
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B8qICyvO.js",
|
||||
"sha256": "87d24dae20c92a060e34d1e4ce80d300c947b7ac6b3a86f6025b39750d286d46",
|
||||
"rawBytes": 28071,
|
||||
"gzipBytes": 6968,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CzRGitFR.js",
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-CXZ19w7L.js",
|
||||
"sha256": "0a7828064aa00462836ba7d7fd7bd975b9a0751894d5546aa5c1bad60b987702",
|
||||
"rawBytes": 28088,
|
||||
"gzipBytes": 6960,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CpC8UBVY.js",
|
||||
"sha256": "b5cbcfffe86b10e56c078dbf139123a6c16625f8c2396570215f06def12e08ba",
|
||||
"rawBytes": 8626,
|
||||
"gzipBytes": 3236,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CI0weKTj.js",
|
||||
"houseplan-assets/houseplan-card-HIu1PXGU.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-BuROtNwW.js",
|
||||
"sha256": "b0d405033a72cab412d7475170818f00729dfedd049f2dcfc3387beefa43ea42",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2126,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DjEvSVKB.js",
|
||||
"sha256": "531006824ca5197709f62fc3bae854d4a7349d3c7f3c3ab85c223d7e31261b9a",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7425,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-toe7Mdc6.js",
|
||||
"sha256": "09566891268f0fd002283422cd1b83151520240025fb9e2899cee758e6147e8c",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js",
|
||||
"houseplan-assets/zigbee-topology-DjEvSVKB.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "0ac5ff1feac770dfe96547960ca7e5816d4f501fc37cdaf47f5e6cd93d3e427b",
|
||||
"sha256": "12e80545e7ee775e0859c8113f922c25f15bef46c5c1866db3cff6d7d3dccdce",
|
||||
"rawBytes": 1183,
|
||||
"gzipBytes": 797,
|
||||
"gzipBytes": 796,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-HIu1PXGU.js"
|
||||
"houseplan-assets/houseplan-card-C0qjaV_3.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{v as e,l as o,z as t,dt as s,A as a,du as i,dv as l,dw as n,E as r,c}from"./houseplan-card-C0qjaV_3.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="d90076d3f49d110c3959a6c08a86895a6b98ca810fe122b37b76e22adc38ad73";import{b as e,l as o,t,d8 as s,A as a,d9 as i,da as l,db as n,E as r,c}from"./houseplan-card-HIu1PXGU.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__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";import{n as t,b as n}from"./zigbee-topology-DjEvSVKB.js";import{I as e}from"./houseplan-card-C0qjaV_3.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="d90076d3f49d110c3959a6c08a86895a6b98ca810fe122b37b76e22adc38ad73";try{await import("./houseplan-assets/houseplan-card-HIu1PXGU.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0d4311a7ae77b6ea13ed95f2b0c886ad4253d09f6770dfff438ee45667c22a39";try{await import("./houseplan-assets/houseplan-card-C0qjaV_3.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Register the House Plan card with the Home Assistant frontend."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import frontend, persistent_notification
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import DOMAIN, FRONTEND_URL, VERSION
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_REGISTRATION_KEY = "frontend_registration"
|
||||
FRONTEND_STATIC_REGISTERED_KEY = "static_registered"
|
||||
FRONTEND_FALLBACK_URLS_KEY = "frontend_fallback_urls"
|
||||
FRONTEND_RELOAD_NOTICE_DATA_KEY = "frontend_reload_notice_created"
|
||||
FRONTEND_RELOAD_NOTICE_ID = "houseplan_frontend_reload_notice"
|
||||
FRONTEND_RETRY_DELAY_SECONDS = 1.0
|
||||
|
||||
_NOTICE_TITLE_KEY = "component.houseplan.issues.frontend_reload_notice.title"
|
||||
_NOTICE_DESCRIPTION_KEY = (
|
||||
"component.houseplan.issues.frontend_reload_notice.description"
|
||||
)
|
||||
_NOTICE_TITLE_FALLBACK = "House Plan card connected"
|
||||
_NOTICE_DESCRIPTION_FALLBACK = (
|
||||
"The House Plan card is connected. Fully reload this page to use the latest "
|
||||
"frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you "
|
||||
"manage dashboard resources manually in storage mode, open Settings → "
|
||||
"Dashboards → Resources."
|
||||
)
|
||||
|
||||
RegistrationStatus = Literal[
|
||||
"not_attempted",
|
||||
"created",
|
||||
"updated",
|
||||
"existing",
|
||||
"registry_pending",
|
||||
"yaml_fallback",
|
||||
"transient_error",
|
||||
"error_fallback",
|
||||
]
|
||||
ResourceLoader = Literal[
|
||||
"none",
|
||||
"lovelace_resource",
|
||||
"extra_module_url",
|
||||
"lovelace_resource_with_session_fallback",
|
||||
]
|
||||
ReloadNoticeStatus = Literal[
|
||||
"created",
|
||||
"already_created",
|
||||
"pending_frontend",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegistrationOutcome:
|
||||
"""Result of one Lovelace resource registry attempt."""
|
||||
|
||||
status: RegistrationStatus
|
||||
last_error: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FrontendRegistrationState:
|
||||
"""Observable state and lifecycle handles for the current config-entry run."""
|
||||
|
||||
card_file_present: bool
|
||||
static_path_registered: bool = False
|
||||
resource_status: RegistrationStatus = "not_attempted"
|
||||
loader: ResourceLoader = "none"
|
||||
module_url: str | None = None
|
||||
retry_pending: bool = False
|
||||
retry_attempted: bool = False
|
||||
last_error: str | None = None
|
||||
first_reload_notice: ReloadNoticeStatus = "pending_frontend"
|
||||
active: bool = True
|
||||
fallback_added: bool = False
|
||||
_cancel_started: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_cancel_timer: Callable[[], None] | None = field(default=None, repr=False)
|
||||
_retry_task: asyncio.Task[None] | None = field(default=None, repr=False)
|
||||
_notice_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
|
||||
|
||||
@callback
|
||||
def cancel(self) -> None:
|
||||
"""Prevent all delayed work for this setup generation."""
|
||||
self.active = False
|
||||
self.retry_pending = False
|
||||
if self._cancel_started is not None:
|
||||
self._cancel_started()
|
||||
self._cancel_started = None
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
if self._retry_task is not None and not self._retry_task.done():
|
||||
self._retry_task.cancel()
|
||||
self._retry_task = None
|
||||
|
||||
|
||||
def _safe_error(phase: str, err: BaseException) -> str:
|
||||
"""Return a support-safe failure fingerprint without message or path data."""
|
||||
return f"{phase}:{type(err).__name__}"
|
||||
|
||||
|
||||
def _lovelace_resources(hass: HomeAssistant):
|
||||
"""Return the Lovelace resource collection across supported HA versions."""
|
||||
lovelace = hass.data.get("lovelace")
|
||||
resources = getattr(lovelace, "resources", None)
|
||||
if resources is None and isinstance(lovelace, dict):
|
||||
resources = lovelace.get("resources")
|
||||
return resources
|
||||
|
||||
|
||||
def _is_current(hass: HomeAssistant, state: FrontendRegistrationState) -> bool:
|
||||
"""Return whether a delayed operation still belongs to the loaded entry."""
|
||||
return (
|
||||
state.active
|
||||
and hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY) is state
|
||||
)
|
||||
|
||||
|
||||
def _owned_fallback_urls(hass: HomeAssistant) -> set[str]:
|
||||
"""Return exact extra-module URLs owned for this Home Assistant run."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
urls = domain_data.get(FRONTEND_FALLBACK_URLS_KEY)
|
||||
if not isinstance(urls, set):
|
||||
urls = set()
|
||||
domain_data[FRONTEND_FALLBACK_URLS_KEY] = urls
|
||||
return urls
|
||||
|
||||
|
||||
def _active_extra_module_urls(hass: HomeAssistant) -> frozenset[str] | None:
|
||||
"""Return HA's current module URLs, or None when the API is unknowable.
|
||||
|
||||
``add_extra_js_url`` is intentionally idempotent and has no ownership
|
||||
return value. Looking before adding is therefore the only way to avoid
|
||||
claiming (and later removing) an identical URL configured by the user.
|
||||
Both the oldest supported HA and current HA expose ``UrlManager.urls``;
|
||||
unknown future/legacy shapes fail closed and are never claimed.
|
||||
"""
|
||||
key = getattr(frontend, "DATA_EXTRA_MODULE_URL", None)
|
||||
if key is None:
|
||||
return None
|
||||
manager = hass.data.get(key)
|
||||
if manager is None:
|
||||
# Frontend setup normally creates the manager before this integration.
|
||||
# If it is genuinely absent, no configured URL can predate our add;
|
||||
# add_extra_js_url will either create/use its supported path or fail.
|
||||
return frozenset()
|
||||
urls = getattr(manager, "urls", None)
|
||||
if not isinstance(urls, (set, frozenset, list, tuple)):
|
||||
return None
|
||||
return frozenset(url for url in urls if isinstance(url, str))
|
||||
|
||||
|
||||
async def _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing: list[dict],
|
||||
canonical_id: str,
|
||||
*,
|
||||
is_active: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Best-effort remove legacy duplicates after choosing one authority."""
|
||||
remover = getattr(resources, "async_delete_item", None)
|
||||
if remover is None:
|
||||
return
|
||||
for item in existing:
|
||||
if item.get("id") == canonical_id:
|
||||
continue
|
||||
if not is_active():
|
||||
return
|
||||
try:
|
||||
await remover(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - canonical registration still succeeded
|
||||
_LOGGER.debug(
|
||||
"Could not remove a duplicate House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def async_register_lovelace_resource(
|
||||
hass: HomeAssistant,
|
||||
module_url: str,
|
||||
*,
|
||||
is_active: Callable[[], bool] | None = None,
|
||||
) -> RegistrationOutcome:
|
||||
"""Create or update the canonical Lovelace resource entry."""
|
||||
active = is_active or (lambda: True)
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None:
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if not hasattr(resources, "async_create_item"):
|
||||
return RegistrationOutcome("yaml_fallback")
|
||||
|
||||
try:
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
elif hasattr(resources, "loaded") and not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
|
||||
existing = [
|
||||
item
|
||||
for item in resources.async_items()
|
||||
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL
|
||||
]
|
||||
if existing:
|
||||
# Prefer an already-current entry when legacy duplicates exist. This
|
||||
# preserves its stable id while the remaining same-base entries are
|
||||
# removed best effort below.
|
||||
item = next(
|
||||
(candidate for candidate in existing if candidate.get("url") == module_url),
|
||||
existing[0],
|
||||
)
|
||||
if item.get("url") == module_url and item.get("type") == "module":
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("existing")
|
||||
if not hasattr(resources, "async_update_item"):
|
||||
return RegistrationOutcome(
|
||||
"transient_error", "registry_update:UnsupportedOperation"
|
||||
)
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_update_item(
|
||||
item["id"], {"url": module_url, "res_type": "module"}
|
||||
)
|
||||
await _async_remove_duplicate_resources(
|
||||
resources,
|
||||
existing,
|
||||
item["id"],
|
||||
is_active=active,
|
||||
)
|
||||
return RegistrationOutcome("updated")
|
||||
|
||||
if not active():
|
||||
return RegistrationOutcome("registry_pending")
|
||||
await resources.async_create_item(
|
||||
{"url": module_url, "res_type": "module"}
|
||||
)
|
||||
_LOGGER.debug("House Plan card registered as a Lovelace resource")
|
||||
return RegistrationOutcome("created")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - typed fallback is intentional
|
||||
_LOGGER.debug("Could not register the House Plan Lovelace resource", exc_info=True)
|
||||
return RegistrationOutcome("transient_error", _safe_error("registry", err))
|
||||
|
||||
|
||||
def _add_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> None:
|
||||
"""Add this setup's exact versioned extra-module URL, best effort."""
|
||||
if state.module_url is None or not _is_current(hass, state):
|
||||
return
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
active_before = _active_extra_module_urls(hass)
|
||||
if active_before is not None and state.module_url in active_before:
|
||||
# The exact URL predates this setup. It is a working loader, but it is
|
||||
# not ours to remove after registry recovery or on uninstall.
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
return
|
||||
try:
|
||||
frontend.add_extra_js_url(hass, state.module_url)
|
||||
except Exception as err: # noqa: BLE001 - frontend setup must remain available
|
||||
state.loader = "none"
|
||||
state.last_error = _safe_error("fallback_add", err)
|
||||
_LOGGER.warning("Could not add the House Plan frontend fallback", exc_info=True)
|
||||
return
|
||||
if active_before is not None:
|
||||
owned_urls.add(state.module_url)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"House Plan could not prove ownership of the frontend fallback; "
|
||||
"it will remain until Home Assistant restarts"
|
||||
)
|
||||
state.fallback_added = True
|
||||
state.loader = "extra_module_url"
|
||||
|
||||
|
||||
def _remove_owned_fallback_url(
|
||||
hass: HomeAssistant,
|
||||
state: FrontendRegistrationState,
|
||||
module_url: str,
|
||||
) -> bool:
|
||||
"""Remove one exact integration-owned fallback when HA supports it."""
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if module_url not in owned_urls:
|
||||
return True
|
||||
remover = getattr(frontend, "remove_extra_js_url", None)
|
||||
if remover is None:
|
||||
return False
|
||||
try:
|
||||
remover(hass, module_url)
|
||||
except KeyError:
|
||||
# Another lifecycle path already removed the exact URL.
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
except Exception as err: # noqa: BLE001 - registry remains authoritative
|
||||
state.last_error = _safe_error("fallback_remove", err)
|
||||
_LOGGER.debug("Could not remove the House Plan frontend fallback", exc_info=True)
|
||||
return False
|
||||
owned_urls.discard(module_url)
|
||||
return True
|
||||
|
||||
|
||||
def _remove_fallback(
|
||||
hass: HomeAssistant, state: FrontendRegistrationState
|
||||
) -> bool:
|
||||
"""Remove this setup's exact fallback when the running HA supports it."""
|
||||
if state.module_url is None:
|
||||
return True
|
||||
owned_urls = _owned_fallback_urls(hass)
|
||||
if state.module_url in owned_urls:
|
||||
removed = _remove_owned_fallback_url(hass, state, state.module_url)
|
||||
state.fallback_added = not removed
|
||||
return removed
|
||||
|
||||
# A pre-existing/user-owned or uninspectable exact URL must never be
|
||||
# removed. Report the combined loader while it is known (or conservatively
|
||||
# assumed) to remain active in this HA document lifecycle.
|
||||
active = _active_extra_module_urls(hass)
|
||||
state.fallback_added = active is None or state.module_url in active
|
||||
return not state.fallback_added
|
||||
|
||||
|
||||
async def _async_create_reload_notice(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Create and persist the one-time localized hard-reload instruction."""
|
||||
# Initial fallback setup and the one-second retry may overlap when loading a
|
||||
# translation is slow. Serialize the persisted one-shot contract rather
|
||||
# than relying only on the stable notification ID (which prevents two UI
|
||||
# rows but would still perform and persist two creates).
|
||||
async with state._notice_lock:
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True:
|
||||
if state.first_reload_notice != "created":
|
||||
state.first_reload_notice = "already_created"
|
||||
return
|
||||
if (
|
||||
not _is_current(hass, state)
|
||||
or not state.card_file_present
|
||||
or not state.static_path_registered
|
||||
or state.loader == "none"
|
||||
):
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
return
|
||||
|
||||
title = _NOTICE_TITLE_FALLBACK
|
||||
description = _NOTICE_DESCRIPTION_FALLBACK
|
||||
try:
|
||||
translations = await async_get_translations(
|
||||
hass,
|
||||
hass.config.language,
|
||||
"issues",
|
||||
integrations={DOMAIN},
|
||||
)
|
||||
title = translations.get(_NOTICE_TITLE_KEY, title)
|
||||
description = translations.get(_NOTICE_DESCRIPTION_KEY, description)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - built-in English fallback is intentional
|
||||
_LOGGER.debug(
|
||||
"Could not load the House Plan reload notice translation",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
try:
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
description,
|
||||
title=title,
|
||||
notification_id=FRONTEND_RELOAD_NOTICE_ID,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - do not consume flag on failure
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not create the House Plan reload notice", exc_info=True
|
||||
)
|
||||
return
|
||||
try:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - roll back the unpersisted notice
|
||||
# A stable ID prevents duplicate rows, but dismissing the notice is
|
||||
# still important: without a durable flag, displaying it would make
|
||||
# System Health claim that the one-shot contract is pending while a
|
||||
# user-visible notice already exists. A later setup/retry can now
|
||||
# attempt the complete create+persist operation honestly.
|
||||
try:
|
||||
persistent_notification.async_dismiss(
|
||||
hass, FRONTEND_RELOAD_NOTICE_ID
|
||||
)
|
||||
except Exception: # noqa: BLE001 - stable ID remains a safe fallback
|
||||
_LOGGER.debug(
|
||||
"Could not roll back an unpersisted House Plan reload notice",
|
||||
exc_info=True,
|
||||
)
|
||||
state.first_reload_notice = "pending_frontend"
|
||||
_LOGGER.warning(
|
||||
"Could not persist the House Plan reload notice state",
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
state.first_reload_notice = "created"
|
||||
|
||||
|
||||
async def _async_retry_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Perform the single delayed resource-registry retry."""
|
||||
if not _is_current(hass, state) or state.module_url is None:
|
||||
return
|
||||
state.retry_pending = False
|
||||
state.retry_attempted = True
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
elif outcome.status == "yaml_fallback":
|
||||
state.resource_status = "yaml_fallback"
|
||||
state.last_error = outcome.last_error
|
||||
_add_fallback(hass, state)
|
||||
else:
|
||||
state.resource_status = "error_fallback"
|
||||
state.last_error = outcome.last_error or "registry_retry:Unavailable"
|
||||
_add_fallback(hass, state)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
|
||||
|
||||
def _schedule_retry(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
state: FrontendRegistrationState,
|
||||
) -> None:
|
||||
"""Schedule exactly one lifecycle-bound retry after HA start plus one second."""
|
||||
state.retry_pending = True
|
||||
|
||||
@callback
|
||||
def _after_started(_event=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_started = None
|
||||
|
||||
@callback
|
||||
def _after_delay(_now=None) -> None:
|
||||
if not _is_current(hass, state):
|
||||
return
|
||||
state._cancel_timer = None
|
||||
state._retry_task = entry.async_create_background_task(
|
||||
hass,
|
||||
_async_retry_registration(hass, entry, state),
|
||||
"houseplan frontend resource retry",
|
||||
)
|
||||
|
||||
state._cancel_timer = async_call_later(
|
||||
hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay
|
||||
)
|
||||
|
||||
state._cancel_started = async_at_started(hass, _after_started)
|
||||
|
||||
|
||||
async def async_setup_frontend_registration(
|
||||
hass: HomeAssistant,
|
||||
entry: HouseplanConfigEntry,
|
||||
card_path: Path,
|
||||
) -> FrontendRegistrationState:
|
||||
"""Register static/card resources without blocking the rest of entry setup."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
previous = domain_data.get(FRONTEND_REGISTRATION_KEY)
|
||||
if isinstance(previous, FrontendRegistrationState):
|
||||
previous.cancel()
|
||||
|
||||
card_file_present = card_path.is_file()
|
||||
module_url = f"{FRONTEND_URL}?v={VERSION}" if card_file_present else None
|
||||
active_fallbacks = _active_extra_module_urls(hass)
|
||||
fallback_added = module_url is not None and (
|
||||
module_url in _owned_fallback_urls(hass)
|
||||
or (active_fallbacks is not None and module_url in active_fallbacks)
|
||||
)
|
||||
state = FrontendRegistrationState(
|
||||
card_file_present=card_file_present,
|
||||
loader="extra_module_url" if fallback_added else "none",
|
||||
module_url=module_url,
|
||||
first_reload_notice=(
|
||||
"already_created"
|
||||
if entry.data.get(FRONTEND_RELOAD_NOTICE_DATA_KEY) is True
|
||||
else "pending_frontend"
|
||||
),
|
||||
fallback_added=fallback_added,
|
||||
)
|
||||
domain_data[FRONTEND_REGISTRATION_KEY] = state
|
||||
entry.async_on_unload(state.cancel)
|
||||
|
||||
if domain_data.get(FRONTEND_STATIC_REGISTERED_KEY):
|
||||
state.static_path_registered = True
|
||||
elif card_file_present:
|
||||
try:
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
except ImportError: # Home Assistant versions before the async API
|
||||
hass.http.register_static_path(
|
||||
FRONTEND_URL, str(card_path), cache_headers=False
|
||||
)
|
||||
else:
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False)]
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - setup continues without frontend
|
||||
state.last_error = _safe_error("static_path", err)
|
||||
_LOGGER.warning("Could not register the House Plan static path", exc_info=True)
|
||||
else:
|
||||
domain_data[FRONTEND_STATIC_REGISTERED_KEY] = True
|
||||
state.static_path_registered = True
|
||||
|
||||
if not card_file_present:
|
||||
_LOGGER.warning("houseplan-card.js was not found next to the integration")
|
||||
return state
|
||||
if not state.static_path_registered or state.module_url is None:
|
||||
return state
|
||||
|
||||
outcome = await async_register_lovelace_resource(
|
||||
hass,
|
||||
state.module_url,
|
||||
is_active=lambda: _is_current(hass, state),
|
||||
)
|
||||
if not _is_current(hass, state):
|
||||
return state
|
||||
state.resource_status = outcome.status
|
||||
state.last_error = outcome.last_error
|
||||
|
||||
if outcome.status in {"created", "updated", "existing"}:
|
||||
state.loader = (
|
||||
"lovelace_resource"
|
||||
if _remove_fallback(hass, state)
|
||||
else "lovelace_resource_with_session_fallback"
|
||||
)
|
||||
_LOGGER.info(
|
||||
"House Plan card auto-registered as a Lovelace resource: %s",
|
||||
state.module_url,
|
||||
)
|
||||
else:
|
||||
_add_fallback(hass, state)
|
||||
if outcome.status in {"registry_pending", "transient_error"}:
|
||||
_schedule_retry(hass, entry, state)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan card uses the frontend extra-module fallback: %s",
|
||||
state.module_url,
|
||||
)
|
||||
|
||||
await _async_create_reload_notice(hass, entry, state)
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
def get_frontend_registration_state(
|
||||
hass: HomeAssistant,
|
||||
) -> FrontendRegistrationState | None:
|
||||
"""Return current frontend registration state for System Health."""
|
||||
state = hass.data.get(DOMAIN, {}).get(FRONTEND_REGISTRATION_KEY)
|
||||
return state if isinstance(state, FrontendRegistrationState) else None
|
||||
|
||||
|
||||
async def async_remove_frontend_registration(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry
|
||||
) -> None:
|
||||
"""Remove all House Plan resource entries and its one-time notice."""
|
||||
state = get_frontend_registration_state(hass)
|
||||
if state is not None:
|
||||
state.cancel()
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
if domain_data.get(FRONTEND_REGISTRATION_KEY) is state:
|
||||
domain_data.pop(FRONTEND_REGISTRATION_KEY, None)
|
||||
|
||||
# Fallback ownership belongs to this HA run, not to the latest config-entry
|
||||
# generation. A bundle-missing reload can therefore have no current URL
|
||||
# while an earlier exact URL is still registered. Uninstall cleans every
|
||||
# URL that this integration itself added, and never pattern-matches foreign
|
||||
# extra-module entries.
|
||||
cleanup_state = state or FrontendRegistrationState(card_file_present=False)
|
||||
for module_url in tuple(_owned_fallback_urls(hass)):
|
||||
_remove_owned_fallback_url(hass, cleanup_state, module_url)
|
||||
|
||||
try:
|
||||
persistent_notification.async_dismiss(hass, FRONTEND_RELOAD_NOTICE_ID)
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not dismiss the House Plan reload notice", exc_info=True)
|
||||
|
||||
try:
|
||||
resources = _lovelace_resources(hass)
|
||||
if resources is None or not hasattr(resources, "async_delete_item"):
|
||||
return
|
||||
if hasattr(resources, "async_get_info"):
|
||||
await resources.async_get_info()
|
||||
for item in list(resources.async_items()):
|
||||
if str(item.get("url", "")).split("?", 1)[0] != FRONTEND_URL:
|
||||
continue
|
||||
try:
|
||||
await resources.async_delete_item(item["id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - continue best-effort cleanup
|
||||
_LOGGER.debug(
|
||||
"Could not remove one House Plan Lovelace resource",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
_LOGGER.debug("House Plan Lovelace resource removed")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - uninstall cleanup is best effort
|
||||
_LOGGER.debug("Could not remove the House Plan Lovelace resource", exc_info=True)
|
||||
@@ -5,30 +5,50 @@ breaks the connection on a large PDF) but via a plain multipart POST — like me
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
try: # KEY_HASS — the modern way to access hass from the aiohttp application
|
||||
from homeassistant.components.http import KEY_HASS
|
||||
except ImportError: # older HA versions
|
||||
KEY_HASS = "hass" # type: ignore[assignment]
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .asset_integrity import get_asset_integrity_verifier
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
FILES_DIR,
|
||||
MAX_DECOR_ASSET_BYTES,
|
||||
MAX_DECOR_ASSETS_BYTES,
|
||||
MAX_DECOR_ASSETS_COUNT,
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
MIN_FREE_BYTES,
|
||||
PLANS_DIR,
|
||||
)
|
||||
from .decor_assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_ID_RE,
|
||||
DecorAssetError,
|
||||
asset_meta_path,
|
||||
physical_asset_usage,
|
||||
public_asset,
|
||||
read_asset,
|
||||
validate_asset,
|
||||
)
|
||||
from .import_export import ImportFailure, create_preview
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
@@ -138,25 +158,38 @@ class HouseplanContentView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, kind: str, sub: str, name: str) -> web.StreamResponse:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if kind not in ("plans", "files"):
|
||||
if kind not in ("plans", "files", "assets"):
|
||||
return web.Response(status=404)
|
||||
safe_sub = sanitize_marker_id(sub)
|
||||
safe_name = sanitize_filename(name)
|
||||
if not safe_sub or not safe_name:
|
||||
return web.Response(status=404)
|
||||
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
|
||||
root_dir = PLANS_DIR if kind == "plans" else ASSETS_DIR if kind == "assets" else FILES_DIR
|
||||
base = Path(hass.config.path(root_dir)).resolve()
|
||||
# plans live flat in one directory: the sub segment is a placeholder ("_")
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
if kind == "assets":
|
||||
stem, suffix = Path(safe_name).stem, Path(safe_name).suffix.lower()
|
||||
if safe_sub != "_" or not ASSET_ID_RE.fullmatch(stem) or suffix not in ASSET_EXTENSIONS:
|
||||
return web.Response(status=404)
|
||||
path = (base / safe_name).resolve()
|
||||
else:
|
||||
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
|
||||
# defence in depth: the sanitizers already strip separators
|
||||
if not str(path).startswith(str(base)):
|
||||
return web.Response(status=404)
|
||||
|
||||
if not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
suffix = path.suffix.lower()
|
||||
if kind == "assets":
|
||||
verifier = get_asset_integrity_verifier(hass)
|
||||
if not await hass.async_add_executor_job(verifier.verify, path, path.stem):
|
||||
return web.Response(status=404)
|
||||
elif not await hass.async_add_executor_job(path.is_file):
|
||||
return web.Response(status=404)
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
"Cache-Control": "private, max-age=31536000, immutable"
|
||||
if kind == "assets" else "private, max-age=3600",
|
||||
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
if suffix == ".svg":
|
||||
# An uploaded SVG is user content served from Home Assistant's own
|
||||
@@ -182,6 +215,140 @@ class HouseplanContentView(HomeAssistantView):
|
||||
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
|
||||
|
||||
|
||||
class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
"""POST one bounded custom decor image and return its catalog row."""
|
||||
|
||||
url = "/api/houseplan/assets/upload"
|
||||
name = "api:houseplan:decor-asset-upload"
|
||||
requires_auth = True
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
runtime = get_data(hass)
|
||||
if runtime is None:
|
||||
return web.json_response({"error": "not_ready"}, status=503)
|
||||
if request.content_length is not None and request.content_length > MAX_DECOR_ASSET_BYTES + _FLUSH_AT:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
filename: str | None = None
|
||||
declared_mime: str | None = None
|
||||
blocks: list[bytes] = []
|
||||
size = 0
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name != "file":
|
||||
continue
|
||||
if filename is not None:
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
filename = part.filename or "image"
|
||||
declared_mime = part.headers.get("Content-Type")
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_DECOR_ASSET_BYTES:
|
||||
return web.json_response({"error": "too_large"}, status=413)
|
||||
blocks.append(chunk)
|
||||
except Exception as err: # noqa: BLE001 - malformed multipart boundary
|
||||
_LOGGER.warning("House Plan decor asset upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
if not filename:
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
|
||||
try:
|
||||
validated = await hass.async_add_executor_job(
|
||||
validate_asset, b"".join(blocks), filename, declared_mime,
|
||||
)
|
||||
except DecorAssetError as err:
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=413 if err.code == "too_large" else 400)
|
||||
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
|
||||
def _store() -> tuple[dict, bool]:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
existing = read_asset(root, validated.asset_id)
|
||||
if existing is not None:
|
||||
existing_blob = root / f"{validated.asset_id}{existing['ext']}"
|
||||
if hashlib.sha256(existing_blob.read_bytes()).hexdigest() != validated.asset_id:
|
||||
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
|
||||
return existing, True
|
||||
aid = validated.asset_id
|
||||
blob = root / f"{aid}{validated.ext}"
|
||||
meta = asset_meta_path(root, aid)
|
||||
safe_display = sanitize_filename(Path(filename).name) or "image"
|
||||
row = {
|
||||
"asset_id": aid, "name": safe_display, "mime": validated.mime,
|
||||
"ext": validated.ext, "width": validated.width, "height": validated.height,
|
||||
"bytes": len(validated.data), "created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
# A hard stop may have promoted the content-addressed blob before
|
||||
# its sidecar. Its exact path and digest prove that no new physical
|
||||
# bytes are needed, so repair remains possible even at full quota.
|
||||
if blob.exists():
|
||||
if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:
|
||||
raise DecorAssetError("invalid_image", "The stored image failed its integrity check")
|
||||
fd, meta_temp_name = tempfile.mkstemp(prefix=".asset-meta-", dir=str(root))
|
||||
os.close(fd)
|
||||
meta_temp = Path(meta_temp_name)
|
||||
try:
|
||||
with meta_temp.open("w", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(meta_temp, meta)
|
||||
return row, False
|
||||
finally:
|
||||
meta_temp.unlink(missing_ok=True)
|
||||
|
||||
count, used = physical_asset_usage(root)
|
||||
if count >= MAX_DECOR_ASSETS_COUNT or used + len(validated.data) > MAX_DECOR_ASSETS_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "The decor image store is full")
|
||||
try:
|
||||
if shutil.disk_usage(root).free - len(validated.data) < MIN_FREE_BYTES:
|
||||
raise DecorAssetError("capacity_exceeded", "Not enough free disk space")
|
||||
except OSError:
|
||||
pass
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".asset-", dir=str(root))
|
||||
os.close(fd)
|
||||
temp = Path(temp_name)
|
||||
meta_temp = temp.with_suffix(".json")
|
||||
promoted_blob = False
|
||||
try:
|
||||
with temp.open("wb") as stream:
|
||||
stream.write(validated.data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
with meta_temp.open("w", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp, blob)
|
||||
promoted_blob = True
|
||||
os.replace(meta_temp, meta)
|
||||
return row, False
|
||||
except Exception:
|
||||
# A normal I/O failure is fully transactional. A process hard
|
||||
# stop between the two renames is healed by the orphan branch
|
||||
# above on the next identical upload.
|
||||
if promoted_blob:
|
||||
blob.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
meta_temp.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
async with runtime.upload_lock:
|
||||
row, reused = await hass.async_add_executor_job(_store)
|
||||
except DecorAssetError as err:
|
||||
status = 507 if err.code == "capacity_exceeded" else 400
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=status)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan decor asset upload: store failed: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
return web.json_response({"ok": True, "reused": reused, "asset": public_asset(row)})
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from urllib.parse import urlsplit
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
EXPORT_VERSION,
|
||||
FILES_DIR,
|
||||
@@ -51,6 +52,7 @@ from .validation import (
|
||||
sanitize_marker_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_vacuum_routes,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
@@ -73,6 +75,13 @@ _IMPORT_ID_NAMESPACES = {
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
_DECOR_ASSET_MIME_BY_SUFFIX = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
_DECOR_ASSET_MIME_TYPES = frozenset(_DECOR_ASSET_MIME_BY_SUFFIX.values())
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
@@ -97,6 +106,7 @@ _DECOR_KIND_FIELDS = {
|
||||
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
|
||||
"image": ("asset_id", "x", "y", "w", "h", "angle", "flip_h", "flip_v"),
|
||||
}
|
||||
|
||||
|
||||
@@ -389,6 +399,12 @@ def _internal_path(root: Path, url: str) -> tuple[str, Path] | None:
|
||||
if name != raw_name:
|
||||
return None
|
||||
return "plan", root / PLANS_DIR / name
|
||||
asset_prefix = CONTENT_URL + "/assets/_/"
|
||||
if url.startswith(asset_prefix):
|
||||
raw_name = url[len(asset_prefix):]
|
||||
if not re.fullmatch(r"[0-9a-f]{64}\.(?:png|jpg|webp|svg)", raw_name):
|
||||
return None
|
||||
return "decor_asset", root / ASSETS_DIR / raw_name
|
||||
for prefix in (CONTENT_URL + "/files/", FILES_URL + "/"):
|
||||
if url.startswith(prefix):
|
||||
tail = url[len(prefix):].split("/")
|
||||
@@ -433,6 +449,41 @@ def content_manifest(config: dict[str, Any], config_root: Path) -> list[dict[str
|
||||
"attachment", "marker", str(marker.get("id")),
|
||||
f"pdfs[{index}].url", pdf.get("url") if isinstance(pdf, dict) else None,
|
||||
)
|
||||
# Asset bytes are deliberately not embedded. The manifest records identity
|
||||
# and availability so a cross-instance import can preserve the image box as
|
||||
# a repairable editor placeholder instead of silently deleting geometry.
|
||||
assets_root = config_root / ASSETS_DIR
|
||||
for space in config.get("spaces") or []:
|
||||
for shape in space.get("decor") or []:
|
||||
if not isinstance(shape, dict) or shape.get("kind") != "image":
|
||||
continue
|
||||
aid = shape.get("asset_id")
|
||||
if not isinstance(aid, str) or not re.fullmatch(r"[0-9a-f]{64}", aid):
|
||||
continue
|
||||
matches = sorted(assets_root.glob(f"{aid}.*")) if assets_root.is_dir() else []
|
||||
blob = next((path for path in matches if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None)
|
||||
metadata: dict[str, Any] = {}
|
||||
try:
|
||||
metadata = json.loads((assets_root / f"{aid}.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
exists = False
|
||||
if blob and blob.is_file():
|
||||
try:
|
||||
exists = hashlib.sha256(blob.read_bytes()).hexdigest() == aid
|
||||
except OSError:
|
||||
pass
|
||||
out.append({
|
||||
"kind": "decor_asset", "owner": "decor",
|
||||
"owner_id": f"{space.get('id', '')}:{shape.get('id', '')}", "field": "asset_id",
|
||||
# Identity is extension-neutral: a missing target must compute
|
||||
# exactly the same expected manifest as the source.
|
||||
"url": aid, "asset_id": aid, "storage": "internal",
|
||||
"mime": metadata.get("mime") or _DECOR_ASSET_MIME_BY_SUFFIX.get(
|
||||
blob.suffix if blob else ""
|
||||
),
|
||||
"hash": aid, "exists_at_export": exists,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -525,6 +576,23 @@ def create_export(
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
# #162: a robot map routed into ANOTHER space cannot travel
|
||||
# with a single-space export — its target would not exist on
|
||||
# import, and a route with a dead space resolves to nothing.
|
||||
# Same neutralisation contract as the marker refs above: drop
|
||||
# it and count it, so the preview states the loss up front.
|
||||
vacuum = marker.get("vacuum")
|
||||
routes = vacuum.get("map_routes") if isinstance(vacuum, dict) else None
|
||||
if isinstance(routes, list):
|
||||
kept_routes = [
|
||||
route for route in routes
|
||||
if isinstance(route, dict) and str(route.get("space")) == str(space_id)
|
||||
]
|
||||
dropped_marker_links += len(routes) - len(kept_routes)
|
||||
# An explicit list is route authority even when filtering
|
||||
# leaves it empty. Turning [] into None would revive any
|
||||
# legacy calibration dictionary after import (#443).
|
||||
vacuum["map_routes"] = kept_routes
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
@@ -593,7 +661,7 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
raise ImportFailure("invalid_json", str(err)) from err
|
||||
if not isinstance(document, dict) or document.get("format") != FORMAT:
|
||||
raise ImportFailure("invalid_format", "Not a House Plan export")
|
||||
if document.get("export_version") != EXPORT_VERSION:
|
||||
if document.get("export_version") not in (1, EXPORT_VERSION):
|
||||
raise ImportFailure("unsupported_export_version", "Unsupported export version")
|
||||
if document.get("kind") not in ("full", "space"):
|
||||
raise ImportFailure("invalid_format", "Export kind must be full or space")
|
||||
@@ -759,7 +827,11 @@ def _validate_plan_only_document(
|
||||
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
|
||||
content = document.get("content_manifest")
|
||||
if not isinstance(content, list) or any(
|
||||
not isinstance(item, dict) or item.get("owner") != "space"
|
||||
not isinstance(item, dict)
|
||||
or not (
|
||||
item.get("owner") == "space"
|
||||
or item.get("owner") == "decor" and item.get("kind") == "decor_asset"
|
||||
)
|
||||
for item in content
|
||||
):
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private content")
|
||||
@@ -1526,6 +1598,7 @@ def build_space_merge(
|
||||
validate_marker_controls(merged_config, current_config)
|
||||
validate_marker_light_entities(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)
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (
|
||||
@@ -1596,7 +1669,37 @@ def _content_state(document: dict[str, Any], same_source: bool, config_root: Pat
|
||||
for item in expected:
|
||||
row = dict(item)
|
||||
declared = supplied_by_id[identity(item)]
|
||||
row["exists_at_export"] = declared.get("exists_at_export")
|
||||
declared_exists = declared.get("exists_at_export")
|
||||
row["exists_at_export"] = declared_exists
|
||||
if item.get("kind") == "decor_asset":
|
||||
aid = str(item.get("asset_id") or item.get("url") or "")
|
||||
declared_mime = declared.get("mime")
|
||||
valid_mime = (
|
||||
isinstance(declared_mime, str)
|
||||
and declared_mime in _DECOR_ASSET_MIME_TYPES
|
||||
)
|
||||
missing_mime = declared_exists is False and declared_mime is None
|
||||
if (declared.get("asset_id") != aid or declared.get("hash") != aid
|
||||
or type(declared_exists) is not bool
|
||||
or not (valid_mime or missing_mime)):
|
||||
raise ImportFailure("invalid_content", "Invalid decor asset manifest row")
|
||||
# Preserve the source description for preview/digest purposes. It
|
||||
# never selects or serves target bytes; the exact local hash below
|
||||
# remains authoritative for availability.
|
||||
row["mime"] = declared_mime
|
||||
candidates = sorted((config_root / ASSETS_DIR).glob(f"{aid}.*"))
|
||||
blob = next((path for path in candidates if path.suffix in {".png", ".jpg", ".webp", ".svg"}), None)
|
||||
exists = False
|
||||
if blob:
|
||||
try:
|
||||
exists = hashlib.sha256(blob.read_bytes()).hexdigest() == aid
|
||||
except OSError:
|
||||
pass
|
||||
row["exists_on_target"] = exists
|
||||
row["state"] = "available" if exists else "missing_preserved"
|
||||
confirmation = confirmation or not exists
|
||||
rows.append(row)
|
||||
continue
|
||||
internal = _internal_path(config_root, item["url"])
|
||||
if _looks_internal(item["url"]) and internal is None:
|
||||
raise ImportFailure("invalid_content", "Non-canonical internal content URL")
|
||||
@@ -1791,6 +1894,7 @@ def create_preview(
|
||||
validate_marker_controls(incoming_config, validate_all=True)
|
||||
validate_marker_light_entities(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
|
||||
content, confirmation = _content_state(document, same_source, config_root)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Matysh"
|
||||
],
|
||||
@@ -16,5 +19,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.70.0"
|
||||
"version": "1.72.0"
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ _LANGUAGES = frozenset({"en", "ru", "de", "fr"})
|
||||
_BROWSERS = frozenset({"chromium", "firefox", "webkit", "unknown"})
|
||||
_REGISTRY_ACCESS = frozenset({"full", "partial", "unavailable"})
|
||||
_REGISTRY_AGE = frozenset({"fresh", "stale", "unknown"})
|
||||
_VALUE_BADGE_POSITIONS = frozenset({"right", "bottom", "left", "top"})
|
||||
|
||||
|
||||
class SupportPackageError(ValueError):
|
||||
@@ -139,6 +140,9 @@ 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):
|
||||
return out
|
||||
show_room_tooltip = value.get("show_room_tooltip")
|
||||
if isinstance(show_room_tooltip, bool):
|
||||
out["show_room_tooltip"] = show_room_tooltip
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
out["fill_colors"] = {
|
||||
@@ -232,6 +236,23 @@ def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | N
|
||||
return None
|
||||
|
||||
|
||||
def _project_value_badge(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
|
||||
"""Project the compatibility-tolerant badge config into safe scalar fields."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
enabled = value.get("enabled")
|
||||
if isinstance(enabled, bool):
|
||||
out["enabled"] = enabled
|
||||
position = value.get("position")
|
||||
if isinstance(position, str) and position in _VALUE_BADGE_POSITIONS:
|
||||
out["position"] = position
|
||||
source = _project_value_source(ids, value.get("source"))
|
||||
if source is not None:
|
||||
out["source"] = source
|
||||
return out or None
|
||||
|
||||
|
||||
def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
binding_kind, binding = _binding(ids, marker.get("binding"))
|
||||
out: dict[str, Any] = {
|
||||
@@ -264,12 +285,8 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
projected = _project_value_source(ids, marker.get(key))
|
||||
if projected:
|
||||
out[key] = projected
|
||||
badge = marker.get("value_badge")
|
||||
if isinstance(badge, dict):
|
||||
safe_badge = _copy_keys(badge, ("enabled", "position"))
|
||||
source = _project_value_source(ids, badge.get("source"))
|
||||
if source:
|
||||
safe_badge["source"] = source
|
||||
safe_badge = _project_value_badge(ids, marker.get("value_badge"))
|
||||
if safe_badge is not None:
|
||||
out["value_badge"] = safe_badge
|
||||
return out
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ async def async_submit_report(
|
||||
idempotency_key: str,
|
||||
attachment: bytes | None,
|
||||
attachment_sha256: str | None,
|
||||
filename_token: str,
|
||||
) -> str:
|
||||
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
|
||||
target = urlsplit(SUPPORT_RELAY_URL)
|
||||
@@ -56,9 +55,10 @@ async def async_submit_report(
|
||||
content_type="application/json",
|
||||
)
|
||||
if attachment is not None:
|
||||
filename_sha256 = str(attachment_sha256 or "")
|
||||
form.add_field(
|
||||
"attachment", attachment,
|
||||
filename=f"houseplan-support-{filename_token[:32]}.json",
|
||||
filename=f"houseplan-support-{filename_sha256[:12]}.json",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .frontend_registration import get_frontend_registration_state
|
||||
from .store import get_data
|
||||
|
||||
|
||||
@@ -23,7 +24,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
cfg_raw = await data.config_store.async_load() or {}
|
||||
layout_raw = await data.store.async_load() or {}
|
||||
config = cfg_raw.get("config", {})
|
||||
return {
|
||||
result = {
|
||||
"config_rev": cfg_raw.get("rev", 0),
|
||||
"spaces": len(config.get("spaces", [])),
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
@@ -33,3 +34,42 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
frontend = get_frontend_registration_state(hass)
|
||||
if frontend is None:
|
||||
result.update(
|
||||
{
|
||||
"card_file": "missing",
|
||||
"static_path": "not_registered",
|
||||
"resource_status": "not_attempted",
|
||||
"resource_loader": "none",
|
||||
"resource_url": "unavailable",
|
||||
"resource_retry": "not_needed",
|
||||
"resource_error": "none",
|
||||
"first_reload_notice": "pending_frontend",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
result.update(
|
||||
{
|
||||
"card_file": "present" if frontend.card_file_present else "missing",
|
||||
"static_path": (
|
||||
"registered"
|
||||
if frontend.static_path_registered
|
||||
else "not_registered"
|
||||
),
|
||||
"resource_status": frontend.resource_status,
|
||||
"resource_loader": frontend.loader,
|
||||
"resource_url": frontend.module_url or "unavailable",
|
||||
"resource_retry": (
|
||||
"pending"
|
||||
if frontend.retry_pending
|
||||
else "attempted"
|
||||
if frontend.retry_attempted
|
||||
else "not_needed"
|
||||
),
|
||||
"resource_error": frontend.last_error or "none",
|
||||
"first_reload_notice": frontend.first_reload_notice,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.helpers.event import async_call_later, async_track_state_chan
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
from .vacuum_routes import effective_routes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,14 +32,31 @@ FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
|
||||
MOVING_STATES = {"cleaning", "returning", "on"}
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
def same_run_identity(run: Any, route_id: str, map_id: str) -> bool:
|
||||
"""Whether a stored run and a fresh point belong to the same cleanup.
|
||||
|
||||
Route identity wins when both sides have one: two maps of one robot may
|
||||
share a map id only across different sources, and a route change (new
|
||||
source, map or target space) is a new run by definition (#162). A run
|
||||
written before #162 carries no route id, so it is still matched by map id
|
||||
and keeps resuming exactly as it used to.
|
||||
"""
|
||||
if not isinstance(run, dict):
|
||||
return False
|
||||
stored_route = run.get("route_id")
|
||||
if route_id and isinstance(stored_route, str) and stored_route:
|
||||
return stored_route == route_id
|
||||
return run.get("map_id") == map_id
|
||||
|
||||
|
||||
def can_resume_trail_run(run: Any, map_id: str, now: float, route_id: str = "") -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
if not same_run_identity(run, route_id, map_id):
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
@@ -84,18 +102,27 @@ class TrailBook:
|
||||
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
||||
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
|
||||
|
||||
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
|
||||
def on_point(
|
||||
self, marker: str, map_id: str, x: float, y: float, now: float,
|
||||
route_id: str = "", source: str = "",
|
||||
) -> bool:
|
||||
rec = self.data.setdefault(marker, {})
|
||||
cur = rec.get("current")
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now, route_id))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
if not cur or cur.get("ended") is not None or not same_run_identity(cur, route_id, map_id):
|
||||
# a new run begins: the old one becomes "previous" (and the one
|
||||
# before it is forgotten — we keep exactly two, per the owner)
|
||||
if cur:
|
||||
rec["previous"] = cur
|
||||
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
|
||||
# #162: the run remembers WHICH route wrote it, so the card can put
|
||||
# it on the right floor without re-deriving the routing itself.
|
||||
if route_id:
|
||||
cur["route_id"] = route_id
|
||||
if source:
|
||||
cur["source"] = source
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
@@ -122,11 +149,33 @@ class TrailBook:
|
||||
"""Forget every stored run of one plan marker."""
|
||||
return self.data.pop(marker, None) is not None
|
||||
|
||||
def drop_unknown_routes(self, marker: str, route_ids: set[str]) -> bool:
|
||||
"""Forget runs whose route no longer exists (deleted or re-targeted).
|
||||
|
||||
Only runs that name a route are touched. A pre-#162 run names none and
|
||||
is adopted by the card instead — deleting it here would destroy history
|
||||
the user can still legitimately see.
|
||||
"""
|
||||
rec = self.data.get(marker)
|
||||
if not isinstance(rec, dict):
|
||||
return False
|
||||
changed = False
|
||||
for slot in ("current", "previous"):
|
||||
run = rec.get(slot)
|
||||
if not isinstance(run, dict):
|
||||
continue
|
||||
stored = run.get("route_id")
|
||||
if isinstance(stored, str) and stored and stored not in route_ids:
|
||||
rec.pop(slot)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
class TrailRecorder:
|
||||
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
|
||||
self.routes_by_marker: dict[str, list[dict[str, Any]]] = {}
|
||||
self.hass = hass
|
||||
self.rt = rt
|
||||
self.store = Store(hass, 1, f"{DOMAIN}.trails")
|
||||
@@ -166,22 +215,35 @@ class TrailRecorder:
|
||||
cfg = stored.get("config") or {}
|
||||
pairs: dict[str, list[tuple[str, str]]] = {}
|
||||
health_pairs: set[tuple[str, str]] = set()
|
||||
routes_by_marker: dict[str, list[dict[str, Any]]] = {}
|
||||
for m in cfg.get("markers") or []:
|
||||
if m.get("removed") is True:
|
||||
continue
|
||||
v = m.get("vacuum") or {}
|
||||
src = v.get("source")
|
||||
if not src or v.get("live") is False:
|
||||
if v.get("live") is False:
|
||||
continue
|
||||
marker_id = str(m.get("id"))
|
||||
health_pairs.add((marker_id, str(src)))
|
||||
# #162: a multi-floor robot may publish each map through its own
|
||||
# camera, so every route source is watched — not only the root
|
||||
# discovery source, which is now just one of them.
|
||||
routes = effective_routes(marker_id, v, str(m.get("space") or ""), src)
|
||||
routes_by_marker[marker_id] = routes
|
||||
sources = {str(route["source"]) for route in routes if route.get("source")}
|
||||
if src:
|
||||
sources.add(str(src))
|
||||
if not sources:
|
||||
continue
|
||||
vac = self._vacuum_entity(m)
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(src, []).append((marker_id, vac))
|
||||
for source in sorted(sources):
|
||||
health_pairs.add((marker_id, source))
|
||||
if vac:
|
||||
# HP-1540-03: append, never overwrite — every floor's
|
||||
# marker records its own copy of the run
|
||||
pairs.setdefault(source, []).append((marker_id, vac))
|
||||
self._refresh_source_health(health_pairs)
|
||||
self.pairs = pairs
|
||||
self.routes_by_marker = routes_by_marker
|
||||
self._resubscribe()
|
||||
# A run already in progress (HA restarted mid-cleanup, or the user
|
||||
# just finished calibrating) must start recording NOW, not at the
|
||||
@@ -261,6 +323,17 @@ class TrailRecorder:
|
||||
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.
|
||||
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:
|
||||
continue
|
||||
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
|
||||
@@ -381,9 +454,26 @@ class TrailRecorder:
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
map_id = resolve_map_id(attrs, st_vac.attributes)
|
||||
changed |= self.book.on_point(marker, map_id, x, y, now)
|
||||
route_id = self._route_id(marker, src, map_id)
|
||||
changed |= self.book.on_point(
|
||||
marker, map_id, x, y, now, route_id=route_id,
|
||||
source=src if route_id else "",
|
||||
)
|
||||
return changed
|
||||
|
||||
def _route_id(self, marker: str, source: str, map_id: str) -> str:
|
||||
"""The route this point belongs to, or "" when routing cannot say.
|
||||
|
||||
Validation keeps (source, map_id) unique inside one marker, so at most
|
||||
one route can match. No match means the map is unmapped: the point is
|
||||
still recorded — raw history is valuable — but it is not filed under a
|
||||
route that does not own it.
|
||||
"""
|
||||
for route in getattr(self, "routes_by_marker", {}).get(marker) or ():
|
||||
if route.get("source") == source and route.get("map_id") == map_id:
|
||||
return str(route.get("id") or "")
|
||||
return ""
|
||||
|
||||
@callback
|
||||
def _on_state(self, event: Any) -> None:
|
||||
eid = event.data.get("entity_id")
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Grundrissbild fehlt",
|
||||
"description": "Die Plandatei `{file}` für den Bereich `{space}` wurde in `config/houseplan/plans/` nicht gefunden. In den Bereichseinstellungen der House-Plan-Karte den Plan erneut hochladen."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House-Plan-Karte verbunden",
|
||||
"description": "Die House-Plan-Karte ist verbunden. Laden Sie diese Seite vollständig neu, um die aktuelle Benutzeroberfläche zu verwenden (`Ctrl+F5` unter Windows/Linux oder `Cmd+Shift+R` unter macOS). Wenn Sie Dashboard-Ressourcen im Speichermodus manuell verwalten, öffnen Sie Einstellungen → Dashboards → Ressourcen."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Datei der House-Plan-Karte",
|
||||
"static_path": "Statischer Frontend-Pfad",
|
||||
"resource_status": "Status der Ressourcenregistrierung",
|
||||
"resource_loader": "Lademethode der Frontend-Ressource",
|
||||
"resource_url": "URL der Frontend-Ressource",
|
||||
"resource_retry": "Wiederholung der Ressourcenregistrierung",
|
||||
"resource_error": "Fehler der Frontend-Registrierung",
|
||||
"first_reload_notice": "Erster Hinweis zum Neuladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Floor plan image is missing",
|
||||
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "House Plan card connected",
|
||||
"description": "The House Plan card is connected. Fully reload this page to use the latest frontend (`Ctrl+F5` on Windows/Linux or `Cmd+Shift+R` on macOS). If you manage dashboard resources manually in storage mode, open Settings → Dashboards → Resources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "House Plan card file",
|
||||
"static_path": "Static frontend path",
|
||||
"resource_status": "Resource registration status",
|
||||
"resource_loader": "Frontend resource loader",
|
||||
"resource_url": "Frontend resource URL",
|
||||
"resource_retry": "Resource registration retry",
|
||||
"resource_error": "Frontend registration error",
|
||||
"first_reload_notice": "First reload notice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "L’image du plan est introuvable",
|
||||
"description": "Le fichier de plan `{file}` pour l’espace `{space}` est introuvable dans `config/houseplan/plans/`. Ouvrez les paramètres de l’espace dans la carte House Plan et téléversez de nouveau le plan."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Carte House Plan connectée",
|
||||
"description": "La carte House Plan est connectée. Rechargez complètement cette page pour utiliser l’interface la plus récente (`Ctrl+F5` sous Windows/Linux ou `Cmd+Shift+R` sous macOS). Si vous gérez manuellement les ressources du tableau de bord en mode stockage, ouvrez Paramètres → Tableaux de bord → Ressources."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Fichier de la carte House Plan",
|
||||
"static_path": "Chemin statique de l’interface",
|
||||
"resource_status": "État de l’enregistrement de la ressource",
|
||||
"resource_loader": "Mode de chargement de la ressource",
|
||||
"resource_url": "URL de la ressource de l’interface",
|
||||
"resource_retry": "Nouvelle tentative d’enregistrement",
|
||||
"resource_error": "Erreur d’enregistrement de l’interface",
|
||||
"first_reload_notice": "Premier avis de rechargement"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
"broken_plan": {
|
||||
"title": "Файл плана этажа не найден",
|
||||
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
|
||||
},
|
||||
"frontend_reload_notice": {
|
||||
"title": "Карточка House Plan подключена",
|
||||
"description": "Карточка House Plan подключена. Полностью перезагрузите эту страницу, чтобы использовать актуальный интерфейс (`Ctrl+F5` в Windows/Linux или `Cmd+Shift+R` в macOS). Если вы вручную управляете ресурсами панели в режиме хранения, откройте Настройки → Панели → Ресурсы."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"card_file": "Файл карточки House Plan",
|
||||
"static_path": "Статический путь интерфейса",
|
||||
"resource_status": "Статус регистрации ресурса",
|
||||
"resource_loader": "Способ загрузки ресурса",
|
||||
"resource_url": "URL ресурса интерфейса",
|
||||
"resource_retry": "Повтор регистрации ресурса",
|
||||
"resource_error": "Ошибка регистрации интерфейса",
|
||||
"first_reload_notice": "Первое уведомление о перезагрузке"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Map-to-space routing for multi-floor vacuums (#162).
|
||||
|
||||
Python mirror of ``src/vacuum-routes.ts``. The recorder and the import/export
|
||||
flows must answer "which floor is this map on?" exactly the way the card does,
|
||||
so both sides are driven by the same shared fixtures in
|
||||
``test/fixtures/vacuum-routes/``. Keep the two files in step: a divergence here
|
||||
shows up as a robot drawn on the wrong floor, which is worse than no robot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
VAC_ROUTE_LIMIT = 32
|
||||
VAC_ROUTE_ID_MAX = 128
|
||||
VAC_ROUTE_SOURCE_MAX = 255
|
||||
VAC_ROUTE_MAP_ID_MAX = 255
|
||||
VAC_ROUTE_ERROR = "invalid_vacuum_map_route"
|
||||
|
||||
_ENTITY_ID = re.compile(r"^[a-z_]+\.[a-zA-Z0-9_]+$")
|
||||
|
||||
|
||||
def normalize_route_matrix(raw: Any) -> list[float] | None:
|
||||
"""Six finite numbers, or nothing; a five-number matrix is not almost fine."""
|
||||
if not isinstance(raw, (list, tuple)) or len(raw) != 6:
|
||||
return None
|
||||
out: list[float] = []
|
||||
for value in raw:
|
||||
# bool is an int in Python; a boolean is not a coordinate.
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
number = float(value)
|
||||
if number != number or number in (float("inf"), float("-inf")):
|
||||
return None
|
||||
out.append(number)
|
||||
return out
|
||||
|
||||
|
||||
def is_entity_id_like(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(_ENTITY_ID.match(value))
|
||||
|
||||
|
||||
def validate_marker_routes(
|
||||
marker_id: str, routes: Any, space_ids: set[str] | None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Shape/uniqueness/reference issues for one marker's routes.
|
||||
|
||||
``space_ids`` None means the spaces are not known in this context (import
|
||||
preview), so the referential check is skipped instead of failing every
|
||||
route.
|
||||
"""
|
||||
if routes is None:
|
||||
return []
|
||||
|
||||
def issue(route_id: str, reason: str) -> dict[str, str]:
|
||||
return {
|
||||
"code": VAC_ROUTE_ERROR, "markerId": marker_id,
|
||||
"routeId": route_id, "reason": reason,
|
||||
}
|
||||
|
||||
if not isinstance(routes, list):
|
||||
return [issue("", "not_object")]
|
||||
problems: list[dict[str, str]] = []
|
||||
if len(routes) > VAC_ROUTE_LIMIT:
|
||||
problems.append(issue("", "limit"))
|
||||
seen_ids: set[str] = set()
|
||||
seen_identity: set[str] = set()
|
||||
for raw in routes:
|
||||
if not isinstance(raw, dict):
|
||||
problems.append(issue("", "not_object"))
|
||||
continue
|
||||
route_id = raw.get("id") if isinstance(raw.get("id"), str) else ""
|
||||
if not route_id or len(route_id) > VAC_ROUTE_ID_MAX:
|
||||
problems.append(issue(route_id or "", "id"))
|
||||
continue
|
||||
if route_id in seen_ids:
|
||||
problems.append(issue(route_id, "duplicate_id"))
|
||||
seen_ids.add(route_id)
|
||||
source = raw.get("source")
|
||||
if not is_entity_id_like(source) or len(source) > VAC_ROUTE_SOURCE_MAX:
|
||||
problems.append(issue(route_id, "source"))
|
||||
map_id = raw.get("map_id")
|
||||
# An empty map id is a real id (resolve_map_id): type and length only.
|
||||
if not isinstance(map_id, str) or len(map_id) > VAC_ROUTE_MAP_ID_MAX:
|
||||
problems.append(issue(route_id, "map_id"))
|
||||
space = raw.get("space")
|
||||
if not isinstance(space, str) or not space:
|
||||
problems.append(issue(route_id, "space"))
|
||||
elif space_ids is not None and space not in space_ids:
|
||||
problems.append(issue(route_id, "unknown_space"))
|
||||
calibration = raw.get("calibration")
|
||||
if calibration is not None and normalize_route_matrix(calibration) is None:
|
||||
problems.append(issue(route_id, "calibration"))
|
||||
if isinstance(source, str) and isinstance(map_id, str):
|
||||
identity = source + " " + map_id
|
||||
if identity in seen_identity:
|
||||
problems.append(issue(route_id, "duplicate_identity"))
|
||||
seen_identity.add(identity)
|
||||
return problems
|
||||
|
||||
|
||||
def legacy_route_id(marker_id: str, source: str, map_id: str) -> str:
|
||||
return "legacy:" + marker_id + " " + source + " " + map_id
|
||||
|
||||
|
||||
def effective_routes(
|
||||
marker_id: str, vacuum: Any, dock_space: str, discovered_source: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The routes a marker effectively has, legacy calibration included."""
|
||||
if not isinstance(vacuum, dict):
|
||||
return []
|
||||
explicit = vacuum.get("map_routes")
|
||||
if isinstance(explicit, list):
|
||||
out = []
|
||||
for route in explicit:
|
||||
if not isinstance(route, dict) or not isinstance(route.get("id"), str):
|
||||
continue
|
||||
if not route["id"]:
|
||||
continue
|
||||
out.append({
|
||||
"id": route["id"],
|
||||
"source": str(route.get("source") or ""),
|
||||
"map_id": str(route.get("map_id") or ""),
|
||||
"space": str(route.get("space") or ""),
|
||||
"calibration": normalize_route_matrix(route.get("calibration")),
|
||||
})
|
||||
return out
|
||||
source = vacuum.get("source")
|
||||
if not isinstance(source, str) or not source:
|
||||
source = discovered_source or ""
|
||||
if not source:
|
||||
return []
|
||||
calibration = vacuum.get("calibration")
|
||||
if not isinstance(calibration, dict):
|
||||
return []
|
||||
out = []
|
||||
for map_id, matrix in calibration.items():
|
||||
normalized = normalize_route_matrix(matrix)
|
||||
if normalized is None:
|
||||
continue
|
||||
out.append({
|
||||
"id": legacy_route_id(marker_id, source, str(map_id)),
|
||||
"source": source, "map_id": str(map_id), "space": dock_space,
|
||||
"calibration": normalized,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def resolve_route(
|
||||
routes: list[dict[str, Any]], observed: dict[str, str], space_ids: set[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pick the one route the robot is on right now — or refuse to pick.
|
||||
|
||||
List order must never decide a floor: two plausible routes are ambiguous,
|
||||
not "the first one".
|
||||
"""
|
||||
matched: list[dict[str, Any]] = []
|
||||
saw_telemetry = False
|
||||
fallback_source = ""
|
||||
fallback_map_id = ""
|
||||
for route in routes:
|
||||
source = route.get("source")
|
||||
if source not in observed:
|
||||
continue
|
||||
seen = observed[source]
|
||||
saw_telemetry = True
|
||||
if not fallback_source:
|
||||
fallback_source = source
|
||||
fallback_map_id = seen
|
||||
if seen == route.get("map_id"):
|
||||
matched.append(route)
|
||||
if len(matched) > 1:
|
||||
return {"kind": "ambiguous", "routeIds": sorted(str(r.get("id")) for r in matched)}
|
||||
if len(matched) == 1:
|
||||
route = matched[0]
|
||||
if space_ids is not None and route.get("space") not in space_ids:
|
||||
return {"kind": "missing_space", "route": route}
|
||||
matrix = normalize_route_matrix(route.get("calibration"))
|
||||
if matrix is None:
|
||||
return {"kind": "needs_calibration", "route": route}
|
||||
return {"kind": "ready", "route": {**route, "calibration": matrix}}
|
||||
if saw_telemetry:
|
||||
return {"kind": "unmapped", "source": fallback_source, "mapId": fallback_map_id}
|
||||
if observed:
|
||||
source = sorted(observed)[0]
|
||||
return {"kind": "unmapped", "source": source, "mapId": observed[source]}
|
||||
return {"kind": "none"}
|
||||
|
||||
|
||||
def adopt_legacy_run(
|
||||
run: Any, routes: list[dict[str, Any]], root_source: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Where a run recorded before #162 belongs (spec 11.3.1).
|
||||
|
||||
Such a run stores only ``{map_id, started, ended, points}``: it carries no
|
||||
source at all. The one surviving witness of the subscription that wrote it
|
||||
is the marker's root ``vacuum.source``, so that is what narrows the
|
||||
candidates; without it only the map id is compared. Two candidates mean the
|
||||
run is not drawn — not that the first one wins.
|
||||
"""
|
||||
if not isinstance(run, dict) or not isinstance(run.get("map_id"), str):
|
||||
return {"kind": "orphan_run"}
|
||||
map_id = run["map_id"]
|
||||
root = root_source if isinstance(root_source, str) else ""
|
||||
candidates = [route for route in routes if route.get("map_id") == map_id]
|
||||
if root:
|
||||
candidates = [route for route in candidates if route.get("source") == root]
|
||||
if len(candidates) == 1:
|
||||
return {"kind": "adopted", "route": candidates[0]}
|
||||
if not candidates:
|
||||
return {"kind": "orphan_run"}
|
||||
return {"kind": "ambiguous_run", "routeIds": sorted(str(r.get("id")) for r in candidates)}
|
||||
@@ -17,6 +17,7 @@ from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
from custom_components.houseplan.vacuum_routes import validate_marker_routes
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
@@ -882,6 +883,50 @@ def validate_marker_value_badges(
|
||||
validate_source(source, "value_source")
|
||||
|
||||
|
||||
def validate_marker_vacuum_routes(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate changed vacuum map routes; dormant future data round-trips.
|
||||
|
||||
Change-aware like the badge validator above (#162): an untouched legacy or
|
||||
future-shaped block must not block an unrelated save, but any edit to
|
||||
routing has to leave the marker in a state the resolver can answer for —
|
||||
unique identities, six-number matrices and target spaces that exist.
|
||||
"""
|
||||
markers = config.get("markers") or []
|
||||
spaces = config.get("spaces")
|
||||
space_ids = None
|
||||
if isinstance(spaces, list):
|
||||
space_ids = {
|
||||
str(space.get("id")) for space in spaces
|
||||
if isinstance(space, dict) and isinstance(space.get("id"), str)
|
||||
}
|
||||
old_markers = (previous or {}).get("markers") or []
|
||||
old_by_id = {str(marker.get("id")): marker for marker in old_markers}
|
||||
by_id = {str(marker.get("id")): marker for marker in markers}
|
||||
new_ids = set(by_id)
|
||||
consumed_old_ids: set[str] = set()
|
||||
for marker_id, marker in by_id.items():
|
||||
old_marker = _matching_previous_marker(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
routes = (marker.get("vacuum") or {}).get("map_routes")
|
||||
old_routes = None if validate_all else ((old_marker or {}).get("vacuum") or {}).get("map_routes")
|
||||
if not validate_all and routes == old_routes:
|
||||
continue
|
||||
problems = validate_marker_routes(marker_id, routes, space_ids)
|
||||
if problems:
|
||||
reasons = ", ".join(sorted({problem["reason"] for problem in problems}))
|
||||
# Literal, not the constant: the #42 scanner proves the emitted
|
||||
# code against ERROR_CODES by reading the source, and it cannot
|
||||
# follow a name. The pair is pinned by a test below.
|
||||
raise MarkerControlError(
|
||||
"invalid_vacuum_map_route",
|
||||
f"Invalid vacuum map routes on {marker_id}: {reasons}",
|
||||
)
|
||||
|
||||
|
||||
def validate_marker_light_entities(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
@@ -1319,6 +1364,7 @@ _FURN_SYMBOL = vol.All(str, vol.Length(min=1, max=MAX_FURN_SYMBOL),
|
||||
# …and its size: strictly positive, capped by the same canvas insurance limit
|
||||
# an opening's length is. A piece of furniture is a SIZE, not a coordinate.
|
||||
_FURN_SIZE = vol.All(_finite, vol.Range(min=0.0000001, max=CANVAS_LIMIT))
|
||||
_DECOR_ASSET_ID = vol.All(str, vol.Match(r"^[0-9a-f]{64}$"))
|
||||
|
||||
_DECOR_COMMON = {
|
||||
vol.Required("id"): str,
|
||||
@@ -1384,6 +1430,13 @@ DECOR_SCHEMA = vol.Any(
|
||||
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "image",
|
||||
vol.Required("asset_id"): _DECOR_ASSET_ID,
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
|
||||
vol.Optional("flip_h"): bool, vol.Optional("flip_v"): bool,
|
||||
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0))},
|
||||
extra=vol.ALLOW_EXTRA),
|
||||
)
|
||||
|
||||
|
||||
@@ -1721,6 +1774,22 @@ MARKER_SCHEMA = vol.Schema(
|
||||
{str: vol.All([_finite], vol.Length(min=6, max=6))}
|
||||
),
|
||||
vol.Optional("segment_map"): vol.Schema({str: str}),
|
||||
# #162: canonical map -> space routing. The schema checks the
|
||||
# shape only; identity uniqueness and the referential space
|
||||
# check live in validate_marker_vacuum_routes, which is
|
||||
# change-aware and must not reject untouched legacy data.
|
||||
vol.Optional("map_routes"): vol.Any(
|
||||
None,
|
||||
vol.All([vol.Schema({
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=128)),
|
||||
vol.Required("source"): vol.All(str, vol.Length(min=1, max=255)),
|
||||
vol.Required("map_id"): vol.All(str, vol.Length(max=255)),
|
||||
vol.Required("space"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Optional("calibration"): vol.Any(
|
||||
None, vol.All([_finite], vol.Length(min=6, max=6)),
|
||||
),
|
||||
}, extra=vol.ALLOW_EXTRA)], vol.Length(max=32)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
|
||||
@@ -1927,6 +1996,7 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
|
||||
@@ -6,6 +6,7 @@ import binascii
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import Counter
|
||||
@@ -20,9 +21,12 @@ from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .asset_integrity import get_asset_integrity_verifier
|
||||
from .auth import may_write
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
CONTENT_URL,
|
||||
DECOR_ASSETS_API_VERSION,
|
||||
DEFAULT_CONFIG,
|
||||
DOMAIN,
|
||||
EXPORT_VERSION,
|
||||
@@ -38,6 +42,7 @@ from .const import (
|
||||
PLAN_MODEL_VERSION,
|
||||
PLANS_DIR,
|
||||
PLANS_URL,
|
||||
SUPPORT_API_VERSION,
|
||||
SUPPORT_PREVIEW_TTL_S,
|
||||
VERSION,
|
||||
)
|
||||
@@ -45,6 +50,15 @@ from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .decor_assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_ID_RE,
|
||||
asset_meta_path,
|
||||
asset_refs,
|
||||
public_asset,
|
||||
read_asset,
|
||||
read_catalog,
|
||||
)
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
content_manifest,
|
||||
@@ -99,6 +113,7 @@ from .validation import (
|
||||
valid_space_id,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_vacuum_routes,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages,
|
||||
validate_partition_opening_hosts,
|
||||
@@ -194,6 +209,9 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
websocket_api.async_register_command(hass, ws_files_migrate)
|
||||
websocket_api.async_register_command(hass, ws_files_cleanup)
|
||||
websocket_api.async_register_command(hass, ws_assets_list)
|
||||
websocket_api.async_register_command(hass, ws_assets_resolve)
|
||||
websocket_api.async_register_command(hass, ws_assets_delete)
|
||||
websocket_api.async_register_command(hass, ws_content_sign)
|
||||
websocket_api.async_register_command(hass, ws_export_create)
|
||||
websocket_api.async_register_command(hass, ws_import_revalidate)
|
||||
@@ -234,25 +252,51 @@ def _send_support_error(connection, msg_id: int, code: str) -> None:
|
||||
connection.send_error(msg_id, code, code)
|
||||
|
||||
|
||||
def _support_monotonic() -> float:
|
||||
"""Clock seam for deterministic support-preview lifecycle tests."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _prune_support_previews(rt: HouseplanData, now: float | None = None) -> None:
|
||||
current = time.monotonic() if now is None else now
|
||||
current = _support_monotonic() if now is None else now
|
||||
for token, preview in list(rt.support_previews.items()):
|
||||
if float(preview.get("expires", 0)) <= current:
|
||||
rt.support_previews.pop(token, None)
|
||||
|
||||
|
||||
_SUPPORT_REPAIR_FAMILY = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
||||
|
||||
|
||||
def _support_repairs(hass: HomeAssistant) -> list[dict[str, Any]]:
|
||||
"""Expose stable House Plan repair families, never raw issue ids/placeholders."""
|
||||
counts: Counter[str] = Counter()
|
||||
registry = ir.async_get(hass)
|
||||
for domain, issue_id in list(registry.issues):
|
||||
for (domain, _issue_id), issue in list(registry.issues.items()):
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
if str(issue_id).startswith("broken_plan_"):
|
||||
counts["broken_plan"] += 1
|
||||
family = getattr(issue, "translation_key", None)
|
||||
if isinstance(family, str) and _SUPPORT_REPAIR_FAMILY.fullmatch(family):
|
||||
counts[family] += 1
|
||||
return [{"code": code, "count": count} for code, count in sorted(counts.items())]
|
||||
|
||||
|
||||
def _support_preview_has_capacity(
|
||||
rt: HouseplanData,
|
||||
owner: str,
|
||||
draft_id: str,
|
||||
) -> bool:
|
||||
"""Check quota while treating this owner's current draft as replaceable."""
|
||||
retained = [
|
||||
item for item in rt.support_previews.values()
|
||||
if not (item.get("owner") == owner and item.get("draft_id") == draft_id)
|
||||
]
|
||||
owned = sum(1 for item in retained if item.get("owner") == owner)
|
||||
return (
|
||||
owned < MAX_SUPPORT_PREVIEWS_PER_USER
|
||||
and len(retained) < MAX_SUPPORT_PREVIEWS_TOTAL
|
||||
)
|
||||
|
||||
|
||||
def _layout_metadata(stored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the exact non-layout portion of a layout-store document."""
|
||||
return {
|
||||
@@ -1064,6 +1108,120 @@ async def ws_content_sign(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
connection.send_result(msg["id"], {"urls": out})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/assets/list"})
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_list(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""List reusable custom images with authoritative reference counts."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only writers may list image assets")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
async with rt.write_lock, rt.upload_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = asset_refs(stored.get("config") or {})
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
rows = await hass.async_add_executor_job(read_catalog, root)
|
||||
connection.send_result(
|
||||
msg["id"], {"assets": [public_asset(row, refs.get(row["asset_id"])) for row in rows]},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/assets/resolve",
|
||||
vol.Required("asset_ids"): vol.All(
|
||||
[vol.All(str, vol.Match(r"^[0-9a-f]{64}$"))], vol.Length(max=200),
|
||||
),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_resolve(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Resolve each unique id once; absent/corrupt content is reported missing."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
requested = set(msg["asset_ids"])
|
||||
allowed = requested
|
||||
if not _check_write(hass, connection):
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
referenced = set(asset_refs(stored.get("config") or {}))
|
||||
allowed = requested & referenced
|
||||
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
verifier = get_asset_integrity_verifier(hass)
|
||||
|
||||
def _resolve() -> tuple[list[dict], list[str]]:
|
||||
rows: list[dict] = []
|
||||
found: set[str] = set()
|
||||
for aid in sorted(allowed):
|
||||
row = read_asset(root, aid)
|
||||
if row is None:
|
||||
continue
|
||||
path = root / f"{aid}{row['ext']}"
|
||||
if not verifier.verify(path, aid):
|
||||
continue
|
||||
rows.append(public_asset(row))
|
||||
found.add(aid)
|
||||
return rows, sorted(requested - found)
|
||||
|
||||
assets, missing = await hass.async_add_executor_job(_resolve)
|
||||
connection.send_result(msg["id"], {"assets": assets, "missing": missing})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/assets/delete",
|
||||
vol.Required("asset_id"): vol.All(str, vol.Match(r"^[0-9a-f]{64}$")),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_assets_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete only an explicitly requested, currently unreferenced asset."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only writers may delete images")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
aid = msg["asset_id"]
|
||||
if not ASSET_ID_RE.fullmatch(aid):
|
||||
connection.send_error(msg["id"], "invalid_format", "Invalid asset id")
|
||||
return
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
async with rt.write_lock, rt.upload_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = asset_refs(stored.get("config") or {}).get(aid, [])
|
||||
if refs:
|
||||
connection.send_error(msg["id"], "in_use", "A decor object still uses this image")
|
||||
return
|
||||
|
||||
def _delete() -> bool:
|
||||
removed = False
|
||||
for extension in ASSET_EXTENSIONS:
|
||||
path = root / f"{aid}{extension}"
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
meta = asset_meta_path(root, aid)
|
||||
if meta.is_file():
|
||||
try:
|
||||
meta.unlink()
|
||||
removed = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
removed = await hass.async_add_executor_job(_delete)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/files/cleanup",
|
||||
@@ -1233,6 +1391,9 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# #295: the card compares this against its own version to decide
|
||||
# whether the «update House Plan» preflight hint can actually help.
|
||||
"integration_version": VERSION,
|
||||
# #423: protocol capability is independent from release skew.
|
||||
"support_api": SUPPORT_API_VERSION,
|
||||
"decor_assets_api": DECOR_ASSETS_API_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1418,6 +1579,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
return validate_junction_limits(
|
||||
@@ -1776,6 +1938,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_marker_vacuum_routes(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
@@ -2134,6 +2297,12 @@ async def ws_support_preview(
|
||||
if rt is None:
|
||||
return
|
||||
|
||||
owner = _connection_user_id(connection)
|
||||
_prune_support_previews(rt)
|
||||
if not _support_preview_has_capacity(rt, owner, msg["draft_id"]):
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
|
||||
# The write lock guarantees that config/layout and both revisions describe
|
||||
# one accepted pair. Projection happens after the deep copy, so the lock is
|
||||
# not held while JSON is built and a large plan cannot stall an editor save.
|
||||
@@ -2183,18 +2352,19 @@ async def ws_support_preview(
|
||||
_send_support_error(connection, msg["id"], code)
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
owner = _connection_user_id(connection)
|
||||
now = _support_monotonic()
|
||||
_prune_support_previews(rt, now)
|
||||
# The executor yields to other requests. Re-check after it returns so two
|
||||
# concurrent builds cannot both consume the final slot. The old preview is
|
||||
# retained until the replacement is ready and this second check succeeds.
|
||||
if not _support_preview_has_capacity(rt, owner, msg["draft_id"]):
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
# A refresh replaces only this card instance's draft. Other cards keep
|
||||
# their token and exact bytes.
|
||||
for old_token, record in list(rt.support_previews.items()):
|
||||
if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:
|
||||
rt.support_previews.pop(old_token, None)
|
||||
owned = sum(1 for item in rt.support_previews.values() if item.get("owner") == owner)
|
||||
if owned >= MAX_SUPPORT_PREVIEWS_PER_USER or len(rt.support_previews) >= MAX_SUPPORT_PREVIEWS_TOTAL:
|
||||
_send_support_error(connection, msg["id"], "support_rate_limited")
|
||||
return
|
||||
token = secrets.token_hex(24)
|
||||
expires = now + SUPPORT_PREVIEW_TTL_S
|
||||
rt.support_previews[token] = {
|
||||
@@ -2299,7 +2469,6 @@ async def ws_support_submit(
|
||||
idempotency_key=msg["idempotency_key"],
|
||||
attachment=attachment,
|
||||
attachment_sha256=preview.get("sha256") if preview else None,
|
||||
filename_token=token or msg["idempotency_key"].lower().replace("_", "-"),
|
||||
)
|
||||
except SupportTransportError as error:
|
||||
_send_support_error(connection, msg["id"], error.code)
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
// rerun by hand when recalibrating WARN_DECODED_BYTES / HARD_DIMENSION.
|
||||
// PLAYWRIGHT_BROWSERS_PATH=... node demo/benchmark_backdrop_decode.mjs
|
||||
import { chromium } from 'playwright';
|
||||
import { reportPageErrors, watchPage } from './serve.mjs';
|
||||
|
||||
const guardProbe = process.argv.includes('--guard-probe');
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await (await browser.newContext()).newPage();
|
||||
const page = watchPage(await (await browser.newContext()).newPage());
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await page.goto('about:blank');
|
||||
const metrics = async () => {
|
||||
@@ -17,7 +20,7 @@ const cases = [
|
||||
{ mp: 32, alpha: false }, { mp: 32, alpha: true }, { mp: 64, alpha: false },
|
||||
{ mp: 100, alpha: false }, { mp: 165, alpha: false },
|
||||
];
|
||||
for (const c of cases) {
|
||||
for (const c of guardProbe ? [] : cases) {
|
||||
try {
|
||||
const r = await page.evaluate(async ({ mp, alpha }) => {
|
||||
const side = Math.round(Math.sqrt(mp * 1e6));
|
||||
@@ -53,4 +56,13 @@ for (const c of cases) {
|
||||
console.log(JSON.stringify({ mp: c.mp, alpha: c.alpha, FAIL: String(e).slice(0, 120) }));
|
||||
}
|
||||
}
|
||||
if (guardProbe) {
|
||||
await page.evaluate(() => {
|
||||
setTimeout(() => { throw new Error('houseplan backdrop guard probe'); }, 0);
|
||||
});
|
||||
}
|
||||
if (await reportPageErrors()) {
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
@@ -15,13 +15,15 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1', 'large-house-interaction-v1'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const interaction = profile === 'large-house-interaction-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
if (planSnap) {
|
||||
for (const [floor, space] of fixture.config.spaces.entries()) {
|
||||
@@ -71,7 +73,7 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
requiresWallFace, interaction, requiresInteraction,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -135,11 +137,11 @@ try {
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
if (isometric) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp-labs=iso');
|
||||
history.replaceState(null, '', '?hp_alpha=1');
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
@@ -151,10 +153,10 @@ try {
|
||||
subscribeEvents: async () => () => undefined,
|
||||
subscribeMessage: async () => () => undefined,
|
||||
};
|
||||
const hassFor = (states) => ({
|
||||
const hassBase = {
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
|
||||
floors: {
|
||||
one: { floor_id: 'one', name: 'One', level: 0 },
|
||||
two: { floor_id: 'two', name: 'Two', level: 1 },
|
||||
@@ -178,17 +180,19 @@ try {
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
});
|
||||
};
|
||||
const hassFor = (states) => ({ ...hassBase, states });
|
||||
|
||||
const loadLongTasks = startLongTaskWindow();
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric) {
|
||||
if (requiresIsometric && window.__hpAlpha !== true) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
// The product flag expires at 1.65.0. Performance keeps exercising
|
||||
// the dormant renderer without changing the public registry contract.
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
// Comparison bundles before #448 do not understand hp_alpha. Preserve
|
||||
// the cross-version benchmark only for that base; current candidates
|
||||
// must activate through the real URL/storage contract above.
|
||||
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
|
||||
}
|
||||
card.hass = hassFor(fixture.states);
|
||||
// launch() warms its own bootstrap card, but each measured sample creates
|
||||
@@ -206,9 +210,33 @@ try {
|
||||
await frame();
|
||||
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
await until(() => card._booting === false);
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
let fullRenderCount = 0;
|
||||
let diagnosticsScanCount = 0;
|
||||
let physicalPreflightCount = 0;
|
||||
let physicalPreflightMs = 0;
|
||||
const fullRenderReasons = [];
|
||||
if (interaction) {
|
||||
const renderBody = card._renderBody.bind(card);
|
||||
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
|
||||
const bindingStatus = card._bindingStatus.bind(card);
|
||||
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
|
||||
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
|
||||
card._checkSpacePhysicalGeometry = (...args) => {
|
||||
physicalPreflightCount++;
|
||||
const started = performance.now();
|
||||
try { return checkSpacePhysicalGeometry(...args); }
|
||||
finally { physicalPreflightMs += performance.now() - started; }
|
||||
};
|
||||
const willUpdate = card.willUpdate.bind(card);
|
||||
card.willUpdate = (changed) => {
|
||||
fullRenderReasons.push([...changed.keys()].map(String));
|
||||
return willUpdate(changed);
|
||||
};
|
||||
}
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
card._setProjection('flat');
|
||||
@@ -236,6 +264,406 @@ try {
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
let interactionDiagnostics = null;
|
||||
let interactionTimings = null;
|
||||
let interactionLongTasks = null;
|
||||
const interactionSeries = interaction ? await duration(async () => {
|
||||
const deltas = {};
|
||||
const timings = {};
|
||||
interactionLongTasks = {};
|
||||
const diagnosticsBefore = diagnosticsScanCount;
|
||||
const reasonsBefore = fullRenderReasons.length;
|
||||
const beforeIrrelevant = fullRenderCount;
|
||||
const intakeBefore = card._hassSequence;
|
||||
const irrelevantStarted = performance.now();
|
||||
let currentStates = nextStates;
|
||||
for (let index = 0; index < 30; index++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
'sensor.performance_unrelated': {
|
||||
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
|
||||
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
|
||||
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
|
||||
|
||||
const relevantBefore = fullRenderCount;
|
||||
const relevantStarted = performance.now();
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: {
|
||||
...currentStates[firstEntity],
|
||||
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
|
||||
},
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.relevantHaTickMs = performance.now() - relevantStarted;
|
||||
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
|
||||
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const device = card.renderRoot.querySelector('[data-hp="device"]');
|
||||
const room = card.renderRoot.querySelector('[data-hp="room"]');
|
||||
const heavy = card.renderRoot.querySelector('.wallbodies');
|
||||
if (!stage || !device || !room || !heavy)
|
||||
throw new Error('interaction profile has no stage/device/room/heavy scene');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const hoverBefore = fullRenderCount;
|
||||
const hoverLongTasks = startLongTaskWindow();
|
||||
const hoverStarted = performance.now();
|
||||
device.dispatchEvent(new PointerEvent('pointerover', {
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
device.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
|
||||
device.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
room.dispatchEvent(new PointerEvent('pointerenter', {
|
||||
clientX: rect.left + 300, clientY: rect.top + 300,
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
room.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
room.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
for (let index = 0; index < 40; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 20 + index, clientY: rect.top + 20,
|
||||
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
|
||||
}
|
||||
await card.updateComplete;
|
||||
timings.hoverSeriesMs = performance.now() - hoverStarted;
|
||||
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
|
||||
deltas.hoverTargets = ['device', 'room', 'miss'];
|
||||
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
|
||||
|
||||
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
|
||||
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const panLongTasks = startLongTaskWindow();
|
||||
const panStarted = performance.now();
|
||||
const panMoveRenders = [];
|
||||
const panTerminalRenders = [];
|
||||
let relevantDuringGestureFullRenders = 0;
|
||||
let relevantDuringGestureIntakes = 0;
|
||||
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
|
||||
const pointerId = 992 + dragIndex;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const panMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if (dragIndex === 0 && index === 10) {
|
||||
const deferredBefore = fullRenderCount;
|
||||
const deferredIntakeBefore = card._hassSequence;
|
||||
for (let tick = 0; tick < 3; tick++) {
|
||||
currentStates = {
|
||||
...currentStates,
|
||||
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
|
||||
};
|
||||
card.hass = hassFor(currentStates);
|
||||
}
|
||||
await card.updateComplete;
|
||||
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
|
||||
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
|
||||
}
|
||||
if ((index + 1) % 10 === 0) {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
}
|
||||
const beforeTerminal = fullRenderCount;
|
||||
panMoveRenders.push(beforeTerminal - panMoveBefore);
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
panTerminalRenders.push(fullRenderCount - beforeTerminal);
|
||||
}
|
||||
timings.panSeriesMs = performance.now() - panStarted;
|
||||
interactionLongTasks.panSeries = await panLongTasks.stop();
|
||||
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
|
||||
const currentView = card._viewOr(card._baseVb());
|
||||
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
|
||||
const position = currentDevice ? card._pos(currentDevice) : null;
|
||||
const scene = position ? card._scenePoint([position.x, position.y]) : null;
|
||||
const deviceRect = device.getBoundingClientRect();
|
||||
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
|
||||
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
|
||||
const overlayErrorPx = scene ? Math.hypot(
|
||||
deviceRect.left + deviceRect.width / 2 - expectedX,
|
||||
deviceRect.top + deviceRect.height / 2 - expectedY,
|
||||
) : Infinity;
|
||||
|
||||
const cameraLongTasks = startLongTaskWindow();
|
||||
const cameraStarted = performance.now();
|
||||
const cameraBefore = fullRenderCount;
|
||||
const pinchPointerA = 1001, pinchPointerB = 1002;
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerA,
|
||||
pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
|
||||
bubbles: true, composed: true, pointerId: pinchPointerB,
|
||||
pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
const pinchMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 360 - index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
|
||||
}));
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
clientX: rect.left + 460 + index, clientY: rect.top + 300,
|
||||
button: 0, buttons: 1, bubbles: true, composed: true,
|
||||
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
|
||||
}));
|
||||
if ((index + 1) % 5 === 0) await frame();
|
||||
}
|
||||
const pinchTerminalBefore = fullRenderCount;
|
||||
for (const pointerId of [pinchPointerA, pinchPointerB]) {
|
||||
stage.dispatchEvent(new PointerEvent('pointerup', {
|
||||
clientX: rect.left + 410, clientY: rect.top + 300,
|
||||
button: 0, buttons: 0, bubbles: true, composed: true,
|
||||
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
|
||||
}));
|
||||
}
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
|
||||
for (let index = 0; index < 4; index++) {
|
||||
stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY: index % 2 ? 120 : -120,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + rect.height / 2,
|
||||
bubbles: true, cancelable: true,
|
||||
}));
|
||||
}
|
||||
await until(() => !card._cameraTransition.active);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
timings.cameraSeriesMs = performance.now() - cameraStarted;
|
||||
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
|
||||
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
|
||||
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
|
||||
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
|
||||
|
||||
const editorLongTaskWindows = [];
|
||||
const editorConfigBefore = JSON.stringify(card._serverCfg);
|
||||
const editorCallsBefore = wsCalls;
|
||||
const editorPaintBefore = card._liveEditorPaintCount || 0;
|
||||
let editorElapsed = 0;
|
||||
const editorMoveRenders = [];
|
||||
const editorTerminalRenders = [];
|
||||
|
||||
card._setMode('plan', false);
|
||||
card._tool = 'draw';
|
||||
card._path = [[100, 100]];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
let editorStage = card.renderRoot.querySelector('.stage');
|
||||
let editorRect = editorStage.getBoundingClientRect();
|
||||
let editorView = card._viewOr(card._baseVb());
|
||||
const fromScene = (x, y) => ({
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
let editorPartLongTasks = startLongTaskWindow();
|
||||
let partStarted = performance.now();
|
||||
let editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
editorStage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
...fromScene(110 + index * 2, 110 + index),
|
||||
bubbles: true, composed: true, pointerId: 1101,
|
||||
pointerType: 'mouse', isPrimary: true,
|
||||
}));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
let editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const resizeRoom = card._rszRooms()[0];
|
||||
const resizeEvent = {
|
||||
pointerId: 1102, stopPropagation: () => undefined,
|
||||
preventDefault: () => undefined, target: null,
|
||||
};
|
||||
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
|
||||
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
|
||||
if (!resizePlan) throw new Error('interaction resize plan was not created');
|
||||
const resizePreflightBefore = physicalPreflightCount;
|
||||
const resizePreflightMsBefore = physicalPreflightMs;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
// A browser delivers pointerdown and pointermove as separate tasks.
|
||||
// Keep the synthetic harness from attributing both handlers to one
|
||||
// impossible long task while still timing every move-side preflight.
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
const target = [
|
||||
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
|
||||
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
|
||||
];
|
||||
card._rszMove({
|
||||
...resizeEvent,
|
||||
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
|
||||
});
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
|
||||
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
|
||||
card._rszCancelDrag();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
|
||||
card._setMode('decor', false);
|
||||
card._decorTool = 'select';
|
||||
const decorShape = card._decorList.find((shape) =>
|
||||
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
|
||||
if (!decorShape) throw new Error('interaction profile has no transformable decor');
|
||||
card._decorSel = decorShape.id;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
|
||||
for (let index = 0; index < 5; index++) await frame();
|
||||
card._dtMeasure();
|
||||
await card.updateComplete;
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
const box = card._decorBoxOf(decorShape);
|
||||
const cornerX = box.x + box.w, cornerY = box.y + box.h;
|
||||
const decorPointer = (x, y) => ({
|
||||
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
|
||||
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
|
||||
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
|
||||
stopPropagation: () => undefined, preventDefault: () => undefined,
|
||||
currentTarget: null, target: null,
|
||||
});
|
||||
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorPartLongTasks = startLongTaskWindow();
|
||||
partStarted = performance.now();
|
||||
editorMoveBefore = fullRenderCount;
|
||||
for (let index = 0; index < 40; index++) {
|
||||
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
|
||||
if ((index + 1) % 10 === 0) await frame();
|
||||
}
|
||||
editorTerminalBefore = fullRenderCount;
|
||||
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
|
||||
card._cancelDecorGesture();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
timings.editorSeriesMs = editorElapsed;
|
||||
interactionLongTasks.editorSeries = {
|
||||
supported: editorLongTaskWindows.every((window) => window.supported),
|
||||
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
|
||||
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
|
||||
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
|
||||
};
|
||||
deltas.editorMoveFullRenders = editorMoveRenders;
|
||||
deltas.editorTerminalFullRenders = editorTerminalRenders;
|
||||
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
|
||||
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
|
||||
deltas.editorWsWrites = wsCalls - editorCallsBefore;
|
||||
deltas.editorLongTaskParts = editorLongTaskWindows;
|
||||
|
||||
card._setMode('view', false);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
deltas.panMoveFullRenders = panMoveRenders;
|
||||
deltas.terminalFullRenders = panTerminalRenders;
|
||||
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
|
||||
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
|
||||
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
|
||||
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
|
||||
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
|
||||
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
|
||||
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
|
||||
interactionTimings = timings;
|
||||
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
|
||||
if (requiresInteraction && (
|
||||
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|
||||
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|
||||
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|
||||
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.terminalFullRenders.some((count) => count !== 1)
|
||||
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|
||||
|| deltas.relevantDuringGestureIntakes !== 3
|
||||
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|
||||
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|
||||
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|
||||
|| deltas.resizeLivePreflightChecks < 1
|
||||
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|
||||
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|
||||
|| deltas.diagnosticsScans !== 0
|
||||
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
|
||||
}) : null;
|
||||
|
||||
let planSnapDiagnostics = null;
|
||||
const planSnapPointer = planSnap ? await duration(async () => {
|
||||
card._setMode('plan');
|
||||
@@ -432,6 +860,11 @@ try {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
...(interactionSeries ? {
|
||||
interactionSeriesMs: interactionSeries.ms,
|
||||
...interactionTimings,
|
||||
interactionDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -442,6 +875,7 @@ try {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
...(interactionSeries ? interactionLongTasks || {} : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -461,6 +895,7 @@ try {
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -474,6 +909,9 @@ const metricNames = [
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
if (interaction) metricNames.splice(2, 0,
|
||||
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
|
||||
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** #461 production-bundle wall-draw terminal-click performance profile. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch, finish } from './serve.mjs';
|
||||
import {
|
||||
installWallDrawClickHarness, resetWallDrawClickHarness, runWallDrawClickChain,
|
||||
} from './wall-draw-click-harness.mjs';
|
||||
|
||||
const outputArg = process.argv.find((arg) => arg.startsWith('--output='));
|
||||
const output = outputArg ? resolve(outputArg.slice('--output='.length)) : null;
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 }, 1);
|
||||
await installWallDrawClickHarness(page);
|
||||
|
||||
const run = async (remote) => {
|
||||
const fixture = await resetWallDrawClickHarness(page, remote);
|
||||
// One complete chain warms the browser/JIT; reset removes all of its state.
|
||||
await runWallDrawClickChain(page);
|
||||
await resetWallDrawClickHarness(page, remote);
|
||||
return { fixture, result: await runWallDrawClickChain(page) };
|
||||
};
|
||||
const base = await run(false);
|
||||
const remote = await run(true);
|
||||
const sorted = [...base.result.times].sort((a, b) => a - b);
|
||||
const medianMs = sorted[Math.floor(sorted.length / 2)];
|
||||
const maxMs = Math.max(...base.result.times);
|
||||
const remoteSorted = [...remote.result.times].sort((a, b) => a - b);
|
||||
const remoteMedianMs = remoteSorted[Math.floor(remoteSorted.length / 2)];
|
||||
|
||||
const clickShape = (click) => click.fullSpacePhysicalChecks === 0
|
||||
&& click.localPhysicalChecks === 1
|
||||
&& click.junctionArtifactPasses === 1
|
||||
&& click.configWrites === 1 && click.history === 1 && click.pathPoints === 1;
|
||||
const fixtureShape = base.fixture.spaces === 5 && base.fixture.rooms === 12
|
||||
&& base.fixture.positiveSegments >= 36 && base.fixture.savedDrafts === 4;
|
||||
const remoteFixtureShape = remote.fixture.positiveSegments === base.fixture.positiveSegments * 2;
|
||||
const structural = fixtureShape && remoteFixtureShape
|
||||
&& base.result.first.fullSpacePhysicalChecks === 0
|
||||
&& base.result.first.localPhysicalChecks === 0
|
||||
&& base.result.first.configWrites === 0 && base.result.first.history === 0
|
||||
&& base.result.clicks.length === 7 && base.result.clicks.every(clickShape)
|
||||
&& remote.result.clicks.every(clickShape)
|
||||
&& base.result.metrics.localProofMaxObjects < base.fixture.positiveSegments
|
||||
&& remote.result.metrics.localProofMaxObjects === base.result.metrics.localProofMaxObjects
|
||||
&& base.result.ids.length === 7 && new Set(base.result.ids).size === 7
|
||||
&& base.result.terminal.fullSpacePhysicalChecks >= 1
|
||||
&& base.result.terminalPartitionCount >= 1 && base.result.activeCleared;
|
||||
const budgets = {
|
||||
medianMs: 150,
|
||||
maxMs: 250,
|
||||
remoteMedianMs: medianMs * 1.5 + 20,
|
||||
};
|
||||
const timing = medianMs <= budgets.medianMs && maxMs <= budgets.maxMs
|
||||
&& remoteMedianMs <= budgets.remoteMedianMs;
|
||||
const report = {
|
||||
issue: 461, profile: 'wall-draw-click-v1',
|
||||
fixture: base.fixture, remoteFixture: remote.fixture,
|
||||
timing: {
|
||||
samplesMs: base.result.times.map((value) => Math.round(value * 10) / 10),
|
||||
medianMs: Math.round(medianMs * 10) / 10,
|
||||
maxMs: Math.round(maxMs * 10) / 10,
|
||||
remoteMedianMs: Math.round(remoteMedianMs * 10) / 10,
|
||||
},
|
||||
budgets: Object.fromEntries(Object.entries(budgets)
|
||||
.map(([key, value]) => [key, Math.round(value * 10) / 10])),
|
||||
counters: base.result.metrics,
|
||||
structural,
|
||||
timingPass: timing,
|
||||
pass: structural && timing,
|
||||
};
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
await finish(browser);
|
||||
@@ -0,0 +1,47 @@
|
||||
/** #54: bounded provider normalization and incident-only hover performance. */
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
|
||||
|
||||
const NODES = 500;
|
||||
const SAMPLES = 9;
|
||||
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
|
||||
const hex = (index) => index.toString(16).padStart(16, '0');
|
||||
const rows = Array.from({ length: NODES }, (_, index) => ({
|
||||
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
|
||||
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
|
||||
const other = index + offset;
|
||||
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
|
||||
}),
|
||||
}));
|
||||
const registry = {
|
||||
revision: 1, authoritative: true, access: 'full', lastSuccess: Date.now(), entities: {},
|
||||
devices: Object.fromEntries(rows.map((_, index) => [`d${index}`, { id: `d${index}` }])),
|
||||
};
|
||||
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
|
||||
const devices = rows.map((_, index) => ({
|
||||
id: `m${index}`, name: '', model: '', area: '', space: index % 5 ? 'main' : 'remote',
|
||||
icon: '', entities: [], bindingKind: 'device', bindingRef: `d${index}`, bindingStatus: active,
|
||||
}));
|
||||
|
||||
const measured = (fn) => {
|
||||
const samples = [];
|
||||
for (let i = 0; i < SAMPLES; i++) {
|
||||
const started = performance.now(); fn(); samples.push(performance.now() - started);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
return samples[Math.floor(samples.length * .9)];
|
||||
};
|
||||
|
||||
let topology; let mapped;
|
||||
const normalizeMs = measured(() => { topology = normalizeZhaTopology(rows); });
|
||||
const mapMs = measured(() => { mapped = mapTopologies([topology], devices, registry); });
|
||||
const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', 'm1'));
|
||||
const repeatedHoverMs = measured(() => {
|
||||
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
|
||||
});
|
||||
const result = { nodes: topology.nodes.length, links: topology.links.length,
|
||||
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
for (const [name, budget] of Object.entries(BUDGETS)) {
|
||||
if (result[name] > budget) throw new Error(`${name} ${result[name].toFixed(2)} ms > ${budget} ms`);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Аргументы запуска Chromium для съёмки документации (#410, #424).
|
||||
*
|
||||
* Модуль отдельный и чистый намеренно: `capture.mjs` — исполняемый скрипт,
|
||||
* который при импорте поднимает браузер и снимает набор. Тест, желающий
|
||||
* прочитать эти константы, не должен запускать съёмку внутри `npm test` — тот
|
||||
* же приём и та же причина, что у `clip.mjs`.
|
||||
*
|
||||
* Первые три флага фиксируют растеризацию текста и цветовой профиль: именно они
|
||||
* убрали основную часть дрейфа в #410.
|
||||
*
|
||||
* Последние два добавлены в #424 и обязаны стоять вместе — по отдельности ни
|
||||
* один дрейф не убирает (проверено перебором, восемь прогонов на конфигурацию):
|
||||
*
|
||||
* - `--disable-partial-raster` запрещает переиспользовать ранее нарисованные
|
||||
* куски тайла. С включённой частичной растеризацией кадр зависит от того,
|
||||
* что композитор рисовал ДО него, то есть от истории прогона;
|
||||
* - `--run-all-compositor-stages-before-draw` заставляет пройти все стадии
|
||||
* композитора до отрисовки, иначе снимок берётся на полпути и «докрутка»
|
||||
* каждый раз происходит по-своему.
|
||||
*
|
||||
* Подпись дефекта, ради которого это сделано: один-два случайных кадра из
|
||||
* десяти расходятся между прогонами на единицы пикселей и два уровня, при этом
|
||||
* внутри одного процесса всё стабильно.
|
||||
*/
|
||||
export const DETERMINISTIC_ARGS = [
|
||||
'--force-color-profile=srgb',
|
||||
'--font-render-hinting=none',
|
||||
'--disable-lcd-text',
|
||||
'--disable-partial-raster',
|
||||
'--run-all-compositor-stages-before-draw',
|
||||
];
|
||||
@@ -8,11 +8,13 @@ import { fileURLToPath } from 'node:url';
|
||||
// manifest-owned tree before launching Chromium; copying only the stable entry
|
||||
// leaves every content-hashed import at 404.
|
||||
import '../../scripts/bundle-sync.mjs';
|
||||
import { wholePixelClip } from './clip.mjs';
|
||||
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
|
||||
import { DETERMINISTIC_ARGS } from './browser-args.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||||
@@ -167,14 +169,11 @@ mkdirSync(OUTPUT, { recursive: true });
|
||||
// 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha
|
||||
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
|
||||
//
|
||||
// `--disable-lcd-text` убирает субпиксельное сглаживание (оно и плавало),
|
||||
// `--font-render-hinting=none` снимает зависимость от хинтинга,
|
||||
// `--force-color-profile=srgb` фиксирует профиль. `reducedMotion: 'reduce'`
|
||||
// добавлен к `animations: 'disabled'` у самого скриншота: первое гасит анимации
|
||||
// в CSS, второе — уже начатые переходы на момент съёмки.
|
||||
const DETERMINISTIC_ARGS = [
|
||||
'--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text',
|
||||
];
|
||||
// Аргументы запуска и объяснение каждого — в `browser-args.mjs`: константы
|
||||
// живут отдельно, чтобы тест мог их прочитать, не поднимая браузер (#424).
|
||||
// `reducedMotion: 'reduce'` добавлен к `animations: 'disabled'` у самого
|
||||
// скриншота: первое гасит анимации в CSS, второе — уже начатые переходы на
|
||||
// момент съёмки.
|
||||
/**
|
||||
* Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`.
|
||||
*
|
||||
@@ -266,27 +265,12 @@ try {
|
||||
const rawClip = scenario.capture === 'room-card'
|
||||
? await roomCardClip(page)
|
||||
: await goldenClip(page, scenario.capture);
|
||||
// Обрезка выравнивается по целым пикселям (#410).
|
||||
//
|
||||
// Прямоугольник считается из живого DOM через getBoundingClientRect, то есть
|
||||
// приходит дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и
|
||||
// тогда сдвиг раскладки на десятую пикселя — от метрики шрифта, от
|
||||
// появившегося скроллбара — переписывает границы всех элементов на единицы
|
||||
// уровней. Ровно эта подпись и была в #410: 76 пикселей, максимум 2 уровня,
|
||||
// alpha не тронута, всё на сглаженных границах полей.
|
||||
//
|
||||
// Замер показал, где искать: три снимка подряд в одном состоянии страницы
|
||||
// совпадают побайтово у всех десяти сценариев, а между прогонами плавают
|
||||
// два. Значит дело не в рендере, а в том, что приходит на вход съёмке.
|
||||
//
|
||||
// Внешняя рамка расширяется, а не округляется к ближайшему: обрезка обязана
|
||||
// содержать цель целиком, потерять полпикселя по краю нельзя.
|
||||
const clip = rawClip && {
|
||||
x: Math.floor(rawClip.x),
|
||||
y: Math.floor(rawClip.y),
|
||||
width: Math.ceil(rawClip.x + rawClip.width) - Math.floor(rawClip.x),
|
||||
height: Math.ceil(rawClip.y + rawClip.height) - Math.floor(rawClip.y),
|
||||
};
|
||||
// Обрезка выравнивается по целым пикселям (#410); почему именно так —
|
||||
// в `wholePixelClip`. Замер тогда показал, где искать: три снимка подряд в
|
||||
// одном состоянии совпадают побайтово у всех десяти сценариев, а между
|
||||
// прогонами плавают два. Значит дело не в рендере, а в том, что приходит на
|
||||
// вход съёмке — и проверяется это между прогонами (#422).
|
||||
const clip = wholePixelClip(rawClip);
|
||||
if (STABILITY_SHOTS && rawClip) {
|
||||
console.log(`${scenario.id} обрезка: сырая`
|
||||
+ ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}`
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Выравнивание области съёмки по целым пикселям (#410, гейт — #422).
|
||||
*
|
||||
* Прямоугольник приходит из живого DOM через `getBoundingClientRect`, то есть
|
||||
* дробным. Дробная обрезка заставляет Chromium ресемплить кадр, и тогда сдвиг
|
||||
* раскладки на десятую пикселя — от метрики шрифта, от появившегося
|
||||
* скроллбара — переписывает границы всех элементов на единицы уровней. Ровно
|
||||
* эта подпись и была в #410: 76 пикселей, максимум 2 уровня, alpha не тронута,
|
||||
* всё на сглаженных границах полей.
|
||||
*
|
||||
* Внешняя рамка РАСШИРЯЕТСЯ, а не округляется к ближайшему: обрезка обязана
|
||||
* содержать цель целиком, потерять полпикселя по краю нельзя. Именно поэтому
|
||||
* здесь `floor` для начала и `ceil` для конца, а не `Math.round` — округление к
|
||||
* ближайшему срезало бы край на половине входов.
|
||||
*
|
||||
* Функция живёт отдельным модулем, потому что она чистая, а `capture.mjs` —
|
||||
* исполняемый скрипт: импортировать его в тест значит запустить съёмку.
|
||||
*/
|
||||
export function wholePixelClip(rect) {
|
||||
if (!rect) return rect;
|
||||
const x = Math.floor(rect.x);
|
||||
const y = Math.floor(rect.y);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.ceil(rect.x + rect.width) - x,
|
||||
height: Math.ceil(rect.y + rect.height) - y,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
const CELL_CM = 5;
|
||||
const point = (x, y) => [x / 240, y / 240];
|
||||
|
||||
const roomAt = (id, x, y) => {
|
||||
const poly = [point(x, y), point(x + 24, y), point(x + 24, y + 24), point(x, y + 24)];
|
||||
const wallIds = poly.map((_, index) => `${id}-wall-${index}`);
|
||||
return {
|
||||
room: { id, name: id, area: null, poly, wall_ids: wallIds },
|
||||
segments: poly.map((a, index) => ({
|
||||
id: wallIds[index], a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
walls: poly.map((a, index) => ({
|
||||
key: `${id}-legacy-${index}`, a, b: poly[(index + 1) % poly.length], cm: 15,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const roomGrid = (prefix, xOffset = 0) => Array.from({ length: 12 }, (_, index) =>
|
||||
roomAt(`${prefix}-${index}`, xOffset + 8 + (index % 4) * 32, 8 + Math.floor(index / 4) * 32));
|
||||
|
||||
export const WALL_DRAW_CLICK_POINTS = [
|
||||
point(152, 144), point(176, 144), point(176, 168), point(200, 168),
|
||||
point(200, 144), point(224, 144), point(224, 168), point(236, 168),
|
||||
];
|
||||
|
||||
export function makeWallDrawClickFixture(remoteVariant = false) {
|
||||
const primary = roomGrid('base');
|
||||
const remote = remoteVariant ? roomGrid('remote', 300) : [];
|
||||
const all = [...primary, ...remote];
|
||||
const roomDrafts = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `saved-draft-${index}`,
|
||||
points: [point(8 + index * 28, 112), point(20 + index * 28, 112), point(20 + index * 28, 124)],
|
||||
segments: [
|
||||
{ id: `saved-draft-${index}-0`, cm: 15 },
|
||||
{ id: `saved-draft-${index}-1`, cm: 15 },
|
||||
],
|
||||
}));
|
||||
const edited = {
|
||||
id: 'edited', title: 'Wall draw click', cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: all.map((item) => item.room),
|
||||
wall_segments: all.flatMap((item) => item.segments),
|
||||
walls: all.flatMap((item) => item.walls),
|
||||
room_drafts: roomDrafts,
|
||||
partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
};
|
||||
return {
|
||||
model_version: 9,
|
||||
spaces: [edited, ...Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `other-${index}`, title: `Other ${index}`, cell_cm: CELL_CM,
|
||||
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], walls: [],
|
||||
room_drafts: [], partitions: [], wall_columns: [], openings: [], open_spans: [],
|
||||
}))],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
goldenAcceptancePlan, goldenAcceptanceRefusal, goldenSilentDeclarations,
|
||||
goldenWitnessRefusal,
|
||||
} from '../../scripts/golden-acceptance.mjs';
|
||||
import {
|
||||
assertCaptureEnvironment, captureEnvironment, environmentNote,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const reviewed = process.argv.includes('--reviewed');
|
||||
@@ -38,6 +41,16 @@ if (typeof report.chromium !== 'string' || !report.chromium)
|
||||
throw new Error('candidate report does not identify its Chromium build');
|
||||
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
|
||||
|
||||
// Среда приёмки (#455). Отчёт съёмки платформу не несёт — `run.mjs` входит в
|
||||
// корпус sourceFingerprint, и его правка объявила бы устаревшими бандл,
|
||||
// скриншоты документации и сам индекс эталонов. Но приёмка идёт там же, где
|
||||
// лежат артефакты, поэтому её платформа — честный признак среды кадров.
|
||||
const environment = captureEnvironment();
|
||||
const foreignAllowed = assertCaptureEnvironment({ kind: 'golden', stage: 'accept' });
|
||||
if (foreignAllowed) {
|
||||
console.log(`Чужая среда приёмки разрешена осознанно: ${foreignAllowed}`);
|
||||
}
|
||||
|
||||
const refusal = goldenAcceptanceRefusal(report.results, declared, declaredNew);
|
||||
if (refusal) throw new Error(refusal);
|
||||
|
||||
@@ -72,7 +85,18 @@ const witnessCheck = goldenWitnessRefusal({
|
||||
skipWitnesses,
|
||||
skipReason,
|
||||
});
|
||||
if (witnessCheck.refusal) throw new Error(witnessCheck.refusal);
|
||||
if (witnessCheck.refusal) {
|
||||
// Приписка про среду — то, чего не хватало отказу: «свидетелей 0 из 10» без
|
||||
// неё читается как «объяви больше сцен», и обход в одну команду выглядит
|
||||
// решением (#455).
|
||||
const note = environmentNote({
|
||||
capturedOn: environment.platform,
|
||||
acceptedOn: existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, 'utf8')).platform || null
|
||||
: null,
|
||||
});
|
||||
throw new Error(note ? `${witnessCheck.refusal}\n${note}` : witnessCheck.refusal);
|
||||
}
|
||||
// Кандидат проверяется целиком, до всякого решения о замене: сломанный отчёт
|
||||
// не имеет права оставить каталог эталонов половинным.
|
||||
for (const scenario of GOLDEN_SCENARIOS) {
|
||||
@@ -102,6 +126,9 @@ writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify
|
||||
acceptedAt: new Date().toISOString(),
|
||||
sourceFingerprint: report.buildFingerprint,
|
||||
chromium: report.chromium,
|
||||
// Платформа рядом с версией браузера: провенанс среды, по которому следующая
|
||||
// приёмка отличит «объявили не то» от «сняли не там» (#455).
|
||||
platform: environment.platform,
|
||||
// #355: след приёмки в артефакте, не только в истории shell.
|
||||
witnesses: skipWitnesses
|
||||
? { skipped: true, reason: skipReason }
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 54,
|
||||
"acceptedAt": "2026-09-02T05:20:33.824Z",
|
||||
"sourceFingerprint": "baf59953cfd6eb3543f7da5af21787b20dc76f136b74782ae917f811d2fc1ab5",
|
||||
"matrixVersion": 55,
|
||||
"acceptedAt": "2026-09-05T15:05:00.040Z",
|
||||
"sourceFingerprint": "450863830ad6931b7698085deecbd39f6686a3e84f1b777008be5347426f4b21",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"witnesses": {
|
||||
"count": 90,
|
||||
"count": 117,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"version-mismatch-desktop-dark-en": "8efc4365de5e5f69dc864a36d87144c3a16a2a52f89ec6e5556fc7755f538fb6",
|
||||
"version-mismatch-touch-light-ru": "6fee87758c8373472b57542b8705595113ef07b92014ef6f5b977d4d1aa19808",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
|
||||
@@ -66,9 +70,9 @@
|
||||
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
|
||||
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
|
||||
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
|
||||
"device-inbox-desktop-en-light": "89587944da06872f610da33e8e39441d108cb34849d8b09d4925bff4d0977565",
|
||||
"device-inbox-desktop-ru-dark": "fe9506408fc198c167f2d4b9cfe91bd9fdfb722b311772457ae44c9d06248680",
|
||||
"device-inbox-narrow-ru-dark": "3ddb3b66a613c6406d3d9ce2d4d479c8d84eff04acd9b5b2dce02c4c777fc738",
|
||||
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
|
||||
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
|
||||
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
|
||||
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
|
||||
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
|
||||
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
|
||||
@@ -133,33 +137,33 @@
|
||||
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
|
||||
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
|
||||
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
|
||||
"device-dialog-desktop-en": "465175fc9960c7ceb5208cf62f919381d56179ef12a93bee20e5c7af63757e7a",
|
||||
"device-dialog-mobile-ru": "8128783f4e9f9ff301f15db9fa7388b51aa7e8a4a74ac0aaf962b21ecda52c78",
|
||||
"device-dialog-desktop-de": "8440d4e980fac5df53142b6b118e527d9c0272e7515b65faaba6a58bc9441075",
|
||||
"device-dialog-desktop-en": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
|
||||
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
|
||||
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
|
||||
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
|
||||
"toggle-entity-dialog-desktop-en": "7a8ac3071d8bb3f79f2bfe724d0138e2b309baab3104ef8afb0b2b081c8b82b1",
|
||||
"toggle-entity-dialog-mobile-ru": "d3062e76da27218cac45ca4c76f34b358d9cb98e85288f24e64b7de1bcca949d",
|
||||
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
|
||||
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
|
||||
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
|
||||
"general-color-popover-desktop-en": "762c20c3d0499c1ea444e5d980eec4e78c7112455589b5202010f53a8b51099c",
|
||||
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
|
||||
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
|
||||
"support-desktop-empty-light-en": "fecabb63521f258acc66ecdf7a438db10c73d0eac39618c55fb51287080f01f5",
|
||||
"support-desktop-preview-dark-en": "a866aa74e3385ac45e837b328d64ed65bc4b108eb9dc4149fcccf393975e72b6",
|
||||
"support-phone-validation-light-ru": "7a461c00631a05872b9aab1f4c0b0b65d1c918273ff06bba25c7bd2e87fba1ca",
|
||||
"support-phone-success-dark-en": "81f83402933a573002581646d0b322f3770f3df41d5334373315ed5ad4974883",
|
||||
"support-relay-error-light-en": "e9a9ad906e106eedfa6e84c592325f1604eb64c89c51135c076ac87a6a0766b2",
|
||||
"support-tablet-preview-dark-ru": "1264e005cd764408edb9b285a5dfbe92ceb90cb925f624b8f65cd47edb96c0c9",
|
||||
"device-ripple-color-popover-mobile-ru": "d9ebf83ad0d2995b46f6174daf6e21ca5404952afa308ddd3a66ecefcee565d0",
|
||||
"space-room-color-popover-desktop-ru": "9bbbcf9c5ea6ff5490b500be8cb3086af9d368d14ca50c304f42a697e296ece1",
|
||||
"backup-full-preview-desktop-en": "0fbd23a92121668001faf5c9ed555a31107704f3074d1791709c8436517d3d1f",
|
||||
"backup-plan-only-export-desktop-en": "ce66fb57edbc87d56355b0d73a2d4203a67c275d5927d400ef1758ac0b607b34",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "101e1a97c6229c693a4edfb4878c6a4564132d6fbcaddd6276cb25aefa429260",
|
||||
"optimize-preflight-dialog-light-ru": "4f34a59baa95b7ad0ad3dbb47f23996537878f6b666dc9111236713f513de97e",
|
||||
"optimize-orphan-references-dark-en": "c8122eda61a52ead0f3609b9872b513004ffbc44c1d4928ec11e5d3b5978af78",
|
||||
"optimize-orphan-references-light-ru": "3cc0b16af015cf695f8bd278dee4d76b160639021a33bc3a28441fb72a5e8e5c",
|
||||
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
|
||||
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
|
||||
"device-help-popover-light-ru": "b8cb39c77690bb2a2a150a2a77af55411b3f2053741eee86cd5fd19b41b4307f",
|
||||
"decor-color-popover-mobile-ru": "c63b72493040fefdbcf55ce5325630a321b171f62962a345caa89ec5fa837f55",
|
||||
"decor-color-popover-desktop-en": "49bc8317d452fbc9e7e95f7c139423838a5ecde763b1fe328c7b1be6ca20ed98",
|
||||
"general-color-popover-desktop-en": "4b3cd5b7fb405b912cb3506680151b0237b803499bbfbad597c20d3fabc869a7",
|
||||
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
|
||||
"settings-help-zoom-200-ru-dark": "0b1f995abec3b14cd5b25e0c0fb0a8d04d08567b0b3e5717463059efa244296d",
|
||||
"support-desktop-empty-light-en": "04d968e28c85096f6eb637f9ae01c2bae86c6d1540a9b3bfb83f0f4992127b4e",
|
||||
"support-desktop-preview-dark-en": "dd3502ce1584109a837ba80f5683ddd5a1ea2063150217121f6e0b8832a0a659",
|
||||
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
|
||||
"support-phone-success-dark-en": "024e72048558561b66f13d635fc22e1e3d289d0b39499e6595dc986761daa31f",
|
||||
"support-relay-error-light-en": "ab769c512f94a1dea22f36f57a36796bc3b5bc3ec788f4ca52bfccbc7fc25ab0",
|
||||
"support-tablet-preview-dark-ru": "416604355b3785aecda02f1ad21d031ce4c977180ed8606937b6d189b0afb7b1",
|
||||
"device-ripple-color-popover-mobile-ru": "5b7ff2f212a1731d0d25f57a385fe594b32af8ba3ae0183c40fb285addf9f832",
|
||||
"space-room-color-popover-desktop-ru": "137deea402c850207bd6e217524359757ca13d9783c7728419e89260b81bcc75",
|
||||
"backup-full-preview-desktop-en": "fc3ac9ec9eae728f2524131f3f9e3a55fa4afce43f440ee6f7b04bd3b78e66df",
|
||||
"backup-plan-only-export-desktop-en": "b000db624416d90a8d9e658ec602d278f2bc8df63af2e8eb9562bc2fbff24ed8",
|
||||
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
|
||||
"optimize-preflight-dialog-dark-en": "0e140653af81cf8a965fee00d6f5ce7d0b49c22d329f5237e96e6ba1829252b4",
|
||||
"optimize-preflight-dialog-light-ru": "1fc545b7017c6ae3b0a2b0e1330b037dc0e565113c5892d48be64ca30f34abf6",
|
||||
"optimize-orphan-references-dark-en": "9611469c4d4e542b77abcc52c7c9484237aa18ff58994926be66514a377564f4",
|
||||
"optimize-orphan-references-light-ru": "7ea732b04dfd7aca25107935576326975b3cc7cf5757d844d809ceb4cef0a245",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -35,6 +35,7 @@ const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
const cardVersion = JSON.parse(readFileSync(
|
||||
new URL('../../package.json', import.meta.url), 'utf8',
|
||||
)).version;
|
||||
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
@@ -56,7 +57,7 @@ const themeVars = {
|
||||
async function stableEnvironment(page, scenario) {
|
||||
await page.setViewportSize(scenario.viewport);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: scenario.theme });
|
||||
await page.evaluate(({ variables, theme }) => {
|
||||
await page.evaluate(({ variables, theme, attemptedTarget, attemptKey }) => {
|
||||
let style = document.getElementById('hp-golden-stability');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
@@ -82,7 +83,15 @@ async function stableEnvironment(page, scenario) {
|
||||
'--hp-golden-page-bg', theme === 'light' ? '#eef1f4' : '#11151b',
|
||||
);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, { variables: themeVars[scenario.theme] || themeVars.dark, theme: scenario.theme });
|
||||
sessionStorage.removeItem(attemptKey);
|
||||
if (attemptedTarget) sessionStorage.setItem(attemptKey, attemptedTarget);
|
||||
}, {
|
||||
variables: themeVars[scenario.theme] || themeVars.dark,
|
||||
theme: scenario.theme,
|
||||
attemptedTarget: scenario.versionRecoveryAttempted
|
||||
? String(scenario.integrationVersion || '').trim() : null,
|
||||
attemptKey: VERSION_RELOAD_ATTEMPT_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
@@ -736,7 +745,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await page.mouse.move(0, 0);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion }) => {
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -760,10 +769,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
window.__goldenEditor?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
history.replaceState(null, '', scenario.labs?.length
|
||||
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
|
||||
if (scenario.labs?.length) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
|
||||
history.replaceState(null, '', scenario.alpha ? '?hp_alpha=1' : location.pathname);
|
||||
if (scenario.alpha) {
|
||||
localStorage.setItem('houseplan_card_alpha_v1', '1');
|
||||
}
|
||||
if (scenario.projection && scenario.space) {
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
|
||||
@@ -788,8 +796,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
three: { floor_id: 'three', name: 'Three', level: 2 },
|
||||
},
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config: structuredClone(fixture.config), rev: 1, can_write: true,
|
||||
...(Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')
|
||||
? { integration_version: scenario.integrationVersion } : {}),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: structuredClone(fixture.layout || {}), rev: 1 };
|
||||
if (message.type === 'houseplan/trail/get') return scenario.vacuumTrail ? {
|
||||
@@ -818,21 +829,16 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
if (scenario.testOnlyLabsSnapshot) {
|
||||
if (!scenario.labs?.length || typeof card._onLabsSnapshot !== 'function') {
|
||||
throw new Error(`invalid test-only Labs contract: ${scenario.id}`);
|
||||
}
|
||||
// connectedCallback has already received the real (expired) registry
|
||||
// snapshot. Inject only the renderer fixture before hass/model boot so
|
||||
// fit and warm-remount follow the former live-Labs lifecycle exactly.
|
||||
card._onLabsSnapshot({ active: Object.freeze([...scenario.labs]), space: '' });
|
||||
}
|
||||
card.hass = hassFor();
|
||||
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
|
||||
await card.updateComplete;
|
||||
const expectedDevices = Object.keys(fixture.devices || {}).length;
|
||||
if (expectedDevices) await until(() => card._devices?.length >= expectedDevices);
|
||||
await until(() => card._booting === false);
|
||||
if (scenario.alpha
|
||||
&& (window.__hpAlpha !== true || JSON.stringify(window.__hpLabs) !== '["iso"]')) {
|
||||
throw new Error(`alpha contract did not activate isometric capability: ${scenario.id}`);
|
||||
}
|
||||
// Golden scenarios intentionally call internal editor commands directly.
|
||||
// Preload the lazy runtime for that legacy harness contract; cold-View
|
||||
// loading and retry semantics are covered by smoke_lazy_editor_chunk.
|
||||
@@ -859,6 +865,21 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(scenario, 'integrationVersion')) {
|
||||
const target = String(scenario.integrationVersion || '').trim();
|
||||
await until(() => card._versionRecovery?.relation?.kind === 'mismatch'
|
||||
&& card.renderRoot.querySelector('.version-recovery'));
|
||||
const notice = card._versionRecovery.banner;
|
||||
const rendered = card.renderRoot.querySelector('.version-recovery');
|
||||
if (!target || notice?.backend !== target
|
||||
|| rendered?.getAttribute('data-version-recovery-target') !== target) {
|
||||
throw new Error(`golden version notice target mismatch: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.versionRecoveryAttempted
|
||||
&& sessionStorage.getItem(attemptKey) !== target) {
|
||||
throw new Error(`golden kiosk attempted target missing: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
// #304: the two existing hidden-wall golden scenes also guard the static
|
||||
// architectural overlay in every Plan tool. Finish in draw so the reviewed
|
||||
// pixels stay stable; a missing/changed tool projection fails before capture.
|
||||
@@ -1066,6 +1087,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (card._effectiveProjection?.() !== 'iso') {
|
||||
throw new Error(`isometric golden rendered Flat: ${scenario.id}`);
|
||||
}
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
@@ -1604,6 +1628,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
throw new Error(`golden general-settings help is clipped: ${scenario.openHelp}`);
|
||||
} else if (scenario.dialog === 'support') {
|
||||
card._haIntegrationVersion = cardVersion;
|
||||
card._haSupportApi = 1;
|
||||
card._openSupportDialog();
|
||||
await card.updateComplete;
|
||||
const opened = card._supportDialog;
|
||||
@@ -1808,7 +1833,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
cachedRays: card._sunRaysCache?.rays?.length || 0,
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario, cardVersion });
|
||||
}, { fixture, scenario, cardVersion, attemptKey: VERSION_RELOAD_ATTEMPT_KEY });
|
||||
if (scenario.tabDrag) {
|
||||
const drag = await page.evaluate((placement) => {
|
||||
const card = window.__goldenCard;
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 54;
|
||||
export const GOLDEN_MATRIX_VERSION = 55;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
// The product flag expires at 1.65.0 by contract. Golden keeps the dormant
|
||||
// renderer covered through an explicit card-only snapshot; this never changes
|
||||
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
|
||||
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
|
||||
|
||||
const openingRoomContract = {
|
||||
kind: 'room', wallCm: 30, surface: 'flat',
|
||||
openings: [
|
||||
@@ -165,6 +160,19 @@ const decorLayerProbes = {
|
||||
};
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
// #462 AC12: the card-level version recovery notice must stay compact,
|
||||
// readable and outside layout/fit flow on desktop, narrow touch and the
|
||||
// manual-only kiosk state after this tab already attempted the target.
|
||||
{ id: 'version-mismatch-desktop-dark-en', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend',
|
||||
language: 'en', theme: 'dark', viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'version-mismatch-touch-light-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend', touchViewport: true,
|
||||
language: 'ru', theme: 'light', viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'version-mismatch-kiosk-attempted-dark-de', fixture: 'visual', space: 'golden-geometry',
|
||||
mode: 'view', integrationVersion: '0.0.0-golden-backend',
|
||||
versionRecoveryAttempted: true, kiosk: true,
|
||||
language: 'de', theme: 'dark', viewport: { width: 820, height: 760 }, ...page },
|
||||
{ id: 'hidden-wall-diagnostics-plan-light', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
hiddenWallDiagnostics: true, mode: 'plan', theme: 'light',
|
||||
@@ -200,23 +208,23 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, showBorders: false, fillMode: 'custom',
|
||||
alpha: true, projection: 'iso', showBorders: false, fillMode: 'custom',
|
||||
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, kiosk: true,
|
||||
alpha: true, projection: 'iso', kiosk: true,
|
||||
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
|
||||
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, warmRemount: true,
|
||||
alpha: true, projection: 'iso', warmRemount: true,
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -348,7 +356,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
alpha: true, projection: 'iso',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
@@ -375,7 +383,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
mode: 'view', openingSymbolContract: openingFlipContract,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
|
||||
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
mode: 'view', alpha: true, projection: 'iso',
|
||||
openingSymbolContract: openingIsoContract,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import {
|
||||
captureEnvironment, foreignCaptureAllowance, foreignCaptureRefusal,
|
||||
} from '../../scripts/capture-environment.mjs';
|
||||
|
||||
// The name must NOT end with `manifest.json`: the HACS submission check globs
|
||||
// `*manifest.json` over the whole clone of the default branch and refuses a
|
||||
// repository with more than one match (test/repo-hygiene.test.mjs).
|
||||
export const GOLDEN_BASELINE_MANIFEST = 'baselines-index.json';
|
||||
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '') => {
|
||||
/**
|
||||
* Проверка вызова — и заодно проверка среды съёмки (#455).
|
||||
*
|
||||
* Почему здесь, а не в `run.mjs`: `run.mjs` входит в корпус
|
||||
* `sourceFingerprint`, и его правка объявляет устаревшими закоммиченный бандл,
|
||||
* манифест скриншотов документации и индекс эталонов. Этот файл из корпуса
|
||||
* исключён именно как «предикаты, вызываемые до и после съёмки», и уже
|
||||
* вызывается первой строкой `run.mjs` — гейт встаёт без единой правки
|
||||
* фингерпринтуемого файла.
|
||||
*
|
||||
* Платформа — ПАРАМЕТР, а не `process.platform` внутри: иначе юнит стал бы
|
||||
* зелёным на Linux и красным на машине владельца, то есть тестом про хост, а
|
||||
* не про правило.
|
||||
*/
|
||||
export const assertGoldenInvocation = (mode, scenarioFilter = '', options = {}) => {
|
||||
if (!['capture', 'verify'].includes(mode)) throw new Error(`unknown golden mode: ${mode}`);
|
||||
if (mode === 'verify' && scenarioFilter)
|
||||
throw new Error('golden verify must run the complete matrix; use capture for a diagnostic --scenario run');
|
||||
// Диагностический прогон verify в чужой среде законен: он ничего не принимает.
|
||||
if (mode !== 'capture') return;
|
||||
const platform = options.platform ?? captureEnvironment().platform;
|
||||
const allowance = options.allowance ?? foreignCaptureAllowance();
|
||||
const { refusal, allowance: accepted } = foreignCaptureRefusal({
|
||||
platform, kind: 'golden', stage: 'capture', allowance,
|
||||
});
|
||||
if (refusal) throw new Error(refusal);
|
||||
if (accepted) console.log(`Чужая среда съёмки разрешена осознанно: ${accepted}`);
|
||||
};
|
||||
|
||||
/** A reviewed golden matrix is exact: neither an orphan PNG nor a stale hash
|
||||
|
||||
@@ -12,7 +12,18 @@
|
||||
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
|
||||
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
|
||||
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
|
||||
3. **Каталог, а не файл** — проб три, и они читаются как набор.
|
||||
3. **Каталог, а не файл** — проб несколько, и они читаются как набор.
|
||||
|
||||
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
|
||||
и служит guard'ом двух мутантов в `scripts/mutation-gate.mjs`.
|
||||
и служит guard'ом мутантов в `scripts/mutation-gate.mjs`.
|
||||
|
||||
Одна проба живёт не здесь: `--guard-probe` у `demo/benchmark_backdrop_decode.mjs`
|
||||
(#430). Benchmark нельзя переселить в этот каталог — его запускают руками при
|
||||
рекалибровке порогов, — поэтому `verify-guard.mjs` умеет запускать файл выше
|
||||
каталога и с аргументами. Режим `--guard-probe` пропускает все замеры, бросает
|
||||
одно исключение внутри страницы и обязан выйти с кодом 1.
|
||||
|
||||
Новый benchmark, открывающий страницу Playwright, обязан получить такой же
|
||||
режим и запись здесь: этого требует `test/smoke-harness-contract.test.mjs`, и
|
||||
требует статически — потому что динамическое доказательство возможно только
|
||||
там, где есть браузер.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Probe #421: reportPageErrors() owns a separate delivery round-trip from finish().
|
||||
//
|
||||
// The page error is scheduled at the tail of the browser turn and there is no
|
||||
// page interaction afterwards. The verdict must therefore flush the page by
|
||||
// itself. This probe deliberately never calls finish(): doing so would prove
|
||||
// the older branch while leaving reportPageErrors() blind.
|
||||
import { launch, reportPageErrors } from '../serve.mjs';
|
||||
|
||||
const { browser, page } = await launch();
|
||||
await page.evaluate(() => {
|
||||
setTimeout(() => { throw new Error('guard-report-page-errors'); }, 0);
|
||||
});
|
||||
await reportPageErrors();
|
||||
await browser.close();
|
||||
@@ -18,7 +18,13 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */
|
||||
/**
|
||||
* Каждая проба: чего ждём от кода возврата и что обязано быть в выводе.
|
||||
*
|
||||
* `args` — необязательные аргументы запуска; `file` может указывать и выше
|
||||
* этого каталога (`../benchmark_*.mjs`), потому что benchmark в `demo/guard/`
|
||||
* не переселить: его гоняют руками при рекалибровке порогов (#430).
|
||||
*/
|
||||
const PROBES = [
|
||||
{
|
||||
file: 'guard_tail_exception.mjs',
|
||||
@@ -38,18 +44,37 @@ const PROBES = [
|
||||
expectOutput: /OK/,
|
||||
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
|
||||
},
|
||||
{
|
||||
// #430: до этой задачи гард benchmark доказывался тестом, который искал
|
||||
// в тексте те самые подстроки, которые сам же и вырезал у мутанта, —
|
||||
// доказано было, что регулярка не пуста. Режим `--guard-probe` в
|
||||
// benchmark существовал с #423 и не вызывался ни одним прогоном; теперь
|
||||
// вызывается здесь, в единственной job с настоящим браузером.
|
||||
file: '../benchmark_backdrop_decode.mjs',
|
||||
args: ['--guard-probe'],
|
||||
expectExit: 1,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'benchmark открывает страницу Playwright и обязан выносить тот же вердикт,'
|
||||
+ ' что и смоки: исключение внутри карточки во время замера иначе не увидит никто',
|
||||
},
|
||||
{
|
||||
file: 'guard_report_page_errors.mjs',
|
||||
expectExit: 1,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'reportPageErrors() обязан сам дождаться хвостового pageerror, не полагаясь на finish()',
|
||||
},
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const probe of PROBES) {
|
||||
const run = spawnSync(process.execPath, [resolve(HERE, probe.file)], {
|
||||
const run = spawnSync(process.execPath, [resolve(HERE, probe.file), ...probe.args || []], {
|
||||
encoding: 'utf8', cwd: resolve(HERE, '../..'), timeout: 90_000,
|
||||
});
|
||||
const output = `${run.stdout || ''}${run.stderr || ''}`;
|
||||
const exitOk = run.status === probe.expectExit;
|
||||
const textOk = probe.expectOutput.test(output);
|
||||
if (exitOk && textOk) {
|
||||
console.log(`ok ${probe.file} → exit ${run.status}`);
|
||||
console.log(`ok ${[probe.file, ...probe.args || []].join(' ')} → exit ${run.status}`);
|
||||
continue;
|
||||
}
|
||||
failed += 1;
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
|
||||
40 columns and 500 decor objects on three floors.
|
||||
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. Because the
|
||||
product flag expires at 1.65.0, the runner injects an explicit test-only Labs
|
||||
snapshot, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A comparison SHA predating #89 remains flat
|
||||
while reporting the same profile. The dedicated
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. The runner
|
||||
enables the current candidate through the real `hp_alpha=1` URL/storage
|
||||
contract, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A compatibility-only snapshot keeps comparison
|
||||
bundles from before #448 measurable without restoring a legacy user-facing
|
||||
activation path; a comparison SHA predating #89 remains flat while reporting
|
||||
the same profile. The dedicated
|
||||
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
@@ -23,6 +25,24 @@ websocket traffic change. Its dedicated budget retains every original timing,
|
||||
heap and cache ceiling and adds the measured pointer series plus a one-entry
|
||||
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
|
||||
|
||||
Issue #451 adds `large-house-interaction-v1`. It dispatches 120 real
|
||||
device/room/free-background hover moves, four 20-move pans, pinch/wheel camera input, three editor
|
||||
drag series and 30 unrelated HA snapshots. It also injects relevant snapshots
|
||||
inside and outside a gesture and counts heavy `_renderBody()` passes plus full
|
||||
marker-binding diagnostic scans. Current bundles fail inside the runner unless
|
||||
hover, editor moves and unrelated ticks perform zero full renders, a relevant
|
||||
tick is deferred during pan, each terminal event reconciles at most one full
|
||||
frame, the heavy wall DOM remains identical, and the projected HTML device
|
||||
layer stays within one CSS pixel of its SVG scene position. Older comparison
|
||||
bundles still produce timing baselines; structural assertions become mandatory
|
||||
only when the target source contains the live viewport implementation. Its
|
||||
dedicated budget preserves every existing large-house ceiling and adds the
|
||||
individual interaction timing gates.
|
||||
|
||||
Structural assertions are evaluated independently of timing budgets: a fast
|
||||
run still fails when an interaction performs an unexpected full render or
|
||||
changes the heavy scene DOM.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
@@ -119,11 +139,33 @@ it would make the same runner incompatible with its base bundle.
|
||||
|
||||
## Local diagnostics
|
||||
|
||||
### Wall draw terminal clicks
|
||||
|
||||
`wall-draw-click-v1` (#461) loads the production bundle with five spaces. The
|
||||
edited space contains 12 rooms, 48 positive-thickness wall atoms and four saved
|
||||
open drafts, then places seven consecutive Walls segments after warm-up. A
|
||||
second variant doubles remote, non-interacting room geometry. The runner fails
|
||||
structurally unless every intermediate click performs zero full-space physical
|
||||
checks, exactly one local physical check, reuses its wall artifact in the
|
||||
junction proof, adds one history entry and queues one full-config write. The
|
||||
chain finish must independently return to the full-space barrier.
|
||||
|
||||
The canonical Linux ceilings are 150 ms median and 250 ms maximum for the seven
|
||||
base clicks; the remote median may not exceed `base × 1.5 + 20 ms`. Counters are
|
||||
the primary verdict, so a fast machine cannot hide a route back through the
|
||||
generic barrier. Run against a freshly synchronized bundle:
|
||||
|
||||
```bash
|
||||
npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
node demo/smoke_wall_draw_click.mjs
|
||||
```
|
||||
|
||||
Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-interaction-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"interactionSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"hoverSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"panSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"cameraSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"editorSeriesMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 750
|
||||
},
|
||||
"irrelevantHaTicksMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"relevantHaTickMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 40,
|
||||
"hardMaxMs": 250
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.4,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.35,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.3,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.35,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"longTaskWindows": {
|
||||
"hoverSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"panSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"cameraSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 },
|
||||
"editorSeries": { "maxSingleMs": 150, "maxCountP95": 3, "maxTotalP95Ms": 300 }
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.75,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -17,22 +17,41 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
label: 'large-house-v1',
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_bindingStatus',
|
||||
'_cancelDecorGesture',
|
||||
'_checkSpacePhysicalGeometry',
|
||||
'_decorBoxOf',
|
||||
'_dtMeasure',
|
||||
'_dtMove',
|
||||
'_dtStart',
|
||||
'_openSettingsDialog',
|
||||
'_pickSpace',
|
||||
'_pos',
|
||||
'_renderBody',
|
||||
'_rszCancelDrag',
|
||||
'_rszEdgeDown',
|
||||
'_rszMove',
|
||||
'_rszRooms',
|
||||
'_setMode',
|
||||
'_scenePoint',
|
||||
'_viewOr',
|
||||
]),
|
||||
fields: Object.freeze([
|
||||
'_booting',
|
||||
'_bootSoft',
|
||||
'_cameraTransition',
|
||||
...CACHE_FIELDS,
|
||||
'_continuity',
|
||||
'_cursorPt',
|
||||
'_decorList',
|
||||
'_decorSel',
|
||||
'_decorTool',
|
||||
'_devices',
|
||||
'_gridPitch',
|
||||
'_hassSequence',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_modeTransitionBusy',
|
||||
'_path',
|
||||
'_serverCfg',
|
||||
'_settingsDialog',
|
||||
@@ -42,7 +61,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
// checks these members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze([
|
||||
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
|
||||
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
]),
|
||||
// #380: v1.68.1 owns the same resize session directly on the card; newer
|
||||
@@ -54,12 +73,19 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_bootSoft: 'boolean',
|
||||
_cameraTransition: 'object',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_continuity: 'object',
|
||||
_decorList: 'array',
|
||||
_decorTool: 'string',
|
||||
_draftSegmentCms: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_hassSequence: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_liveEditorPaintCount: 'number',
|
||||
_model: 'array',
|
||||
_onLabsSnapshot: 'function',
|
||||
_offerWallFaces: 'function',
|
||||
|
||||
@@ -143,6 +143,20 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
|
||||
},
|
||||
));
|
||||
|
||||
for (const [windowName, budget] of Object.entries(budgets.longTaskWindows ?? {})) {
|
||||
const windows = candidate.rows.map((row) => row.longTasks?.[windowName]);
|
||||
const available = windows.every((window) => window?.supported === true);
|
||||
checks.push({
|
||||
id: `longTask.${windowName}.available`, actual: available ? 1 : 0, limit: 1, pass: available,
|
||||
});
|
||||
const maxSingle = available ? Math.max(...windows.map((window) => window.maxMs)) : NaN;
|
||||
const countP95 = available ? percentile(windows.map((window) => window.count), 0.95) : NaN;
|
||||
const totalP95 = available ? percentile(windows.map((window) => window.totalMs), 0.95) : NaN;
|
||||
checks.push(makeCheck(`longTask.${windowName}.maxSingleMs`, maxSingle, budget.maxSingleMs));
|
||||
checks.push(makeCheck(`longTask.${windowName}.countP95`, countP95, budget.maxCountP95));
|
||||
checks.push(makeCheck(`longTask.${windowName}.totalP95Ms`, totalP95, budget.maxTotalP95Ms));
|
||||
}
|
||||
|
||||
const candidateHeap = candidate.rows
|
||||
.map((row) => row.heapGrowthBytes)
|
||||
.filter(finite)
|
||||
|
||||