Compare commits

..
Author SHA1 Message Date
claude[bot] 5eef4346ca docs: review document for #368
Issue: #368
User-Visible: no
2026-08-29 09:36:29 +00:00
Codex 774388c2e5 docs: name the expected_rev requirement for external writers (#368)
#340/#356 made expected_rev mandatory for config/set and layout/set over a
non-empty store — an honest protection the release notes sold only as a
stale-tab guard. A third-party script writing plans directly cannot infer
from "protects from stale tabs" that it must now read the revision first.

Both changelogs gain an explicit breaking-for-external-writers entry with
the read-then-write recipe; ARCHITECTURE.md's WS contract section extends
the #340 paragraph with the cycle external clients must follow (get rev →
send expected_rev → on conflict re-read and retry); and both conflict
messages now carry the actionable hint for scripts — "include expected_rev
from houseplan/config/get / layout/get" — alongside the tab-oriented
"reload" advice. The backend tests pin only the "revision is required"
substring and stay untouched.

Issue: #368
User-Visible: yes
2026-08-29 12:29:33 +03:00
686 changed files with 18598 additions and 95257 deletions
+2 -34
View File
@@ -9,12 +9,6 @@
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
# попадают в репозиторий через явное решение, а не через бота.
#
# Снимать здесь больше не обязанность, а удобство (#401). Приёмка проверяет не
# место съёмки, а её воспроизводимость: каждый кадр, не объявленный изменённым,
# должен совпасть с закоммиченным байт-в-байт. Эта джоба потому и удобна, что
# среда у неё та же, в которой снят закоммиченный набор, — но принять получится
# из любой, где кадры воспроизводятся, и не получится ни из одной, где нет.
name: Скриншоты документации
on:
@@ -77,40 +71,14 @@ jobs:
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/oxipng" --version
# Съёмка идёт дважды и сравнивается по хешам (#422). Прежний замер
# (`--stability`) отвечает на вопрос «плавает ли кадр от времени внутри
# страницы» и остаётся ниже; дефект #410 был по другой оси — обрезка
# плавала МЕЖДУ прогонами, и три снимка внутри одного процесса совпали бы
# всегда. Проверка, объявленная гарантией воспроизводимости, на
# собственном инциденте промолчала бы.
#
# Второй прогон заодно оставляет в `docs/images` кадры, которые и уедут в
# артефакт: сравнивать хеши и публиковать разные файлы было бы странно.
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
run: node scripts/capture-determinism.mjs
- name: Capture
run: node demo/docs/capture.mjs
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
# браузер: тот же Chromium и десять изменившихся картинок означают, что
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
# разницу рендеринга.
# Хеши печатаются в лог, а не только уезжают в артефакт (#410): чтобы
# сравнить два прогона одного и того же SHA, нужен текст, который видно с
# экрана. Именно так измеряется недетерминированность съёмки — и её
# отсутствие после починки.
# Съёмка обязана быть воспроизводимой, и это проверяется, а не
# предполагается (#410): три снимка на сценарий в одном процессе, без
# правки состояния между ними, обязаны совпасть побайтово. Шаг падает,
# если кадр снова начнёт зависеть от времени.
#
# Шаг стоит ПОСЛЕ съёмки и до вердикта: публикацию артефакта он всё равно
# блокирует падением job'а, а снимать набор третий раз ради порядка строк
# в логе незачем.
- name: "Кадр не плавает внутри одного состояния (#410)"
run: node demo/docs/capture.mjs --stability=3
- name: Хеши кадров
run: |
for f in docs/images/*.png; do sha256sum "$f"; done
- name: Вердикт
run: |
# Поле манифеста «до» — из закоммиченного состояния, «после» — из
+2 -7
View File
@@ -61,15 +61,10 @@ jobs:
- uses: actions/setup-python@v7
with:
python-version: '3.14'
python-version: '3.13'
- name: Установить backend test dependencies
run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
# резолвер выбирает набор сам, и «зелёный backend» значит разное
# в разные дни.
pip install -r tests_backend/requirements.txt
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Кэш браузеров Playwright
id: pw
+1 -21
View File
@@ -27,7 +27,7 @@ jobs:
name: Бенчмарки рендера и геометрии
# Every profile keeps base and candidate sequential on one hosted runner.
# Independent profile pairs run in parallel: cross-profile timing is never
# compared, while serialising all profile pairs cannot fit the job timeout.
# compared, while serialising all five pairs cannot fit the job timeout.
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -39,8 +39,6 @@ jobs:
- plan-snap
- blend
- overlay
- space-default
- space-glow
steps:
- name: Check out candidate
uses: actions/checkout@v7
@@ -202,18 +200,6 @@ jobs:
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
;;
space-default)
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-default-baseline.json
npm run benchmark:glow -- --profile=large-space-card-default-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-default-candidate.json
;;
space-glow)
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-baseline.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/space-glow-candidate.json
if ! grep -q "light_pools" ../baseline/src/space-card.ts; then
echo "Base predates opt-in static Glow; bootstrap relative baseline, keep absolute gate"
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
fi
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
@@ -241,12 +227,6 @@ jobs:
overlay)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
;;
space-default)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-default.json --baseline=../artifacts/performance/space-default-baseline.json --candidate=../artifacts/performance/space-default-candidate.json --output=../artifacts/performance/space-default-comparison.json
;;
space-glow)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
;;
esac
- name: Upload full performance report
-61
View File
@@ -226,20 +226,6 @@ jobs:
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
# ветку, и спрашивать его поздно.
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
@@ -643,9 +629,6 @@ jobs:
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
@@ -694,14 +677,6 @@ jobs:
mkdir -p docs/reviews
cp "$SOURCE" "$doc"
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
# Якоря дописывает конвейер, а не ревьюер (#414). Дисциплина здесь
# уже подводила: на #403 SHA сняли до ребейза и не сверили перед
# выводом — через раунд команда из §2.10 не работала. Машина же
# снимает якоря в момент чтения материала и ошибиться в них не
# может; блок помечен как машинный, чтобы никто не правил его руками.
node scripts/review-doc-guard.mjs --anchor="$doc" \
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS"
else
echo "::warning::$SOURCE не найден — документа для публикации нет"
fi
@@ -770,42 +745,6 @@ jobs:
fi
echo "документ опубликован в $target: $doc"
# Материал раунда обязан быть достижим с origin (#413).
#
# SPEC-REVIEW-403-r2 объявил материал на `HEAD = 83005c3c`, и тот же SHA
# независимо назвал автор ТЗ в комментарии issue. Коммит существовал, но
# к моменту публикации был осиротевшим: ветку перебазировали за 15 минут
# ДО публикации документа, спец-коммит переехал в 94502d3d с тем же
# сообщением и тем же содержимым. Через раунд команда `git diff
# 83005c3c..HEAD` из §2.10 буквально не работала, и r3 восстанавливал
# реальный коммит по содержимому диффа руками.
#
# Проверка стоит ПОСЛЕ публикации намеренно. Артефакт ревью терялся здесь
# трижды (#171, #220), и «вердикт без документа» в этом репозитории
# дороже мёртвой ссылки: документ сначала спасается, потом судится. Шаг
# при этом идёт ДО «Переставить метку», поэтому инвариант «метка не
# сменилась = прогон упал» сохраняется.
#
# Достижимость считается от `refs/remotes/origin/*`, а не от локальных
# ссылок: осиротевший 83005c3c до сих пор лежит в клоне автора и
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
# пользы нет — он достанет только то, что есть на origin.
- name: "Материал раунда воспроизводим (#413)"
if: steps.rebase.outputs.conflict != 'true'
env:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ steps.branch.outputs.name }}
run: |
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
target="${BRANCH:-dev}"
git fetch -q origin "$target"
# Судится опубликованная версия, а не рабочая копия: именно её прочтёт
# следующий раунд.
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
- name: Решение по вердикту
id: decide
if: steps.rebase.outputs.conflict != 'true'
+31 -217
View File
@@ -32,13 +32,6 @@ jobs:
preflight:
name: "Предполётные проверки: документация, провенанс, процесс"
runs-on: ubuntu-latest
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
# проверка 8 процессного гейта. Права перечислены явно, потому что job
# обращается к API сверх содержимого репозитория.
permissions:
contents: read
actions: read
issues: read
steps:
# `blob:none` при полной истории (#345): этой job нужны сообщения
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
@@ -76,38 +69,12 @@ jobs:
exit 1
fi
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
# никто: свой прогон отменён, а следующий пуш сравнивает уже с ними.
#
# База — последний предок с УСПЕШНО завершённым Validate. Фолбэк остаётся
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
# в красноте навсегда.
- name: "База диапазона: последний доказанно зелёный предок"
id: range
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
FALLBACK: ${{ github.event.before }}
run: |
# `status=completed`, а не `success`: гейтам диапазона нужен факт
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch=dev -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
- name: "Провенанс коммитов: трейлеры и эталоны"
id: provenance
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
@@ -125,7 +92,7 @@ jobs:
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
@@ -173,65 +140,20 @@ jobs:
changes:
name: Классификация изменённых файлов
runs-on: ubuntu-latest
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
# содержимого репозитория (#387).
permissions:
contents: read
actions: read
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
# судить сам диапазон на dev (#388). Общее имя означало бы, что
# потребитель однажды молча возьмёт чужую базу.
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
# блобы истории этой job не нужны ни на одном шаге (#345).
- uses: actions/checkout@v7
with: { fetch-depth: 0, filter: 'blob:none' }
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
# без единого исполненного тяжёлого гейта.
- id: base
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
BRANCH: ${{ github.ref_name }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
FALLBACK: ${{ github.event.before }}
run: |
git fetch -q origin dev
# Недоступность API — не отказ гейта: пустой ответ уводит базу в
# сторону БОЛЬШЕГО объёма проверок, а не меньшего.
# Один запрос на оба режима: `completed` — надмножество `success`,
# а нужный предикат применяет скрипт (#387 — успех, #388 — судимость).
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
-f branch="$BRANCH" -f status=completed -F per_page=100 \
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
if [ "$REF" = "refs/heads/dev" ]; then
# На dev классифицировать нечего (всё true), но база диапазона
# нужна гейту «новый код не добавляет any» в job frontend (#388).
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
exit 0
fi
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
node scripts/classify-base.mjs --head="$HEAD_SHA" \
--merge-base="$merge_base" --runs=/tmp/validate-runs.json
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
GREEN_BASE: ${{ steps.base.outputs.base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
@@ -241,45 +163,39 @@ jobs:
exit 0
fi
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# #347: непустой before, которого больше не существует, — это
# force-push (ребейз/аменд переписал историю ветки). Диапазон в этом
# случае НЕ угадывается: merge-base после переписанной истории уже
# выдавал «изменились только два doc-файла» при реальном диффе в
# custom_components/**, и тяжёлые job тихо скипались, а общий статус
# рана оставался success — ровно класс тихого пропуска #171/#207.
# Force-push редок, полный прогон дешевле ложного «зелёного».
#
# Проверка сохранена как есть и после #387: там база стала доказуемой
# (зелёный предок), но механизм, из-за которого merge-base врал на
# переписанной истории, не разобран до конца. Снимать защиту, не
# объяснив её, — способ получить #347 второй раз.
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \
&& ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации"
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
# этом случае НЕ угадывается: merge-base после переписанной истории
# уже выдавал «изменились только два doc-файла» при реальном диффе
# в custom_components/**, и тяжёлые job тихо скипались, а общий
# статус рана оставался success — ровно класс тихого пропуска
# #171/#207. Force-push редок, полный прогон дешевле ложного
# «зелёного».
if [ -n "$base" ] && [ "$base" != "$zero" ] \
&& ! git cat-file -e "$base" 2>/dev/null; then
echo "force-push: before=$base переписан — полный прогон без классификации"
{
echo "### Классификация изменённых файлов"
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
echo "все тяжёлые job запускаются без фильтра путей (#347)."
} >> "$GITHUB_STEP_SUMMARY"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
# а не от головы предыдущего пуша: его прогон штатно отменяется
# следующим пушем. PR — исключение: там база задана событием.
base="$GREEN_BASE"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
if [ -z "$base" ]; then
echo "база не определена — полный прогон без классификации"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
git fetch -q origin dev
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
{
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')"
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
@@ -423,24 +339,17 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
git fetch -q origin dev
zero=$(printf '%040d' 0)
base=""
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
# его прогон штатно отменяется следующим, и добавленные им строки не
# судил бы никто. Фолбэк — прежний `before`.
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
base="${PROVEN_BASE:-$BEFORE_SHA}"
fi
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
# Там диапазон и так шире, поэтому #388 этих случаев не касается.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# Пуш прямо в dev: диапазон — то, что этот пуш добавил. Новая ветка или
# перезаписанная история: точки отсчёта нет, берём merge-base с dev.
if [ "$REF" != "refs/heads/dev" ] || [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; then
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
fi
@@ -489,7 +398,6 @@ jobs:
needs: [frontend, reuse]
if: needs.reuse.outputs.smoke != 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
@@ -533,14 +441,6 @@ jobs:
path: test-build
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
# Гард исключений внутри карточки объявлен в demo/serve.mjs с 2026-07-27 и
# до #404 не срабатывал ни разу: счётчик читался раньше, чем Playwright
# доставлял pageerror. Такое ловится только запуском, поэтому пробы живут
# здесь — в единственной job с настоящим браузером. Один шард из трёх:
# проверка не зависит от разбиения, а платить за неё трижды незачем.
- name: "Гард исключений умеет падать (#404)"
if: matrix.shard == 1
run: node demo/guard/verify-guard.mjs
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
env:
SHARD: ${{ matrix.shard }}
@@ -557,20 +457,10 @@ jobs:
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
ran=$((ran + 1))
name=$(basename "$f" .mjs)
if timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
if 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), а в логе прогона осталось
# только «expected true, got false». Числа теперь достаются
# адресно — они и есть разница между «чинить» и «гадать».
grep -aE 'pixel-diffs|^EXC |diagnostic' "/tmp/smoke-logs/$name.log" | tail -5 || true
tail -20 "/tmp/smoke-logs/$name.log"
fail=1
fi
@@ -647,14 +537,6 @@ jobs:
path: dist
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
# Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до
# прогона, потому что после падения шаги кэша уже не помогут отличить
# виновника от свидетеля.
- name: Прежнее падение на тех же входах
uses: actions/cache/restore@v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
- name: Capture or verify golden matrix
id: golden
run: |
@@ -671,25 +553,6 @@ jobs:
with:
name: golden-images
path: artifacts/golden
# Красная golden обязана сказать, ЧЕЙ это долг (#386). Без этого письмо
# «Run failed» называет коммит, который пушнули следующим: 29 августа так
# был назван документ ревью `0f7b6f5`, а сцену без эталона добавил
# `dbbe94ae`. Вердикт job не меняется — меняется только формулировка.
- name: Кто уронил golden
id: fail_note
if: failure()
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node scripts/gate-reuse.mjs --job=golden --note --marker=.fail-marker
- name: Запомнить первопричину
if: failure() && steps.fail_note.outputs.first == 'true'
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
# это тем более не отказ гейта — job и так красная по существу.
continue-on-error: true
uses: actions/cache/save@v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
@@ -752,11 +615,9 @@ jobs:
- name: Capture the heaviest Glow state
run: |
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
- name: Enforce absolute smoke ceilings
run: |
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
- name: Бюджеты ограничений стыков (#330)
@@ -796,57 +657,10 @@ jobs:
- uses: actions/setup-node@v7
with: { node-version: 22 }
- uses: actions/setup-python@v7
with: { python-version: "3.14" }
- run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
# резолвер выбирает набор сам, и «зелёный backend» значит разное
# в разные дни. #42 ставит из того же файла ruff для шага линта.
pip install -r tests_backend/requirements.txt
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
- name: Линт бэкенда (ruff, узкий набор)
run: python -m ruff check custom_components/houseplan
# #42: типизация была измеримой только локально — CI её не исполнял, и
# регрессия в любом из allowlist-модулей проходила молча. Список модулей
# берётся ИЗ pyproject.toml, а не дублируется здесь: разошедшийся дубль
# означал бы зелёный шаг, проверяющий не то. Пустой список — отказ.
- name: Типы бэкенда (mypy strict по allowlist)
run: |
modules=$(python -c "
import tomllib
cfg = tomllib.load(open('pyproject.toml', 'rb'))
for o in cfg['tool']['mypy'].get('overrides', []):
if o.get('strict'):
print(' '.join('-p ' + m for m in o['module']))
")
test -n "$modules" || { echo '::error::strict-allowlist в pyproject.toml пуст — проверять нечего'; exit 1; }
echo "mypy strict: $modules"
python -m mypy $modules
# #42: защита от тихого скипа HA-harness — импорт и порог collect
- name: HA-harness присутствует
run: |
python -c "import homeassistant"
collected=$(python -m pytest tests_backend/ -q --collect-only 2>/dev/null | grep -c "test_ha_" || true)
echo "collected HA-harness tests: $collected"
test "$collected" -ge 50
- name: Backend unit tests (pure + HA harness, coverage)
run: >
python -m pytest tests_backend/ -q
--cov=custom_components/houseplan --cov-branch
--cov-report=xml --cov-report=term
- name: Support relay unit tests
run: python -m unittest discover -s scripts/support-relay/tests -q
# #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа
- name: Порог покрытия не ниже baseline
run: |
baseline=$(head -1 scripts/backend-coverage-baseline.txt)
actual=$(python -c "import xml.etree.ElementTree as ET; print(round(float(ET.parse('coverage.xml').getroot().get('line-rate'))*100, 1))")
echo "coverage: $actual% (baseline $baseline%)"
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
- name: Артефакт coverage.xml
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: coverage.xml
with: { python-version: "3.13" }
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Backend unit tests (pure + HA harness)
run: python -m pytest tests_backend/ -q
# Маркер пишется последним шагом: он существует только если всё выше
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
- name: Записать маркер успеха
+5 -54
View File
@@ -193,35 +193,6 @@ 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).
@@ -266,36 +237,16 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
Порядок:
1. найти вердикт предыдущего раунда и **материал, на котором он получен**.
Материал объявлен блоком «Материал раунда» в конце документа предыдущего
раунда: конвейер дописывает туда SHA ветки, **дерево** материала и **блоб**
каждого ТЗ вместе с командами поиска (issue #416). Блок машинный — править
его руками не нужно и не следует;
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
вердикте не назван — это находка;
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
issue для этапа ТЗ.
**Если SHA не резолвится — это не находка, а обычное дело.** Ветку задачи
между раундами перебазируют, сквошат или удаляют, и SHA умирает: по корпусу
ревью таких объявлений 98 из 804. Материал в этом случае берётся по якорям,
которые ребейз не меняет, потому что адресуются содержимым:
```
git log --all --format='%H %T' | grep <дерево>
git log --all --find-object=<блоб> -- <путь к ТЗ>
```
Находкой остаётся другое: **SHA, мёртвый уже в момент публикации отчёта** —
он означает, что значение сняли до `amend` или `rebase` и не сверили перед
выводом, как требует §7.2. Это отличие не теоретическое: на #403 оба
источника, автор и ревьюер, независимо назвали один и тот же осиротевший
SHA, и следующий раунд восстанавливал коммит по содержимому диффа руками
(issue #413). Конвейер теперь такую публикацию останавливает сам;
issue для этапа ТЗ;
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
строкой кода или текста, а не заявлением автора;
4. заново проверять только те AC, чьё доказательство дельта задевает;
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
проверки, со ссылкой на документ того раунда и его материал. Без перечня
сокращение превращается в молчаливое доверие.
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
превращается в молчаливое доверие.
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
+4 -14
View File
@@ -13,11 +13,12 @@ from homeassistant.helpers.event import async_track_time_interval
from . import websocket_api as hp_ws
from .const import (
ASSETS_DIR,
DOMAIN,
FILES_DIR,
FILES_URL,
FRONTEND_URL,
PLANS_DIR,
PLANS_URL,
VERSION,
)
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
@@ -37,16 +38,10 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
hass.data.setdefault(DOMAIN, {})
hp_ws.async_register(hass)
from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView
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())
@@ -90,13 +85,8 @@ 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),
assets_path.mkdir(parents=True, exist_ok=True),
)
lambda: (plans_path.mkdir(parents=True, exist_ok=True), files_path.mkdir(parents=True, exist_ok=True))
)
# Static paths cannot be unregistered — register once per HA run.
@@ -1,139 +0,0 @@
"""Bounded, shared integrity verification for content-addressed decor assets."""
from __future__ import annotations
import hashlib
import logging
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
_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:
stat = path.stat()
return FileSignature(
size=stat.st_size,
mtime_ns=stat.st_mtime_ns,
ctime_ns=stat.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:
flight.event.wait()
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
@@ -2,6 +2,7 @@
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from .const import CONF_ADMIN_ONLY, DOMAIN
+3 -59
View File
@@ -29,9 +29,6 @@ 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
@@ -49,29 +46,14 @@ 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.71.0-beta.2"
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
# false. The relay is deployed and operated by the project; changing it is a
# 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
MAX_SUPPORT_MESSAGE_CODEPOINTS = 10_000
MAX_SUPPORT_CONTACT_CODEPOINTS = 320
VERSION = "1.69.0-beta.3"
# Portable backup format. This is deliberately independent from the Home
# Assistant Store version above: storage migrations and files exported by a
# user have different compatibility lifecycles.
PLAN_MODEL_VERSION = 9
EXPORT_VERSION = 2
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
MAX_IMPORT_PREVIEWS_PER_USER = 3
@@ -81,46 +63,8 @@ MAX_IMPORT_PREVIEWS_PER_USER = 3
# accounts.
MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict[str, object] = {
DEFAULT_CONFIG: dict = {
"spaces": [],
"markers": [],
"settings": {"bg_mode": "daynight"},
}
# #42: THE stable public error-code contract. Every code a user-facing
# failure can carry — send_error literals, exception-class codes and the
# literal MarkerControlError codes — lives here; the scanner in
# tests_backend/test_backend_quality.py fails when a source emits a code
# outside this set (fail-closed), and every fixed code has a localized
# `backup.error.<code>` message on the frontend.
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_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_value_badge_attribute", "invalid_value_badge_position",
"invalid_value_badge_source", "invalid_value_source",
"invalid_value_source_attribute", "io_error", "marker_control_cycle",
"marker_control_missing", "marker_control_not_light",
"marker_control_self", "missing_content", "missing_plan", "no_backup",
"not_ready", "not_toggleable", "nothing_to_repair", "preview_expired",
"preview_owner_mismatch", "space_in_use", "space_not_found",
"support_invalid_message", "support_package_too_large",
"support_preview_expired", "support_rate_limited", "support_rejected",
"support_unavailable",
"too_large", "unauthorized", "unsupported_export_version", "unsupported_image",
"value_badge_source_required", "wall_model_client_outdated",
"wall_model_migration_blocked",
})
# Template-code families: f-string codes carry one of these prefixes and are
# served by the generic per-code fallback on the frontend.
ERROR_CODE_FAMILIES: tuple[str, ...] = (
"junction_limit_",
"value_badge_",
"value_source_",
)
@@ -10,11 +10,11 @@ import copy
import math
from typing import Any
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:
@@ -151,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 DECOR_BOX_KINDS:
elif kind in ("rect", "ellipse", "furniture"):
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
-454
View File
@@ -1,454 +0,0 @@
"""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_blobs(root: Path) -> list[Path]:
"""Return exact promoted blob files, independently from their sidecars."""
if not root.is_dir():
return []
blobs: list[Path] = []
for path in root.iterdir():
if (
path.suffix in ASSET_EXTENSIONS
and ASSET_ID_RE.fullmatch(path.stem)
and stat.S_ISREG(path.stat(follow_symlinks=False).st_mode)
):
blobs.append(path)
return sorted(blobs, key=lambda path: path.name)
def physical_asset_usage(root: Path) -> tuple[int, int]:
"""Return promoted blob count and actual bytes used for quota checks."""
blobs = physical_asset_blobs(root)
return len(blobs), sum(path.stat().st_size for path in blobs)
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,113 @@
{
"schema": 1,
"fingerprint": "e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec",
"fingerprint": "b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-o7i25aVl.js",
"houseplan-assets/houseplan-card-DCggDxve.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 291069,
"initialViewGzipBytes": 273697,
"lazyFiles": [
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/fr-DpKqMyVB.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/de-CDaPitbg.js",
"houseplan-assets/editor-CasKiVVQ.js",
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
],
"lazyGzipBytes": 215495,
"lazyGzipBytes": 168977,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js"
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/editor-CasKiVVQ.js",
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js"
],
"lazyEditorGzipBytes": 159809,
"lazyEditorGzipBytes": 139700,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
],
"lazyOnboardingGzipBytes": 14126,
"lazyOnboardingGzipBytes": 9445,
"lazyLocaleFiles": [
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/fr-DpKqMyVB.js"
"houseplan-assets/de-CDaPitbg.js"
],
"lazyLocaleGzipBytes": 48734,
"lazyLocaleGzipBytes": 22783,
"files": [
{
"path": "houseplan-assets/backdrop-pick-Dg2Uytel.js",
"sha256": "b67eb5e04c63007ff8ca7ff5623a7dcae44ff3c6f3ca5528ac7f1d389f75c4ec",
"rawBytes": 20996,
"gzipBytes": 7174,
"path": "houseplan-assets/backdrop-pick-qoGkr9B3.js",
"sha256": "e3bf2e1ca0f3352a31390d29596c901b60d3451bd65acb11045a4d2b9ab666a6",
"rawBytes": 6967,
"gzipBytes": 2951,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-o7i25aVl.js"
"houseplan-assets/houseplan-card-DCggDxve.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-D_5HnJeM.js",
"sha256": "ad2dde1407ae850f963cf81a8c0218c0e6646a86cf3bceb61a305cd54f60dad9",
"rawBytes": 81632,
"gzipBytes": 24639,
"path": "houseplan-assets/de-CDaPitbg.js",
"sha256": "968d659500403eee7ab7c8a0d6159621972df977b0b52a20c324921b6e309e09",
"rawBytes": 74836,
"gzipBytes": 22783,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CqQRQq4C.js",
"sha256": "5007144deff010072eb0a6a6d3e5565064a60575baa173a0250cec89b1478823",
"rawBytes": 3826,
"gzipBytes": 1580,
"path": "houseplan-assets/editor-CasKiVVQ.js",
"sha256": "c0ec3ab48e3d7a5bb767f02e7519ecdff932e4cb70a34f562a67b65f322c2350",
"rawBytes": 3831,
"gzipBytes": 1581,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-o7i25aVl.js"
"houseplan-assets/houseplan-card-DCggDxve.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DpKqMyVB.js",
"sha256": "3c03580d357a96678d80b012923e7e28e989fe6a612fe0342d5d041e2d37d4f1",
"rawBytes": 83754,
"gzipBytes": 24095,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-o7i25aVl.js",
"sha256": "9b3942532fe83c5d49791859c419fb6f35a8db86c01a09ddccb1a2ec186bfbd3",
"rawBytes": 1033268,
"gzipBytes": 290274,
"path": "houseplan-assets/houseplan-card-DCggDxve.js",
"sha256": "57bd9d474cdccceed23e0acf6b309ea216a29debf5eed20657ba978307b80133",
"rawBytes": 964180,
"gzipBytes": 272947,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-D_5HnJeM.js",
"houseplan-assets/editor-CqQRQq4C.js",
"houseplan-assets/fr-DpKqMyVB.js",
"houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js"
"houseplan-assets/de-CDaPitbg.js",
"houseplan-assets/editor-CasKiVVQ.js",
"houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
"houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-D0JYjEs4.js",
"sha256": "b2a69132a223d6c5b06b8d4a11b19416d33582bae2d39b2448ecc41f44ba98b2",
"rawBytes": 576032,
"gzipBytes": 151055,
"path": "houseplan-assets/houseplan-editor-runtime-DpcuLOlh.js",
"sha256": "4d0827d46f53b2ed22be6bf7a16dd9ab4fa97a04d340f236165eb6cd281891f9",
"rawBytes": 523356,
"gzipBytes": 135168,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-card-o7i25aVl.js"
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/houseplan-card-DCggDxve.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-C-SJxeJ_.js",
"sha256": "1324b8003d727c99c6ce6d9e48598032b29782f8c8c7b783c1b1073e9b79d9f2",
"rawBytes": 28088,
"gzipBytes": 6952,
"path": "houseplan-assets/houseplan-onboarding-runtime-g7xdzkz_.js",
"sha256": "292254483f1dd92dccbafc1600335127cf50206b5a58e53972ef514c7368c9f6",
"rawBytes": 26195,
"gzipBytes": 6494,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-Dg2Uytel.js",
"houseplan-assets/houseplan-card-o7i25aVl.js"
"houseplan-assets/backdrop-pick-qoGkr9B3.js",
"houseplan-assets/houseplan-card-DCggDxve.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "d6c8be124ce907703e76f24a5e9fedb3abd945213d7befb1f97d1635d82f4830",
"rawBytes": 1183,
"gzipBytes": 795,
"sha256": "c463ada4a8f02a5decf4a62a425cffb08f7d8ef22ec9c964e05474dce8b8aa16",
"rawBytes": 1089,
"gzipBytes": 750,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-o7i25aVl.js"
"houseplan-assets/houseplan-card-DCggDxve.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6";import{c as e,l as o,t,cZ as s,A as a,c_ as i,c$ as l,d0 as n,E as r,b as c}from"./houseplan-card-DCggDxve.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec";import{b as e,l as o,t,dg as s,A as a,dh as i,di as l,dj as n,E as r,c}from"./houseplan-card-o7i25aVl.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);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="e5edb95a1f5817c9035c6418ea0f9cb86cde238466f1f5c3174fb5246662c8ec";try{await import("./houseplan-assets/houseplan-card-o7i25aVl.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__="b76f28b2db2f8889fefaa8ea917e3afbad86383c2ea21d1fec3eba92af67c5b6";try{await import("./houseplan-assets/houseplan-card-DCggDxve.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).":"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)}
@@ -5,7 +5,6 @@ from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
try:
from homeassistant.components.http import KEY_HASS
except ImportError: # pragma: no cover - old HA compatibility
+13 -182
View File
@@ -5,50 +5,27 @@ 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,
CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES,
MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR,
)
from .auth import may_write
from .import_export import ImportFailure, create_preview
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
from .registry_snapshot import import_registry_snapshot
@@ -136,7 +113,7 @@ class HouseplanImportPreviewView(HomeAssistantView):
except ImportFailure as err:
status = 413 if err.code == "too_large" else 400
return web.json_response({"error": err.code, "message": err.message}, status=status)
except Exception: # noqa: BLE001 - the HTTP boundary must answer 400, never leak a traceback
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import preview failed")
return web.json_response({"error": "invalid_format"}, status=400)
return web.json_response(result)
@@ -158,38 +135,25 @@ 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", "assets"):
if kind not in ("plans", "files"):
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)
root_dir = PLANS_DIR if kind == "plans" else ASSETS_DIR if kind == "assets" else FILES_DIR
base = Path(hass.config.path(root_dir)).resolve()
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
# plans live flat in one directory: the sub segment is a placeholder ("_")
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()
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)
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):
if not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
suffix = path.suffix.lower()
headers = {
"Cache-Control": "private, max-age=31536000, immutable"
if kind == "assets" else "private, max-age=3600",
"Cache-Control": "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
@@ -215,139 +179,6 @@ 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:
return web.json_response({"error": err.code, "message": str(err)}, status=507)
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."""
@@ -433,7 +264,7 @@ class HouseplanUploadView(HomeAssistantView):
break
if pending:
await hass.async_add_executor_job(_flush, tmp, pending)
except Exception as err: # noqa: BLE001 - a broken multipart stream must answer 400, not crash the view
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
error = ({"error": "bad_request"}, 400)
+17 -160
View File
@@ -6,6 +6,7 @@ only the exact candidate that produced that preview.
"""
from __future__ import annotations
import copy
import hashlib
import json
import math
@@ -21,11 +22,10 @@ from urllib.parse import urlsplit
import voluptuous as vol
from .const import (
ASSETS_DIR,
CONTENT_URL,
EXPORT_VERSION,
FILES_DIR,
FILES_URL,
FILES_DIR,
IMPORT_PREVIEW_TTL_S,
MAX_EXPORT_BYTES,
MAX_IMPORT_PREVIEWS_PER_USER,
@@ -36,6 +36,10 @@ from .const import (
VERSION,
)
from .store import HouseplanData
from .wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
@@ -44,21 +48,16 @@ from .validation import (
MAX_LAYOUT,
MAX_MARKERS,
MAX_SPACES,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
sanitize_filename,
sanitize_marker_id,
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages,
validate_partition_opening_hosts,
)
from .wall_segment_model import (
WallSegmentMigrationError,
commit_wall_segment_model,
validate_opening_passages, validate_partition_opening_hosts,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
)
FORMAT = "houseplan-export"
@@ -74,13 +73,6 @@ _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",
@@ -104,8 +96,7 @@ _DECOR_KIND_FIELDS = {
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
"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"),
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
}
@@ -398,12 +389,6 @@ 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("/")
@@ -448,41 +433,6 @@ 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
@@ -551,14 +501,6 @@ def create_export(
continue
kept.append(ref)
marker["controls"] = kept or None
# #385(г): the two neutralisation formats below differ on
# purpose and are a PAIR — each disarms an external ref the
# way its own model represents "no source": the badge keeps
# its object with enabled=False/source=None (enabled is part
# of the badge model), while value_source's absence IS auto,
# so the key is dropped. Both count in dropped_marker_links;
# changing either format would break round-trips of existing
# exports.
badge = marker.get("value_badge")
source = badge.get("source") if isinstance(badge, dict) else None
ref = source.get("ref") if isinstance(source, dict) \
@@ -568,13 +510,6 @@ def create_export(
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
value_source = marker.get("value_source")
ref = value_source.get("ref") if isinstance(value_source, dict) \
and value_source.get("kind") == "derived_marker_state" else None
if isinstance(ref, str) and ref.startswith("marker:") \
and ref[len("marker:"):] not in selected_ids:
marker.pop("value_source", None)
dropped_marker_links += 1
config = {
"spaces": [_json_copy(space)],
"markers": _json_copy(selected_markers),
@@ -643,7 +578,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") not in (1, EXPORT_VERSION):
if document.get("export_version") != 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")
@@ -764,7 +699,7 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
if not isinstance(transfer, dict):
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
value = transfer.get("dropped_marker_links", 0)
maximum = MAX_MARKERS * (MAX_CONTROLS + 2)
maximum = MAX_MARKERS * (MAX_CONTROLS + 1)
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum:
raise ImportFailure("invalid_format", "Invalid dropped marker link count")
return value
@@ -809,11 +744,7 @@ 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 not (
item.get("owner") == "space"
or item.get("owner") == "decor" and item.get("kind") == "decor_asset"
)
not isinstance(item, dict) or item.get("owner") != "space"
for item in content
):
raise ImportFailure("invalid_format", "Plan-only export contains private content")
@@ -872,16 +803,6 @@ def _drop_invalid_import_marker_links(
badge["enabled"] = False
badge["source"] = None
dropped += 1
source = marker.get("value_source")
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
ref = source.get("ref")
target_id = ref[len("marker:"):] \
if isinstance(ref, str) and ref.startswith("marker:") else ""
target = by_id.get(target_id)
if not target_id or target is None or target.get("removed") is True \
or target.get("is_light") is not True:
marker.pop("value_source", None)
dropped += 1
return dropped
@@ -1189,20 +1110,6 @@ def _repair_target_space_refs(
preserve_related(
marker_id, "marker.value_badge", old_target, resolve_marker,
)
source = marker.get("value_source")
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
ref = source.get("ref")
old_target = ref[len("marker:"):] \
if isinstance(ref, str) and ref.startswith("marker:") else None
mapped = replace(
marker_id, "marker.value_source", old_target, resolve_marker_link,
)
if mapped is not None:
source["ref"] = "marker:" + mapped
else:
preserve_related(
marker_id, "marker.value_source", old_target, resolve_marker,
)
# Destination wins collisions. Rekey only proven dead plan-owned keys;
# opaque HA owners remain literal.
@@ -1438,17 +1345,13 @@ def build_space_merge(
source = badge.get("source") if isinstance(badge, dict) else None
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
dropped_marker_links += 1
value_source = marker.get("value_source")
if isinstance(value_source, dict) \
and value_source.get("kind") == "derived_marker_state":
dropped_marker_links += 1
marker["binding"] = "virtual"
marker["display"] = "static_icon"
for field in (
"area", "controls", "tap_action", "tap_target", "tap_confirm",
"vacuum", "is_light", "use_climate_temp", "glow_color",
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
"value_badge", "value_source",
"value_badge",
):
marker.pop(field, None)
output_markers.append(marker)
@@ -1492,21 +1395,6 @@ def build_space_merge(
badge["enabled"] = False
badge["source"] = None
dropped_marker_links += 1
source = marker.get("value_source")
if isinstance(source, dict) and source.get("kind") == "derived_marker_state":
ref = source.get("ref")
old_target = ref[len("marker:"):] \
if isinstance(ref, str) and ref.startswith("marker:") else ""
target = None if old_target in virtualized_targets else marker_map.get(old_target)
if target:
source["ref"] = "marker:" + target
_report_remap(
reference_report, "incoming", "marker.value_source",
str(marker.get("id", "?")), old_target,
)
else:
marker.pop("value_source", None)
dropped_marker_links += 1
output_markers_by_id = {
str(marker.get("id")): marker for marker in output_markers
@@ -1650,37 +1538,7 @@ 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)]
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
row["exists_at_export"] = declared.get("exists_at_export")
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")
@@ -1809,7 +1667,6 @@ def _materialize_import_candidate(
settings = config.get("settings") or {}
settings.pop("known_devices", None)
settings.pop("new_device_ids", None)
settings.pop("marker_area_snapshot", None)
details = {
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
}
+24 -26
View File
@@ -19,14 +19,12 @@ ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
from __future__ import annotations
import logging
import math
from typing import Any
import logging
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model,
)
_LOGGER = logging.getLogger(__name__)
@@ -84,11 +82,11 @@ def _quantize_key_coord(value: float) -> float:
return 0.0 if rounded == 0 else rounded
def _key(point: list[float]) -> str:
def _key(point) -> str:
return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}"
def _length(a: list[float], b: list[float]) -> float:
def _length(a, b) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
@@ -96,9 +94,9 @@ def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> f
return (cm / (cell_cm or 1)) * grid_pitch
def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
def limit_segments(space: dict) -> list[dict]:
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
segments: list[dict[str, Any]] = []
segments: list[dict] = []
for segment in space.get("wall_segments") or []:
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
segments.append({
@@ -130,7 +128,7 @@ def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]:
if _length(segment["a"], segment["b"]) > _EPS]
def check_nodes(segments: list[dict[str, Any]]) -> list[tuple[str, str, float, float]]:
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
"""П1 + П2: valence of a node and the narrowest wedge in it."""
rays: dict[str, list[float]] = {}
for segment in segments:
@@ -165,20 +163,20 @@ def check_nodes(segments: list[dict[str, Any]]) -> list[tuple[str, str, float, f
return violations
def _axis_degrees(segment: dict[str, Any]) -> float:
def _axis_degrees(segment: dict) -> float:
degrees = math.degrees(math.atan2(
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
))
return (degrees % 180 + 180) % 180
def _collinear(left: dict[str, Any], right: dict[str, Any], tolerance_deg: float = 1.0) -> bool:
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
delta = abs(_axis_degrees(left) - _axis_degrees(right))
return min(delta, 180 - delta) <= tolerance_deg
def _build_node_index(segments: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
by_node: dict[str, list[dict[str, Any]]] = {}
def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]:
by_node: dict[str, list[dict]] = {}
for item in segments:
for point in (item["a"], item["b"]):
by_node.setdefault(_key(point), []).append(item)
@@ -186,8 +184,8 @@ def _build_node_index(segments: list[dict[str, Any]]) -> dict[str, list[dict[str
def collinear_run_length_units(
segment: dict[str, Any], segments: list[dict[str, Any]],
by_node_index: dict[str, list[dict[str, Any]]] | None = None,
segment: dict, segments: list[dict],
by_node_index: dict[str, list[dict]] | None = None,
) -> float:
"""Length of the WALL a segment belongs to, not of the atom.
@@ -220,7 +218,7 @@ def collinear_run_length_units(
return total
def check_segment_lengths(
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
violations: list[tuple[str, str, float, float]] = []
@@ -237,7 +235,7 @@ def check_segment_lengths(
return violations
def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float:
def _distance_to_segment(point, a, b) -> float:
dx, dy = b[0] - a[0], b[1] - a[1]
length_sq = dx * dx + dy * dy
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
@@ -247,7 +245,7 @@ def _distance_to_segment(point: list[float], a: list[float], b: list[float]) ->
def check_node_distances(
segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N,
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
) -> list[tuple[str, str, float, float]]:
"""П4: non-incident nodes and node-to-foreign-wall clearance.
@@ -258,19 +256,19 @@ def check_node_distances(
suite. Pair order inside the subject follows the lexicographic key order,
which replaces the i<j of the all-pairs loop.
"""
nodes: dict[str, list[Any]] = {}
nodes: dict[str, list] = {}
for segment in segments:
nodes[_key(segment["a"])] = segment["a"]
nodes[_key(segment["b"])] = segment["b"]
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
size = min_units if min_units > _EPS else 1.0
node_grid: dict[tuple[int, int], list[Any]] = {}
node_grid: dict[tuple[int, int], list] = {}
for node_key, point in nodes.items():
node_grid.setdefault(
(int(point[0] // size), int(point[1] // size)), []
).append((node_key, point))
segment_grid: dict[tuple[int, int], list[Any]] = {}
segment_grid: dict[tuple[int, int], list] = {}
for segment in segments:
x0 = min(segment["a"][0], segment["b"][0]) - min_units
x1 = max(segment["a"][0], segment["b"][0]) + min_units
@@ -316,7 +314,7 @@ def check_node_distances(
))
return violations
def space_violations(space: dict[str, Any]) -> list[tuple[str, str, float, float]]:
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
"""П1–П4 over one space, in the frontend's order."""
segments = limit_segments(space)
cell_cm = float(space.get("cell_cm") or 1)
@@ -327,7 +325,7 @@ def space_violations(space: dict[str, Any]) -> list[tuple[str, str, float, float
]
def _migrated_spaces(config: dict[str, Any] | None, *, side: str = "previous") -> dict[str, dict[str, Any]]:
def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]:
"""Spaces of one document AFTER the wall-segment migration, by id.
The limits read `wall_segments`, so a document that predates the catalogue
@@ -377,7 +375,7 @@ def _migrated_spaces(config: dict[str, Any] | None, *, side: str = "previous") -
}
def space_violation_counts(spaces: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]:
def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]:
"""Violation counts per space per rule — the shape the barrier consumes
and the rev cache (#330 §4.2) stores. Documents are NOT retained."""
result: dict[str, dict[str, int]] = {}
@@ -390,7 +388,7 @@ def space_violation_counts(spaces: dict[str, dict[str, Any]]) -> dict[str, dict[
def validate_junction_limits(
config: dict[str, Any], previous: dict[str, Any] | None = None, *,
config: dict, previous: dict | None = None, *,
baseline_counts: dict[str, dict[str, int]] | None = None,
) -> dict[str, dict[str, int]]:
"""Refuse a write that ADDS a junction violation; inherit the rest.
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.71.0-beta.2"
"version": "1.69.0-beta.3"
}
+1
View File
@@ -297,6 +297,7 @@ def collect_plans(
a file-system problem must not turn a durable commit into a failed call.
"""
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
# A commit knows what it superseded. The timer only knows what nothing
# points at *right now*, and for a plan that is a reversible state: the
# editor detaches the image when a space switches to "draw" and says the
+4 -18
View File
@@ -85,11 +85,7 @@ rules:
comment: No authentication against an external service.
test-coverage:
status: todo
comment: >-
#42 stage 1 shipped the machinery: pytest-cov in CI with a committed
baseline gate, coverage.xml artifact and a hard fail on a silently
skipped HA harness. The 90%/95% thresholds are raised by follow-up
baseline bumps; the rule flips to done at >=95%.
comment: Backend covered by pure tests + HA-harness tests in CI; measuring >95% is planned.
# ---- Gold (selected; entity/device rules are exempt — no entities) ----
diagnostics:
status: done
@@ -101,23 +97,13 @@ rules:
status: done
comment: Missing plan files raise repair issues (translation_key broken_plan).
docs-troubleshooting:
status: done
comment: >-
USER-GUIDE.md #22 Troubleshooting and USER-GUIDE.ru.md #22 «Диагностика»
cover load failures, missing devices, geometry, vacuum and reporting.
status: todo
docs-examples:
status: done
comment: >-
USER-GUIDE carries executable YAML examples for both cards (view,
space card with fit/light_pools, filters) and the setup walkthrough.
status: todo
# ---- Platinum ----
strict-typing:
status: todo
comment: >-
#42 stage 1: mypy strict gates six pure modules (const, projection,
coordinate_canonicalization, frontend_asset_manifest, junction_limits,
plans) via a grow-only allowlist; the HA-boundary modules are the next
stage.
comment: Python is annotated; mypy strict pass is planned.
async-dependency:
status: exempt
comment: No dependencies.
+1 -4
View File
@@ -25,6 +25,7 @@ from .coordinate_canonicalization import (
canonicalize_layout_geometry,
)
_LOGGER = logging.getLogger(__name__)
_BG_MODES = frozenset({"static", "daynight"})
@@ -100,10 +101,6 @@ class HouseplanData:
# Parsed import candidates are short-lived, user-bound and memory-only.
# dict keeps insertion order, which lets the preview service evict oldest.
import_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
# #43: already-sanitized support-package bytes only. Raw config/layout is
# never retained here; the write lock below is used to take one coherent
# deep copy and the privacy projection immediately replaces it.
support_previews: dict[str, dict[str, Any]] = field(default_factory=dict)
# #330 §4.2: junction-limit violation counts of the STORED document,
# keyed by its rev — (rev, {space_id: {rule: count}}). One slot,
# memory-only, invalidated by a rev mismatch; the previous document is
@@ -1,520 +0,0 @@
"""Strict, allowlisted House Plan support-package projection (#43).
This module deliberately has no Home Assistant imports. Besides making the
privacy boundary easy to test, that prevents an innocent serializer helper from
gaining access to registries, states, URLs or paths that the package must never
contain. The caller supplies one coherent config/layout copy and bounded,
already-classified runtime facts; this code constructs a new object field by
field. It never serializes the source and then tries to redact it.
"""
from __future__ import annotations
import hashlib
import json
import re
import secrets
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
from .const import (
EXPORT_VERSION,
MAX_SUPPORT_ATTACHMENT_BYTES,
PLAN_MODEL_VERSION,
)
PACKAGE_FORMAT = "houseplan-support-package"
PACKAGE_VERSION = 1
_SAFE_VERSION = re.compile(r"\A[0-9A-Za-z._+-]{1,32}\Z")
_SAFE_ICON = re.compile(r"\Amdi:[a-z0-9-]{1,80}\Z")
_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):
"""A stable public failure without source data in its text."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def _safe_version(value: object) -> str:
text = str(value or "unknown")
return text if _SAFE_VERSION.fullmatch(text) else "unknown"
def validate_frontend_facts(value: object) -> dict[str, Any]:
"""Accept only the small runtime enum/boolean contract from the browser."""
if not isinstance(value, dict):
raise SupportPackageError("support_rejected")
browser = value.get("browser_family")
language = value.get("language")
registry = value.get("registry_access")
age = value.get("registry_age_bucket")
major = value.get("browser_major")
if browser not in _BROWSERS or language not in _LANGUAGES:
raise SupportPackageError("support_rejected")
if registry not in _REGISTRY_ACCESS or age not in _REGISTRY_AGE:
raise SupportPackageError("support_rejected")
if isinstance(major, bool) or not isinstance(major, int) or not 0 <= major <= 999:
raise SupportPackageError("support_rejected")
for key in ("coarse_pointer", "hover_capable"):
if not isinstance(value.get(key), bool):
raise SupportPackageError("support_rejected")
return {
"browser_family": browser,
"browser_major": major,
"language": language,
"coarse_pointer": value["coarse_pointer"],
"hover_capable": value["hover_capable"],
"registry_access": registry,
"registry_age_bucket": age,
}
@dataclass
class _Pseudonyms:
namespace: str
maps: dict[str, dict[str, str]] = field(default_factory=dict)
def get(self, kind: str, raw: object) -> str | None:
if raw is None:
return None
value = str(raw)
if not value:
return None
items = self.maps.setdefault(kind, {})
if value not in items:
items[value] = f"{kind}-{self.namespace}-{len(items) + 1}"
return items[value]
def _copy_keys(source: object, keys: tuple[str, ...]) -> dict[str, Any]:
if not isinstance(source, dict):
return {}
return {key: source[key] for key in keys if key in source}
def _point(value: object) -> list[float] | None:
if not isinstance(value, (list, tuple)) or len(value) != 2:
return None
if any(isinstance(item, bool) or not isinstance(item, (int, float)) for item in value):
return None
return [value[0], value[1]]
def _points(value: object) -> list[list[float]]:
if not isinstance(value, list):
return []
return [point for item in value if (point := _point(item)) is not None]
def _entity_ref(ids: _Pseudonyms, value: object) -> str | None:
return ids.get("entity", value)
def _binding(ids: _Pseudonyms, value: object) -> tuple[str, str]:
text = str(value or "")
if text == "virtual":
return "virtual", "virtual"
kind, separator, raw = text.partition(":")
if separator and kind in {"device", "entity"} and raw:
# Reuse the same per-kind namespace as controls/value sources so one
# raw entity remains one pseudonym everywhere in this package.
pseudo = ids.get(kind, raw)
return kind, f"{kind}:{pseudo}"
return "unknown", "unknown"
def _custom_fill(value: object) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
return _copy_keys(value, ("c", "a"))
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"] = {
str(key): _copy_keys(item, ("c", "a"))
for key, item in fill_colors.items()
if isinstance(key, str) and isinstance(item, dict)
}
style = value.get("decor_default_style")
if isinstance(style, dict):
out["decor_default_style"] = _copy_keys(
style, ("color", "opacity", "width_cm", "fill", "fill_color", "fill_opacity"),
)
return out
def _space_settings(value: object) -> dict[str, Any]:
out = _copy_keys(value, (
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
"fill_mode", "glow_enabled", "temp_min", "temp_max", "show_lqi",
"hide_decor", "hide_openings", "label_temp", "label_hum", "label_lqi",
"label_light", "card_font_scale", "north_deg", "bg_mode", "sun_rays",
))
if isinstance(value, dict) and "custom_fill" in value:
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
return out
def _room_settings(ids: _Pseudonyms, value: object) -> dict[str, Any]:
out = _copy_keys(value, ("fill_mode", "glow", "name_scale", "label_scale"))
if not isinstance(value, dict):
return out
if "custom_fill" in value:
out["custom_fill"] = _custom_fill(value.get("custom_fill"))
for key in ("temp_source", "hum_source"):
source = value.get(key)
if source:
kind, pseudo = _binding(ids, source)
out[key] = pseudo
out[f"{key}_kind"] = kind
return out
def _project_room(ids: _Pseudonyms, room: dict[str, Any], index: int) -> dict[str, Any]:
out: dict[str, Any] = {
"id": ids.get("room", room.get("id")),
"name": f"Room {index}",
}
out.update(_copy_keys(room, ("x", "y", "w", "h")))
if "poly" in room:
out["poly"] = _points(room.get("poly"))
if isinstance(room.get("wall_ids"), list):
out["wall_ids"] = [ids.get("wall", item) for item in room["wall_ids"]]
if isinstance(room.get("open_to"), list):
out["open_to"] = [ids.get("room", item) for item in room["open_to"]]
settings = _room_settings(ids, room.get("settings"))
if settings:
out["settings"] = settings
return out
def _project_decor(ids: _Pseudonyms, item: dict[str, Any]) -> dict[str, Any]:
kind = str(item.get("kind") or "unknown")
out: dict[str, Any] = {"id": ids.get("decor", item.get("id")), "kind": kind}
out.update(_copy_keys(item, (
"color", "opacity", "width_cm", "width", "x1", "y1", "x2", "y2",
"line_style", "x", "y", "w", "h", "angle", "fill", "fill_color",
"fill_opacity", "size", "size_cm", "scale", "symbol", "flip_h", "flip_v",
)))
if kind == "text":
out["text"] = "[redacted text]"
if item.get("entity"):
out["entity"] = _entity_ref(ids, item.get("entity"))
return out
def _project_value_source(ids: _Pseudonyms, value: object) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
kind = value.get("kind")
if kind in {"entity_state", "entity_attribute"}:
out: dict[str, Any] = {"kind": kind, "entity_id": _entity_ref(ids, value.get("entity_id"))}
# Attribute names can be arbitrary user strings and are not needed to
# reproduce geometry or marker placement, so they are deliberately absent.
return out
if kind == "derived_lqi":
return {"kind": kind}
if kind == "derived_marker_state":
raw = str(value.get("ref") or "")
marker = raw[7:] if raw.startswith("marker:") else raw
return {"kind": kind, "ref": f"marker:{ids.get('marker', marker)}"}
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] = {
"id": ids.get("marker", marker.get("id")),
"binding": binding,
"binding_kind": binding_kind,
}
out.update(_copy_keys(marker, (
"hidden", "removed", "tap_action", "tap_confirm", "display", "ripple_color",
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
"use_climate_temp",
)))
icon = marker.get("icon")
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
out["icon"] = icon
if marker.get("space"):
out["space"] = ids.get("space", marker.get("space"))
if marker.get("room_id"):
out["room_id"] = ids.get("room", marker.get("room_id"))
for key in ("light_entity", "toggle_entity", "tap_target"):
if marker.get(key):
out[key] = _entity_ref(ids, marker.get(key))
controls = marker.get("controls")
if isinstance(controls, list):
out["controls"] = [_entity_ref(ids, value) for value in controls]
vacuum = marker.get("vacuum")
if isinstance(vacuum, dict):
out["vacuum"] = _copy_keys(vacuum, ("live", "trail", "trail_mode", "room_highlight"))
for key in ("value_source",):
projected = _project_value_source(ids, marker.get(key))
if projected:
out[key] = projected
safe_badge = _project_value_badge(ids, marker.get("value_badge"))
if safe_badge is not None:
out["value_badge"] = safe_badge
return out
def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[str, Any]:
out: dict[str, Any] = {
"id": ids.get("space", space.get("id")),
"title": f"Space {index}",
"has_plan": bool(space.get("plan_url")),
}
out.update(_copy_keys(space, (
"cell_cm", "plan_aspect", "plan_x", "plan_y", "plan_scale", "plan_scale_x",
"plan_scale_y", "plan_angle", "view_box", "zero_wall_style",
)))
settings = _space_settings(space.get("settings"))
if settings:
out["settings"] = settings
out["rooms"] = [
_project_room(ids, room, room_index)
for room_index, room in enumerate(space.get("rooms") or [], 1)
if isinstance(room, dict)
]
out["wall_segments"] = [
{
"id": ids.get("wall", item.get("id")),
"a": _point(item.get("a")), "b": _point(item.get("b")), "cm": item.get("cm"),
}
for item in space.get("wall_segments") or [] if isinstance(item, dict)
]
out["walls"] = [
{
"key": ids.get("wall", item.get("key")), "cm": item.get("cm"),
**({"a": _point(item.get("a")), "b": _point(item.get("b"))}
if "a" in item and "b" in item else {}),
}
for item in space.get("walls") or [] if isinstance(item, dict)
]
out["room_drafts"] = []
for draft in space.get("room_drafts") or []:
if not isinstance(draft, dict):
continue
out["room_drafts"].append({
"id": ids.get("draft", draft.get("id")),
"points": _points(draft.get("points")),
"segments": [
{
**({"id": ids.get("wall", segment.get("id"))} if segment.get("id") else {}),
"cm": segment.get("cm"),
}
for segment in draft.get("segments") or [] if isinstance(segment, dict)
],
})
out["partitions"] = [
{"id": ids.get("partition", item.get("id")), "a": _point(item.get("a")),
"b": _point(item.get("b")), "cm": item.get("cm")}
for item in space.get("partitions") or [] if isinstance(item, dict)
]
out["wall_columns"] = [
{"id": ids.get("column", item.get("id")), **_copy_keys(item, ("shape", "center", "cm", "angle"))}
for item in space.get("wall_columns") or [] if isinstance(item, dict)
]
out["openings"] = []
for opening in space.get("openings") or []:
if not isinstance(opening, dict):
continue
projected = {"id": ids.get("opening", opening.get("id"))}
projected.update(_copy_keys(opening, (
"type", "x", "y", "angle", "length", "invert", "flip_h", "flip_v",
)))
for key in ("contact", "lock"):
if opening.get(key):
projected[key] = _entity_ref(ids, opening.get(key))
host = opening.get("host")
if isinstance(host, dict) and host.get("kind") in {"wall", "partition"}:
kind = host["kind"]
projected["host"] = {
"kind": kind,
"id": ids.get(kind, host.get("id")),
"t": host.get("t"),
}
out["openings"].append(projected)
out["decor"] = [
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
]
out["open_spans"] = [
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
for item in space.get("open_spans") or [] if isinstance(item, dict)
]
return out
def _project_layout(ids: _Pseudonyms, layout: object) -> dict[str, Any]:
if not isinstance(layout, dict):
return {}
marker_ids = ids.maps.get("marker", {})
room_ids = ids.maps.get("room", {})
out: dict[str, Any] = {}
for raw_key, value in layout.items():
if not isinstance(raw_key, str) or not isinstance(value, dict):
continue
if raw_key in marker_ids:
key = marker_ids[raw_key]
elif raw_key.startswith("rl_") and raw_key[3:] in room_ids:
key = "rl_" + room_ids[raw_key[3:]]
else:
# Unknown keys may be stale device ids. Omitting them is the only
# fail-closed choice; copying them would disclose the raw id.
continue
position = _copy_keys(value, ("x", "y", "k"))
if value.get("s"):
position["s"] = ids.get("space", value.get("s"))
out[key] = position
return out
def _summary(config: object, layout: object) -> dict[str, Any]:
spaces = config.get("spaces", []) if isinstance(config, dict) else []
markers = config.get("markers", []) if isinstance(config, dict) else []
kinds: Counter[str] = Counter()
bindings: Counter[str] = Counter()
lifecycles: Counter[str] = Counter()
decor_kinds: Counter[str] = Counter()
for marker in markers:
if not isinstance(marker, dict):
continue
bindings[_binding_kind(marker.get("binding"))] += 1
lifecycles[
"removed" if marker.get("removed") else "hidden" if marker.get("hidden") else "active"
] += 1
for space in spaces:
if not isinstance(space, dict):
continue
for opening in space.get("openings") or []:
if isinstance(opening, dict):
kinds[str(opening.get("type") or "unknown")] += 1
for item in space.get("decor") or []:
if isinstance(item, dict):
decor_kinds[str(item.get("kind") or "unknown")] += 1
return {
"spaces": len(spaces),
"rooms": sum(len(space.get("rooms") or []) for space in spaces if isinstance(space, dict)),
"room_drafts": sum(len(space.get("room_drafts") or []) for space in spaces if isinstance(space, dict)),
"walls": sum(len(space.get("wall_segments") or space.get("walls") or []) for space in spaces if isinstance(space, dict)),
"partitions": sum(len(space.get("partitions") or []) for space in spaces if isinstance(space, dict)),
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
"openings": dict(sorted(kinds.items())),
"decor": dict(sorted(decor_kinds.items())),
"markers": {
"total": len(markers),
"lifecycle": dict(sorted(lifecycles.items())),
"binding": dict(sorted(bindings.items())),
},
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
}
def _binding_kind(value: object) -> str:
text = str(value or "")
if text == "virtual":
return "virtual"
if text.startswith("device:"):
return "device"
if text.startswith("entity:"):
return "entity"
return "unknown"
def build_support_package(
config: dict[str, Any],
layout: dict[str, Any],
*,
config_rev: int,
layout_rev: int,
card_version: str,
integration_version: str,
home_assistant_version: str,
runtime: dict[str, Any],
repairs: list[dict[str, Any]] | None = None,
namespace: str | None = None,
) -> tuple[bytes, dict[str, Any]]:
"""Build canonical bytes and a bounded summary for the preview response."""
facts = validate_frontend_facts(runtime)
ids = _Pseudonyms(namespace or secrets.token_hex(4))
spaces = [item for item in config.get("spaces", []) if isinstance(item, dict)]
markers = [item for item in config.get("markers", []) if isinstance(item, dict)]
projected_spaces = [
_project_space(ids, space, index) for index, space in enumerate(spaces, 1)
]
projected_markers = [_project_marker(ids, marker) for marker in markers]
summary = _summary(config, layout)
package = {
"format": PACKAGE_FORMAT,
"version": PACKAGE_VERSION,
"versions": {
"card": _safe_version(card_version),
"integration": _safe_version(integration_version),
"home_assistant": _safe_version(home_assistant_version),
"model": PLAN_MODEL_VERSION,
"export_schema": EXPORT_VERSION,
},
"runtime": facts,
"revisions": {"config": int(config_rev), "layout": int(layout_rev)},
"summary": summary,
"validation": {
"config": "valid", "layout": "valid", "unknown_fields": "dropped",
},
"repairs": repairs or [],
"plan_backup": {
"config": {
"model_version": int(config.get("model_version", PLAN_MODEL_VERSION)),
"settings": _global_settings(config.get("settings")),
"spaces": projected_spaces,
"markers": projected_markers,
},
"layout": _project_layout(ids, layout),
},
}
raw = (json.dumps(
package, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
) + "\n").encode("utf-8")
if len(raw) > MAX_SUPPORT_ATTACHMENT_BYTES:
raise SupportPackageError("support_package_too_large")
return raw, {
"size": len(raw),
"sha256": hashlib.sha256(raw).hexdigest(),
"spaces": summary["spaces"],
"format": PACKAGE_FORMAT,
"version": PACKAGE_VERSION,
"versions": package["versions"],
}
@@ -1,101 +0,0 @@
"""Bounded outbound submit to the compile-time House Plan support relay (#43)."""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlsplit
import aiohttp
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import SUPPORT_RELAY_URL
_REPORT_ID_PREFIX = "hpr-"
class SupportTransportError(RuntimeError):
"""Stable frontend-facing error; never contains the remote response."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
async def async_submit_report(
hass,
*,
message: str,
contact: str,
versions: dict[str, Any],
idempotency_key: str,
attachment: bytes | None,
attachment_sha256: str | None,
) -> str:
"""Send once, without redirects, proxies, arbitrary hosts or reflected text."""
target = urlsplit(SUPPORT_RELAY_URL)
if target.scheme != "https" or target.hostname != "support.houseplan.tech":
raise SupportTransportError("support_unavailable")
request: dict[str, Any] = {
"schema_version": 1,
"message": message,
"idempotency_key": idempotency_key,
"versions": {key: str(value) for key, value in versions.items()},
}
if contact:
request["contact"] = contact
if attachment is not None:
request["attachment"] = {
"size": len(attachment),
"sha256": str(attachment_sha256 or ""),
}
form = aiohttp.FormData()
form.add_field(
"request", json.dumps(request, ensure_ascii=False, separators=(",", ":")),
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_sha256[:12]}.json",
content_type="application/json",
)
session = async_get_clientsession(hass)
timeout = aiohttp.ClientTimeout(total=20, sock_connect=5)
try:
async with session.post(
SUPPORT_RELAY_URL, data=form, timeout=timeout, allow_redirects=False,
) as response:
if response.status == 429:
raise SupportTransportError("support_rate_limited")
if response.status == 413:
raise SupportTransportError("support_package_too_large")
if response.status in {400, 401, 403, 404, 405, 409, 415, 422}:
raise SupportTransportError("support_rejected")
if response.status != 200:
raise SupportTransportError("support_unavailable")
# A compromised/misconfigured relay cannot make HA buffer an
# unbounded response or reflect its details to the browser.
body = await response.content.read(4097)
if len(body) > 4096:
raise SupportTransportError("support_unavailable")
except SupportTransportError:
raise
except (aiohttp.ClientError, TimeoutError, OSError):
raise SupportTransportError("support_unavailable") from None
try:
payload = json.loads(body.decode("utf-8"))
report_id = payload.get("report_id") if isinstance(payload, dict) else None
except (UnicodeDecodeError, json.JSONDecodeError):
report_id = None
if not isinstance(report_id, str) or not report_id.startswith(_REPORT_ID_PREFIX):
raise SupportTransportError("support_unavailable")
if not 8 <= len(report_id) <= 64 or any(
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
for char in report_id
):
raise SupportTransportError("support_unavailable")
return report_id
+1 -1
View File
@@ -10,7 +10,6 @@ want to see where the cleanup has already been).
from __future__ import annotations
import asyncio
import logging
import math
import time
from typing import Any
@@ -22,6 +21,7 @@ from homeassistant.helpers.storage import Store
from .const import DOMAIN
import logging
_LOGGER = logging.getLogger(__name__)
TRAIL_CAP = 2000 # raw points per run before decimation
@@ -1,30 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": {
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
}
}
},
"abort": {
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
}
},
"options": {
"step": {
"init": {
"data": {
"admin_only": "Seuls les administrateurs peuvent modifier le plan"
}
}
}
},
"issues": {
"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."
}
}
}
+44 -103
View File
@@ -4,11 +4,10 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
"""
from __future__ import annotations
from collections import Counter
import copy
import json
import math
import re
from collections import Counter
import voluptuous as vol
@@ -50,12 +49,9 @@ class OpeningPassageError(ValueError):
self.space_id = space_id
self.opening_id = opening_id
self.fields = tuple(sorted(fields))
# #42: structured details as JSON — the frontend parses this instead
# of regexing an English sentence; the legacy "space=..;.." format is
# still accepted there for one beta.
super().__init__(json.dumps({
"space": space_id, "opening": opening_id, "fields": list(self.fields),
}, ensure_ascii=False))
super().__init__(
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
)
class PartitionOpeningHostError(ValueError):
@@ -76,11 +72,10 @@ class PartitionOpeningJambMarginError(ValueError):
self.opening_id = opening_id
self.margin = margin
self.margin_cm = margin_cm
# #42: structured details as JSON (see OpeningPassageError).
super().__init__(json.dumps({
"space": space_id, "opening": opening_id,
"margin": round(margin, 12), "margin_cm": round(margin_cm, 12),
}, ensure_ascii=False))
super().__init__(
f"space={space_id}; opening={opening_id}; "
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
)
class WallModelClientOutdatedError(ValueError):
@@ -415,7 +410,7 @@ def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:], strict=False): # #42 B905: adjacent pairs, unequal length is the point
for lo, hi in zip(points, points[1:]):
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
@@ -810,7 +805,7 @@ def _matching_previous_marker(
def validate_marker_value_badges(
config: dict, previous: dict | None = None, *, validate_all: bool = False
) -> None:
"""Validate changed badge/face sources; dormant future data round-trips."""
"""Validate only new/changed badge data; dormant future/legacy data round-trips."""
markers = config.get("markers") or []
by_id = {str(marker.get("id")): marker for marker in markers}
old_markers = (previous or {}).get("markers") or []
@@ -819,14 +814,30 @@ def validate_marker_value_badges(
consumed_old_ids: set[str] = set()
known_source_fields = {"kind", "entity_id", "attribute", "ref"}
def validate_source(source: object, field: str) -> None:
prefix = "value_badge" if field == "value_badge" else "value_source"
source_error = "invalid_value_badge_source" if field == "value_badge" \
else "invalid_value_source"
attribute_error = "invalid_value_badge_attribute" if field == "value_badge" \
else "invalid_value_source_attribute"
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if not validate_all and badge == old_badge:
continue
if badge is None:
continue
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError("value_badge_source_required", "Enabled value badge needs a source")
if source is None:
continue
if not isinstance(source, dict) or source.get("kind") not in VALUE_BADGE_SOURCE_KINDS:
raise MarkerControlError(source_error, f"Invalid {field} source")
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge source")
kind = source["kind"]
allowed_fields = {
"entity_state": {"kind", "entity_id"},
@@ -835,51 +846,23 @@ def validate_marker_value_badges(
"derived_marker_state": {"kind", "ref"},
}[kind]
if (known_source_fields & set(source)) - allowed_fields:
raise MarkerControlError(source_error, f"Inconsistent {field} source")
raise MarkerControlError("invalid_value_badge_source", "Inconsistent value badge source")
if kind in {"entity_state", "entity_attribute"}:
entity_id = source.get("entity_id")
if not isinstance(entity_id, str) or not _CONTROL_ENTITY_ID_RE.fullmatch(entity_id):
raise MarkerControlError(source_error, f"Invalid {field} entity id")
if kind == "entity_attribute" and source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError(attribute_error, f"Invalid {field} attribute")
raise MarkerControlError("invalid_value_badge_source", "Invalid value badge entity id")
if kind == "entity_attribute":
if source.get("attribute") not in VALUE_BADGE_ATTRIBUTES:
raise MarkerControlError("invalid_value_badge_attribute", "Invalid value badge attribute")
if kind == "derived_marker_state":
ref = source.get("ref")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) \
or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError(source_error, f"Invalid {field} target")
if not isinstance(ref, str) or not ref.startswith(MARKER_CONTROL_PREFIX) or not ref[len(MARKER_CONTROL_PREFIX):]:
raise MarkerControlError("invalid_value_badge_source", "Invalid marker value badge target")
target = by_id.get(ref[len(MARKER_CONTROL_PREFIX):])
if target is None or target.get("removed") is True:
raise MarkerControlError(f"{prefix}_marker_missing", f"{field} target does not exist")
raise MarkerControlError("value_badge_marker_missing", "Marker value badge target does not exist")
if target.get("is_light") is not True:
raise MarkerControlError(f"{prefix}_marker_not_light", f"{field} target is not a forced light")
for marker_id, marker in by_id.items():
old_marker = _matching_previous_marker(
marker, marker_id, old_by_id, old_markers, new_ids,
consumed_old_ids, validate_all,
)
badge = marker.get("value_badge")
old_badge = None if validate_all else (old_marker or {}).get("value_badge")
if validate_all or badge != old_badge:
if badge is not None:
if not isinstance(badge, dict):
raise MarkerControlError("invalid_value_badge", "Value badge must be an object")
if not isinstance(badge.get("enabled"), bool):
raise MarkerControlError("invalid_value_badge", "Value badge enabled must be boolean")
if badge.get("position") not in VALUE_BADGE_POSITIONS:
raise MarkerControlError("invalid_value_badge_position", "Invalid value badge position")
source = badge.get("source")
if badge["enabled"] and not isinstance(source, dict):
raise MarkerControlError(
"value_badge_source_required", "Enabled value badge needs a source"
)
if source is not None:
validate_source(source, "value_badge")
source = marker.get("value_source")
old_source = None if validate_all else (old_marker or {}).get("value_source")
if (validate_all or source != old_source) and source is not None:
validate_source(source, "value_source")
raise MarkerControlError("value_badge_marker_not_light", "Marker value badge target is not a forced light")
def validate_marker_light_entities(
@@ -1090,7 +1073,6 @@ MAX_OPEN_TO = 50
MAX_CONTROLS = 200
MAX_PDFS = 50
MAX_KNOWN_DEVICES = 20000
MAX_MARKER_AREA_SNAPSHOT = MAX_KNOWN_DEVICES
MAX_TEXT = 500 # names, models, ids
MAX_DESCRIPTION = 4000
MAX_URL = 2000
@@ -1104,7 +1086,6 @@ CELL_CM_MIN = 0.1
CELL_CM_MAX = 1000.0
_TEXT = vol.All(str, vol.Length(max=MAX_TEXT))
_NONEMPTY_TEXT = vol.All(str, vol.Length(min=1, max=MAX_TEXT))
_TEXT_OR_NONE = vol.Any(None, _TEXT)
_URL = vol.All(str, vol.Length(max=MAX_URL))
@@ -1319,7 +1300,6 @@ _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,
@@ -1382,14 +1362,6 @@ DECOR_SCHEMA = vol.Any(
vol.Required("symbol"): _FURN_SYMBOL,
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("w"): _FURN_SIZE, vol.Required("h"): _FURN_SIZE,
vol.Optional("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),
)
@@ -1443,7 +1415,7 @@ def _room_draft_segments(value: dict) -> dict:
"""An open draft has exactly one thickness per consecutive edge."""
if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1):
raise vol.Invalid("room draft segments must match consecutive point pairs")
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:], strict=False)): # #42 B905: adjacent pairs
if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])):
raise vol.Invalid("room draft consecutive points must differ")
return value
@@ -1706,7 +1678,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("model"): _TEXT_OR_NONE,
vol.Optional("link"): vol.Any(None, _URL),
vol.Optional("description"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DESCRIPTION))),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "none", "cover", None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", "run", "cover", None),
# the 'run' target: only the runnable domains, nothing else is callable
vol.Optional("tap_target"): vol.Any(
None, vol.All(str, vol.Length(max=MAX_TEXT), vol.Match(r"^(automation|script|scene)\.[A-Za-z0-9_]+$"))
@@ -1771,12 +1743,6 @@ MARKER_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
),
),
# Explicit source for display:value. Semantic delta validation shares
# the badge source contract while keeping untouched future literals.
vol.Optional("value_source"): vol.Any(
None,
vol.Schema({}, extra=vol.ALLOW_EXTRA),
),
# climate current_temperature: badge + room-average vote (off unless True)
vol.Optional("use_climate_temp"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
@@ -1919,41 +1885,16 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): _COLOR,
# #377: the Background editor's default style for new decor
# objects; absence of the key means the built-in default
vol.Optional("decor_default_style"): vol.Schema(
{
vol.Optional("color"): _COLOR,
vol.Optional("opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("width_cm"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=100)),
vol.Optional("fill"): bool,
vol.Optional("fill_color"): _COLOR,
vol.Optional("fill_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
}
),
# sun on the plan (docs/SUN.md): global defaults
vol.Optional("north_deg"): _north_deg,
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
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.
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("marker_area_snapshot"): vol.All(
vol.Schema({
_NONEMPTY_TEXT: vol.Schema({
vol.Required("binding"): vol.All(
_NONEMPTY_TEXT,
vol.Match(r"^(?:device|entity):.+$"),
),
vol.Required("area"): _NONEMPTY_TEXT,
}),
}),
vol.Length(max=MAX_MARKER_AREA_SNAPSHOT),
),
vol.Optional("fill_colors"): vol.Schema(
{
str: vol.Schema(
@@ -15,6 +15,7 @@ from typing import Any
from .coordinate_canonicalization import canonicalize_config_geometry
WALL_SEGMENT_MODEL_VERSION = 9
GRID_STEP_N = 1 / 240
EPS = 1e-9
@@ -191,11 +192,10 @@ def _atomize(
float(span["b"][0]), float(span["b"][1]),
])
if not legacy_segments:
def linked(first: dict, second: dict) -> bool: # #42 E731
return (
str(second.get("id", "")) in (first.get("open_to") or [])
or str(first.get("id", "")) in (second.get("open_to") or [])
)
linked = lambda first, second: (
str(second.get("id", "")) in (first.get("open_to") or [])
or str(first.get("id", "")) in (second.get("open_to") or [])
)
for first_index, first in enumerate(rooms):
for second in rooms[first_index + 1:]:
if not linked(first, second):
@@ -223,6 +223,7 @@ def _atomize(
])
for segment in canonical_zero_segments:
global_breaks.extend((segment[:2], segment[2:]))
zero_segments = canonical_zero_segments + legacy_segments
# #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that
# carries an existing contour opening; its edges become atom boundaries so
@@ -347,15 +348,10 @@ def _atomize(
parent_index = parents[index]
atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)]))
midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
# #42 B023/E731: the loop variable is bound as a default — the
# closure is only called inside this iteration, but the binding
# makes that safety structural instead of incidental.
def covered_by(cuts: list[list[float]], *, midpoint: list[float] = midpoint) -> bool:
return any(
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in cuts
)
covered_by = lambda cuts: any(
_distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04
for cut in cuts
)
# Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom
# that carries an opening (#316 §3.1).
atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or (
@@ -551,11 +547,7 @@ def _host_openings(
continue
raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None
# #42 B023: loop variables are bound as defaults (see covered_by).
def eligible(
segment: dict[str, Any], *,
centre: list[float] = centre, angle: float = angle, half: float = half,
) -> bool:
def eligible(segment: dict[str, Any]) -> bool:
if float(segment.get("cm", 0)) <= 0:
return False
t = _project_t(centre, segment["a"], segment["b"])
@@ -567,10 +559,7 @@ def _host_openings(
and half >= 0 and t * span - half >= -EPS
and t * span + half <= span + EPS)
def materialize(
carrier: dict[str, Any], *,
opening: dict[str, Any] = opening, centre: list[float] = centre,
) -> None:
def materialize(carrier: dict[str, Any]) -> None:
opening["host"] = {
"kind": "wall", "id": carrier["id"],
"t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))),
@@ -592,18 +581,15 @@ def _host_openings(
if not initial_migration:
raise WallSegmentMigrationError("opening-host", str(opening.get("id", "")))
def pick(
pool: list[dict[str, Any]], *,
current: dict[str, Any] | None = current, centre: list[float] = centre,
) -> dict[str, Any] | None:
def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None:
if not pool:
return None
if current is not None and any(item is current for item in pool):
return current
return sorted(pool, key=lambda candidate: (
_distance_to_segment(centre, candidate["a"], candidate["b"]),
-float(candidate.get("cm", 0)),
str(candidate.get("id", "")),
return sorted(pool, key=lambda segment: (
_distance_to_segment(centre, segment["a"], segment["b"]),
-float(segment.get("cm", 0)),
str(segment.get("id", "")),
))[0]
# #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1
+30 -496
View File
@@ -1,64 +1,36 @@
"""House Plan WS commands: layout, space configuration, plan uploads."""
from __future__ import annotations
import logging
import base64
import binascii
import copy
import json
import logging
import re
import secrets
import time
from collections import Counter
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
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 homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import (
ASSETS_DIR,
CONTENT_URL,
DECOR_ASSETS_API_VERSION,
DEFAULT_CONFIG,
DOMAIN,
EXPORT_VERSION,
FILES_DIR,
MAX_PLANS_BYTES,
MAX_PLANS_FILES,
MAX_PLANS_LISTED,
CONF_ADMIN_ONLY, DEFAULT_CONFIG,
CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED,
MAX_SIGN_PATHS,
MAX_SUPPORT_CONTACT_CODEPOINTS,
MAX_SUPPORT_MESSAGE_CODEPOINTS,
MAX_SUPPORT_PREVIEWS_PER_USER,
MAX_SUPPORT_PREVIEWS_TOTAL,
PLAN_MODEL_VERSION,
PLANS_DIR,
PLANS_URL,
SUPPORT_API_VERSION,
SUPPORT_PREVIEW_TTL_S,
PLANS_DIR, PLANS_URL,
VERSION,
)
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 .auth import may_write
from .import_export import (
ImportFailure,
content_manifest,
@@ -68,66 +40,43 @@ from .import_export import (
prepare_apply,
revalidate_candidate,
)
from .junction_limits import JunctionLimitError, validate_junction_limits
from .plans import (
QuotaError,
check_quota,
collect_attachments,
collect_plans,
is_plan_file,
plan_basename,
plan_refs,
reserve_filename,
QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file,
plan_basename, plan_refs, reserve_filename,
)
from .projection import project_config, project_layout
from .registry_snapshot import import_registry_snapshot
from .store import (
LAYOUT_STORE_CORE_KEYS,
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
HouseplanData,
async_save_config_state,
async_save_layout_state,
get_data,
get_entry,
)
from .store import (
OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP,
)
from .store import (
OPTIMIZE_PENDING as _OPTIMIZE_PENDING,
)
from .support_package import SupportPackageError, build_support_package
from .support_transport import SupportTransportError, async_submit_report
from .validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
MAX_CONFIG_BYTES,
MAX_PLAN_BYTES,
PLAN_EXTENSIONS,
POS_SCHEMA,
MarkerControlError,
OpeningPassageError,
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
WallModelClientOutdatedError,
sanitize_filename,
valid_space_id,
validate_marker_controls,
validate_marker_light_entities,
validate_marker_value_badges,
validate_opening_passages,
validate_partition_opening_hosts,
validate_wall_model_transition,
)
from .virtual_lights import (
EVENT_VIRTUAL_LIGHT_UPDATED,
async_toggle_virtual_light,
async_virtual_light_snapshot,
)
from .registry_snapshot import import_registry_snapshot
from .projection import project_config, project_layout
from .wall_segment_model import (
WALL_SEGMENT_MODEL_VERSION,
WallSegmentMigrationError,
commit_wall_segment_model,
)
from .junction_limits import JunctionLimitError, validate_junction_limits
from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
PartitionOpeningHostError, PartitionOpeningJambMarginError,
WallModelClientOutdatedError, sanitize_filename,
validate_opening_passages, validate_partition_opening_hosts,
validate_marker_controls, validate_marker_light_entities,
validate_marker_value_badges, validate_wall_model_transition, valid_space_id,
)
_LOGGER = logging.getLogger(__name__)
@@ -208,16 +157,10 @@ 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)
websocket_api.async_register_command(hass, ws_import_apply)
websocket_api.async_register_command(hass, ws_support_preview)
websocket_api.async_register_command(hass, ws_support_preview_discard)
websocket_api.async_register_command(hass, ws_support_submit)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -246,56 +189,6 @@ def _send_import_error(connection, msg_id: int, err: ImportFailure) -> None:
connection.send_error(msg_id, err.code, err.message)
def _send_support_error(connection, msg_id: int, code: str) -> None:
"""Return only a stable code; support data never enters an error string."""
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 = _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), issue in list(registry.issues.items()):
if domain != DOMAIN:
continue
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 {
@@ -368,7 +261,7 @@ async def _commit_import_pair(
try:
await _persist_pair_intent(rt, rollback)
await _converge_pair(rt, rollback)
except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error
except Exception as rollback_error: # noqa: BLE001
_LOGGER.exception(
"House Plan import rollback could not finish; rollback intent remains for setup"
)
@@ -419,7 +312,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan export failed")
connection.send_error(msg["id"], "invalid_config", "Could not create export")
return
@@ -593,7 +486,7 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan import commit failed")
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
return
@@ -1107,120 +1000,6 @@ 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",
@@ -1390,9 +1169,6 @@ 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,
},
)
@@ -1842,7 +1618,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_config", str(err))
return
except Exception: # noqa: BLE001 - best-effort backup pruning never fails the request
except Exception: # noqa: BLE001
_LOGGER.exception("House Plan space delete failed")
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
return
@@ -2234,245 +2010,3 @@ async def ws_trail_delete(hass: HomeAssistant, connection: websocket_api.ActiveC
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
removed = await rec.async_delete(msg["marker_id"]) if rec else False
connection.send_result(msg["id"], {"ok": True, "removed": removed})
# ---------------- private support package / feedback (#43) ----------------
def _support_string(value: object) -> str:
if not isinstance(value, str):
raise vol.Invalid("support field must be a string")
return value
def _support_bool(value: object) -> bool:
if not isinstance(value, bool):
raise vol.Invalid("support field must be a boolean")
return value
def _support_browser_major(value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 999:
raise vol.Invalid("browser_major must be an integer in 0..999")
return value
_SUPPORT_TOKEN = vol.All(
_support_string, vol.Length(min=16, max=128), vol.Match(r"^[0-9a-f]+$")
)
_SUPPORT_ID = vol.All(
_support_string, vol.Length(min=8, max=128), vol.Match(r"^[A-Za-z0-9_.:-]+$")
)
_SUPPORT_VERSION = vol.All(
_support_string, vol.Length(min=1, max=32), vol.Match(r"^[0-9A-Za-z._+-]+$")
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/support/preview",
vol.Required("card_version"): _SUPPORT_VERSION,
vol.Required("browser_family"): vol.In(["chromium", "firefox", "webkit", "unknown"]),
vol.Required("browser_major"): _support_browser_major,
vol.Required("language"): vol.In(["en", "ru", "de", "fr"]),
vol.Required("coarse_pointer"): _support_bool,
vol.Required("hover_capable"): _support_bool,
vol.Required("registry_access"): vol.In(["full", "partial", "unavailable"]),
vol.Required("registry_age_bucket"): vol.In(["fresh", "stale", "unknown"]),
vol.Required("draft_id"): _SUPPORT_ID,
}
)
@websocket_api.async_response
async def ws_support_preview(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Build and retain one exact, already-sanitized support-package snapshot."""
if not _check_write(hass, connection):
_send_support_error(connection, msg["id"], "unauthorized")
return
rt = _runtime(hass, connection, msg["id"])
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.
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
config = copy.deepcopy({**DEFAULT_CONFIG, **(config_data.get("config") or {})})
layout = copy.deepcopy(layout_data.get("layout") or {})
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
# A package must not turn malformed stored data into an apparently valid
# diagnostic artifact. Validation runs on disposable copies because the
# schemas canonicalize coordinates.
repairs = _support_repairs(hass)
def _build_snapshot() -> tuple[bytes, dict[str, Any]]:
CONFIG_SCHEMA(copy.deepcopy(config))
LAYOUT_SCHEMA(copy.deepcopy(layout))
return build_support_package(
config,
layout,
config_rev=config_rev,
layout_rev=layout_rev,
card_version=msg["card_version"],
integration_version=VERSION,
home_assistant_version=HA_VERSION,
runtime={
"browser_family": msg["browser_family"],
"browser_major": msg["browser_major"],
"language": msg["language"],
"coarse_pointer": msg["coarse_pointer"],
"hover_capable": msg["hover_capable"],
"registry_access": msg["registry_access"],
"registry_age_bucket": msg["registry_age_bucket"],
},
repairs=repairs,
)
try:
# The maximum valid package is deliberately large. Keep validation,
# pseudonymisation and canonical JSON away from Home Assistant's event
# loop while retaining the coherent copies captured under write_lock.
raw, preview = await hass.async_add_executor_job(_build_snapshot)
except (vol.Invalid, ValueError, TypeError, OverflowError) as error:
code = error.code if isinstance(error, SupportPackageError) else "support_rejected"
_send_support_error(connection, msg["id"], code)
return
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)
token = secrets.token_hex(24)
expires = now + SUPPORT_PREVIEW_TTL_S
rt.support_previews[token] = {
"owner": owner,
"draft_id": msg["draft_id"],
"created": now,
"expires": expires,
"bytes": raw,
"sha256": preview["sha256"],
"versions": preview["versions"],
}
connection.send_result(
msg["id"],
{
"token": token,
"expires_in": SUPPORT_PREVIEW_TTL_S,
"size": preview["size"],
"sha256": preview["sha256"],
"spaces": preview["spaces"],
"format": preview["format"],
"version": preview["version"],
"text": raw.decode("utf-8"),
},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/support/preview/discard",
vol.Required("token"): _SUPPORT_TOKEN,
}
)
@websocket_api.async_response
async def ws_support_preview_discard(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Idempotently discard only a preview owned by this HA user."""
if not _check_write(hass, connection):
_send_support_error(connection, msg["id"], "unauthorized")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
_prune_support_previews(rt)
preview = rt.support_previews.get(msg["token"])
if preview is not None and preview.get("owner") != _connection_user_id(connection):
_send_support_error(connection, msg["id"], "support_preview_expired")
return
rt.support_previews.pop(msg["token"], None)
connection.send_result(msg["id"], {"ok": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/support/submit",
vol.Required("message"): _support_string,
vol.Optional("contact", default=""): _support_string,
vol.Optional("preview_token"): _SUPPORT_TOKEN,
vol.Required("idempotency_key"): _SUPPORT_ID,
}
)
@websocket_api.async_response
async def ws_support_submit(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Submit text and, when selected, the exact previewed package bytes."""
if not _check_write(hass, connection):
_send_support_error(connection, msg["id"], "unauthorized")
return
rt = _runtime(hass, connection, msg["id"])
if rt is None:
return
message = msg["message"].strip()
contact = msg.get("contact", "").strip()
if not message or len(message) > MAX_SUPPORT_MESSAGE_CODEPOINTS:
_send_support_error(connection, msg["id"], "support_invalid_message")
return
if len(contact) > MAX_SUPPORT_CONTACT_CODEPOINTS:
_send_support_error(connection, msg["id"], "support_rejected")
return
_prune_support_previews(rt)
token = msg.get("preview_token")
preview = rt.support_previews.get(token) if token else None
if token and (preview is None or preview.get("owner") != _connection_user_id(connection)):
_send_support_error(connection, msg["id"], "support_preview_expired")
return
attachment = preview.get("bytes") if preview else None
versions = preview.get("versions") if preview else {
"card": VERSION,
"integration": VERSION,
"home_assistant": HA_VERSION,
"model": PLAN_MODEL_VERSION,
"export_schema": EXPORT_VERSION,
}
try:
report_id = await async_submit_report(
hass,
message=message,
contact=contact,
versions=versions,
idempotency_key=msg["idempotency_key"],
attachment=attachment,
attachment_sha256=preview.get("sha256") if preview else None,
)
except SupportTransportError as error:
_send_support_error(connection, msg["id"], error.code)
return
if token:
# Retry after a timeout keeps the token; only a confirmed delivery
# consumes it. The relay's idempotency record handles uncertain first
# attempts with the same frontend key.
rt.support_previews.pop(token, None)
connection.send_result(msg["id"], {"report_id": report_id})
+2 -14
View File
@@ -3,11 +3,8 @@
// 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 = watchPage(await (await browser.newContext()).newPage());
const page = await (await browser.newContext()).newPage();
const session = await page.context().newCDPSession(page);
await page.goto('about:blank');
const metrics = async () => {
@@ -20,7 +17,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 guardProbe ? [] : cases) {
for (const c of cases) {
try {
const r = await page.evaluate(async ({ mp, alpha }) => {
const side = Math.round(Math.sqrt(mp * 1e6));
@@ -56,13 +53,4 @@ for (const c of guardProbe ? [] : 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();
+11 -36
View File
@@ -7,19 +7,12 @@ import { launch } from './serve.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
import {
assertCardContract, GLOW_CARD_CONTRACT, SPACE_GLOW_CARD_CONTRACT,
} from './performance/card-contract.mjs';
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const profile = valueArg('profile') || 'large-light-blend-v1';
if (![
'large-light-blend-v1', 'large-house-glow-overlay-v1',
'large-space-card-default-v1', 'large-space-card-glow-v1',
].includes(profile))
if (!['large-light-blend-v1', 'large-house-glow-overlay-v1'].includes(profile))
throw new Error(`unknown Glow profile: ${profile}`);
const staticCardProfile = profile.startsWith('large-space-card-');
const staticGlowProfile = profile === 'large-space-card-glow-v1';
const parsedSamples = Number(valueArg('samples'));
const parsedWarmups = Number(valueArg('warmups'));
const samples = Math.max(1, Math.min(20, Number.isFinite(parsedSamples) && parsedSamples > 0 ? parsedSamples : 7));
@@ -203,37 +196,22 @@ try {
config: { unit_system: { length: 'km' } },
});
const cacheSnapshot = (card) => ({
...(card.localName === 'houseplan-space-card' ? {
// #380: the previous stable predates the shared static-card Glow
// runtime. Its default profile still has a valid empty cache, and
// the opt-in Glow profile is explicitly bootstrap-compared below.
glowClip: card._glowRuntimeState?.clipCache?.size ?? 0,
} : {
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
}),
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
});
window.__card?.remove?.();
localStorage.clear();
const host = document.getElementById('host');
const result = { sample, longTasks: {}, renderCounts: {}, poolCounts: {} };
const staticCardProfile = profile.startsWith('large-space-card-');
const card = document.createElement(staticCardProfile ? 'houseplan-space-card' : 'houseplan-card');
card.setConfig(staticCardProfile ? {
type: 'custom:houseplan-space-card', space: fixture.config.spaces[0].id,
show_button: false, light_pools: profile === 'large-space-card-glow-v1',
} : {
type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4,
});
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
host.replaceChildren(card);
card.hass = hassFor(statesFor(1));
await until(() => staticCardProfile
? !!card._snap && card._devices?.length > 0
: card._loadOk && card._devices?.length === fixture.deviceCount);
window.__hpAssertCardContract(card, cardContract);
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
if ('_glowScreenBlend' in card) {
const probeDeadline = performance.now() + 2500;
while (!card._glowScreenBlend && performance.now() < probeDeadline)
@@ -281,10 +259,7 @@ try {
result.renderedDevices = card._devices?.length ?? 0;
result.screenBlend = card._glowScreenBlend === true;
return result;
}, {
fixture, profile, sample,
cardContract: staticCardProfile ? SPACE_GLOW_CARD_CONTRACT : GLOW_CARD_CONTRACT,
});
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
const captureStarted = performance.now();
await page.screenshot({ type: 'png' });
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
+1 -13
View File
@@ -5,7 +5,6 @@ import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
@@ -53,9 +52,6 @@ await page.addStyleTag({
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
await page.addScriptTag({
content: `window.__hpEnsureHarnessEditorRuntime = ${ensureHarnessEditorRuntime.toString()};`,
});
const chromium = await browser.version();
let buildFingerprint;
try {
@@ -191,11 +187,6 @@ try {
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
// launch() warms its own bootstrap card, but each measured sample creates
// a fresh card. Current lazy bundles therefore need their own controller
// construction; monolithic stable bundles remain immediate (#380).
if (!await window.__hpEnsureHarnessEditorRuntime(card))
throw new Error('large-house editor runtime did not preload');
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
|| !(card._isoGeometryCache instanceof Map))) {
@@ -355,10 +346,7 @@ try {
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
// #380: the previous stable owns the active session directly; current
// candidates own it through ResizeController. The explicit contract
// above requires one of these shapes before measurements begin.
const plan = card._resize?.plan || card._rszDrag?.plan;
const plan = card._resize?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
-32
View File
@@ -1,32 +0,0 @@
/**
* Аргументы запуска 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',
];
+18 -162
View File
@@ -8,13 +8,11 @@ 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');
@@ -88,15 +86,6 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const card = window.__goldenCard;
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
const settleCamera = async () => {
const started = performance.now();
do { await frame(); }
while (card._cameraTransition?.active && performance.now() - started < 1200);
if (card._cameraTransition?.active)
throw new Error(`documentation camera did not settle: ${current.id}`);
// One quiet pair proves that the settled reactive frame reached layout.
await frame();
};
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
@@ -153,99 +142,12 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
throw new Error('documentation viewport does not show the device presentation preview');
}
await settleCamera();
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
// Растеризация закрепляется теми же тремя флагами, что у golden
// (`demo/golden/run.mjs`), и по той же причине — только golden их имел с самого
// начала, а съёмка документации запускалась без единого (#410).
//
// Измерено на dev SHA 184e0098: два прогона канонического workflow на одном и
// том же коммите, одном Chromium 151.0.7922.34 и одном oxipng 10.2.0 дали три
// разошедшихся кадра из десяти (06-device-editor, 08-room-card,
// 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
//
// Аргументы запуска и объяснение каждого — в `browser-args.mjs`: константы
// живут отдельно, чтобы тест мог их прочитать, не поднимая браузер (#424).
// `reducedMotion: 'reduce'` добавлен к `animations: 'disabled'` у самого
// скриншота: первое гасит анимации в CSS, второе — уже начатые переходы на
// момент съёмки.
/**
* Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`.
*
* Отвечает на вопрос, который иначе решается гаданием: плавает ли кадр ВНУТРИ
* одного состояния страницы или разница копится между подготовками сценария.
* Для каждого сценария делается N снимков подряд без единой правки состояния, и
* они сравниваются попиксельно прямо в странице — тем же приёмом, что у golden
* (`createImageBitmap` + canvas), чтобы не тащить декодер PNG в зависимости.
*
* Ничего не пишет на диск и манифест не трогает: это измерение, а не съёмка.
*/
const unstable = [];
const stabilityArg = process.argv.find((arg) => arg.startsWith('--stability'));
const STABILITY_SHOTS = stabilityArg
? Math.max(2, Number(stabilityArg.split('=')[1] || 3))
: 0;
const comparePairs = (target, shots) => target.evaluate(async (base64Shots) => {
const decode = async (base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
};
const read = (bitmap) => {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(bitmap, 0, 0);
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
};
const frames = await Promise.all(base64Shots.map(async (shot) => {
const bitmap = await decode(shot);
return { data: read(bitmap), width: bitmap.width, height: bitmap.height };
}));
const first = frames[0];
return frames.slice(1).map((frame, index) => {
if (frame.width !== first.width || frame.height !== first.height) {
return { pair: `1↔${index + 2}`, sizeMismatch: true };
}
let pixels = 0;
let maxDelta = 0;
let alphaTouched = 0;
const box = { x0: Infinity, y0: Infinity, x1: -1, y1: -1 };
for (let at = 0; at < first.data.length; at += 4) {
const dr = Math.abs(first.data[at] - frame.data[at]);
const dg = Math.abs(first.data[at + 1] - frame.data[at + 1]);
const db = Math.abs(first.data[at + 2] - frame.data[at + 2]);
const da = Math.abs(first.data[at + 3] - frame.data[at + 3]);
if (!dr && !dg && !db && !da) continue;
pixels += 1;
maxDelta = Math.max(maxDelta, dr, dg, db);
if (da) alphaTouched += 1;
const pixel = at / 4;
const x = pixel % first.width;
const y = Math.floor(pixel / first.width);
box.x0 = Math.min(box.x0, x); box.y0 = Math.min(box.y0, y);
box.x1 = Math.max(box.x1, x); box.y1 = Math.max(box.y1, y);
}
return {
pair: `1↔${index + 2}`,
size: [first.width, first.height],
pixels,
maxDelta,
alphaTouched,
box: pixels ? box : null,
};
});
}, shots.map((shot) => shot.toString('base64')));
const { page, browser } = await launch(
undefined, undefined, DETERMINISTIC_ARGS, { reducedMotion: 'reduce' },
);
const { page, browser } = await launch();
const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
@@ -262,48 +164,12 @@ try {
const runtime = await applyDocumentationState(page, scenario);
if (scenario.expectDialog && !runtime.dialog)
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
const rawClip = scenario.capture === 'room-card'
const clip = scenario.capture === 'room-card'
? await roomCardClip(page)
: await goldenClip(page, scenario.capture);
// Обрезка выравнивается по целым пикселям (#410); почему именно так —
// в `wholePixelClip`. Замер тогда показал, где искать: три снимка подряд в
// одном состоянии совпадают побайтово у всех десяти сценариев, а между
// прогонами плавают два. Значит дело не в рендере, а в том, что приходит на
// вход съёмке — и проверяется это между прогонами (#422).
const clip = wholePixelClip(rawClip);
if (STABILITY_SHOTS && rawClip) {
console.log(`${scenario.id} обрезка: сырая`
+ ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}`
+ ` → целая ${clip.x},${clip.y} ${clip.width}x${clip.height}`);
}
// Два кадра ожидания перед съёмкой — как в golden. `animations: 'disabled'`
// гасит анимации, но не гарантирует, что уже запланированный ре-рендер
// успел лечь в композитор до захвата.
await page.evaluate(() => new Promise((done) => {
requestAnimationFrame(() => requestAnimationFrame(done));
}));
const shotOptions = {
const image = await page.screenshot({
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
};
if (STABILITY_SHOTS) {
const shots = [];
for (let attempt = 0; attempt < STABILITY_SHOTS; attempt += 1) {
await page.evaluate(() => new Promise((done) => {
requestAnimationFrame(() => requestAnimationFrame(done));
}));
shots.push(await page.screenshot(shotOptions));
}
for (const result of await comparePairs(page, shots)) {
console.log(`${scenario.id} ${result.pair}: пикселей ${result.pixels}`
+ `, максимум ${result.maxDelta}, alpha ${result.alphaTouched}`
+ (result.box ? `, bbox ${result.box.x0},${result.box.y0}`
+ `..${result.box.x1},${result.box.y1}` : '')
+ `, кадр ${result.size ? result.size.join('x') : '?'}`);
if (result.pixels || result.sizeMismatch) unstable.push(`${scenario.id} ${result.pair}`);
}
continue;
}
const image = await page.screenshot(shotOptions);
});
const imagePath = resolve(OUTPUT, scenario.file);
writeFileSync(imagePath, image);
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
@@ -320,30 +186,20 @@ try {
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
}
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
if (STABILITY_SHOTS) {
console.log(`\nзамер стабильности: ${STABILITY_SHOTS} снимка на сценарий, манифест не тронут`);
if (unstable.length) {
// Режим — не только диагностика, но и гейт: если кадр снова начнёт зависеть
// от времени, это должно останавливать съёмку, а не печататься в лог.
throw new Error(`кадр плавает внутри одного состояния страницы: ${unstable.join(', ')}`);
}
console.log('кадр не плавает ни в одном сценарии');
} else {
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
// Чем жали — тоже часть доказательства: без инструмента байты другие.
oxipng: oxipngVersion,
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
};
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
}
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
// Кто снимал. Смена браузера переписывает все картинки без содержательных
// изменений (#246), поэтому окружение съёмки — часть доказательства.
chromium: browser.version(),
// Чем жали — тоже часть доказательства: без инструмента байты другие.
oxipng: oxipngVersion,
sourceFingerprint: fingerprint,
captureScriptSha256: sha256(readFileSync(SCRIPT)),
command: 'npm run build && node demo/docs/capture.mjs',
scenarios,
};
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
} finally {
await browser.close();
}
-29
View File
@@ -1,29 +0,0 @@
/**
* Выравнивание области съёмки по целым пикселям (#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,
};
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Prepare editor internals for demo harnesses across comparison baselines.
*
* Since #337 current builds keep the editor in a lazy runtime and expose an
* explicit preload method. Older stable builds are monolithic: absence of the
* method means there is nothing to preload, not that the baseline is broken.
* A present method remains fail-closed through its result or exception.
*/
export async function ensureHarnessEditorRuntime(
card = globalThis.window?.__card,
) {
const ensure = card?._ensureEditorRuntime;
if (typeof ensure !== 'function') return true;
return Boolean(await ensure.call(card));
}
-3
View File
@@ -63,9 +63,6 @@ const previous = existsSync(manifestPath)
// байт-в-байт, доказывают, что среда съёмки та же, что у принятого эталона.
const witnessCheck = goldenWitnessRefusal({
results: report.results,
// #408: от размера матрицы, а не от числа уцелевших эталонов — иначе порог
// обходится удалением каталога эталонов.
sceneCount: GOLDEN_SCENARIOS.length,
declared,
declaredNew,
previousHashes: previous,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+135 -146
View File
@@ -1,165 +1,154 @@
{
"schema": 1,
"matrixVersion": 54,
"acceptedAt": "2026-09-03T06:25:02.855Z",
"sourceFingerprint": "b69bdfe3df225cf1b96fea0159a03a620e38cb94ec6106f9ee3b0e57cdefd183",
"matrixVersion": 50,
"acceptedAt": "2026-08-29T08:39:24.783Z",
"sourceFingerprint": "e45ac741d448e089239176cab2ff317351e9b80db7d2f7434e7b7fd34c0c8e22",
"chromium": "151.0.7922.34",
"witnesses": {
"count": 132,
"count": 123,
"floor": 10
},
"scenarios": {
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
"coincident-partition-thin-dark": "36f506ed50b92df11ab36b022a98d8fd8f3526b081bd634e6a8a38269f661051",
"coincident-partition-thick-dark": "4beacb2fdf3ab4cfdf8bb50c9a96bbee2de88d72ba7bec8924862f9a707461b1",
"coincident-partition-virtual-dark": "f37e7b03d99afdc0c588e256053965f404f31c14ea4c53bb95db5a0f9d2d465f",
"span-over-door-migrated-dark": "5e37e627eaecb455baa5e279d23f8c6d09f9cbdddfb58976c3b26383fce6df64",
"split-corner-wall-before-dark": "14407cf23f6c29c3ba7161be5445acb3606a59116fdfd03e33acb87155b3518f",
"split-corner-wall-thin-dark": "9a509f0fa7ee8503c54a508c313b47e10f8044f0a9931791d1cab83467dc3f90",
"split-corner-wall-thick-dark": "51ac5e7594386e306f5ebc94e50d6c6d428fd6a4f26a447b28e1cbdd429afda7",
"split-zero-divider-taper-dark": "eb43a7ba81ebd8b1f7c7f76306bb6205ea77a5cef3c21739d40ca540f0f7a1bf",
"hidden-wall-diagnostics-plan-light": "37ba151aa6b1ea7b1b270d461ccca348444c5eeb14a5592bf045a0e69b0fb83e",
"hidden-wall-diagnostics-plan-dark": "565d5481f744704a2a6f18658b9b6fa65e7cdbfcd11b50a2bedbc0daae2d8488",
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
"span-over-door-migrated-dark": "a817f5baacf5ff80d1f236ffaa176cb6331b75423393dae5b16e54f67f37c7e0",
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "48062a49e12728c377a7c4352148381b1be2a5606b6792d754ae055b04c22be1",
"split-corner-wall-thick-dark": "a5d795eec4561e3a1aa9c37843fadddcb04d888d5a49e6ffacf29c17b7798568",
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
"isometric-geometry-view-dark": "0d8427299c77e1e3bb86f37f53128a6640b6666e0b682e5f96085c4f07a657ec",
"isometric-geometry-view-light": "a3e592448ad3ad4ff65266f1fd6f31259c01a9baf47392f6b8be9811d74f8dce",
"isometric-live-layers-dark": "90fa1c0b64a12626e481c5f34e515c53b3e06cd657f5976bb8a72127aec3e1e9",
"isometric-no-borders-dark": "a0abf9fdc31092980de3bcbf8598844b6e3d152363694e9bd070d45af73c26d9",
"isometric-touch-kiosk-dark": "d3ae363165c4f0aba65bcbac9bd6bdca79f29aca03bba997f3abec1860eb1e55",
"isometric-large-warm-remount-dark": "299401bd4b499876d40678593308b42ac8aa4d3328113f27ad5940fc3621184a",
"geometry-view-dark-fit": "1ea111a2f68d71c2d2269ee65ac70b586b7d8a3a76f550bb1caf8d154bf42b26",
"geometry-view-light-fit": "80bbb618619731dcb9dbd5e872785f32f1e52ff71ce989a2154c5f1218812eda",
"vacuum-trail-smoothing-dark": "b33ddbfaed72f51e601576bbf87664a08578fbce5c22c69a11d83cc2644ff312",
"room-label-parity-view-dark": "0b4dd478e9b5e0f7acbf46c135309c8b1ed984b8c21f9c7ca0be9a25c10779b7",
"room-label-parity-plan-dark": "9112819b3af20153022dbc79940961c2776d70eaeb61716dad62df3c4c518665",
"room-label-parity-view-light": "a8a9915d2900a1347e85baa3df2ebde602904a9e35bbcc143fea7b1067c171f0",
"room-label-parity-plan-light": "354e6c78c6daf94305b9b0c9b4efa5caea89a327d3a81f634f1c3f5c6674524c",
"washer-active-cycle-dark": "0f38b85f2f912059a6380a0642a4d0314ea5c6fa6acb9568e1c4abbf4a7731a2",
"washer-idle-cycle-dark": "08d5f10dc14b548e6a149c8d9c93f9b635854604cebd681644f30a1ca0b4ee4d",
"day-cycle-dawn-dark": "cee9dea7017ee192dc69771822e53583df973dceecd46279c93079ba6bc730c5",
"day-cycle-day-dark": "96fb83defa72f78d9e58413505f9250fae9cc766e21bdecb4b23374e04f15d43",
"day-cycle-dusk-dark": "eca235de3abf21776b160d1b79cde3309952f0030bc2031a0ada7bdf2847d2be",
"day-cycle-night-dark": "feec7492db1187b84e59cb627de9dd8d6d412f1dfc2643c3eab5cafe8f198f7c",
"geometry-plan-editor-dark": "0fc3d491560d87e9c9b0285729c50bd20bc85c959b487b40c4d47f8c9378e8f4",
"safe-resize-handles-clamp-light": "1a5adb25fd12180bc7b9781924abb4ffad637579ec1eba9e1a76481523080960",
"safe-resize-handles-clamp-dark": "bd0035cacc4d8c16c789754507a00f673b75cfecd1569635e673683e5b9f0247",
"space-tab-drop-before-light": "d174ac6b543672ebd649e7c9363c3bb79b22b417f7d899c08f755e273632f702",
"space-tab-drop-after-dark": "eb8d6bae0e32392234bd4462652e2a8f7831d7017bf5e8d8106c9d6f203b3eaf",
"plan-snap-endpoint-light": "dd4fc2254ae457e9ae5628b1e97ca0228699cdedf9b57f5358b2280dbebd6d6e",
"plan-snap-line-gaps-dark": "b8457468ef5d4b8b38b62c4a52950364d231aedb7549fcbcbd4ee6f9444569e1",
"wall-junctions-plan-preview-light": "3151196d0015c56a46b17a599513ced13dc5558c03106e98fb7d6dda5fad8de2",
"wall-junctions-plan-t-dark": "6d16a52e7cf362622778e5b5692b6f3ae4ec04c790e5117144fb0ca50f6c70b2",
"wall-junctions-view-dark": "ad23e5fb0ca9ba23f58ee4839cec489e7d05a28ef193d424b451a4020ef8ce0b",
"junction-patch-resilience-plan-dark": "80d032f5962dce27cd3577972e3d651d41080dae22c114fde8aec4c4b605f24e",
"junction-patch-resilience-view-dark": "4ba710fd6859f958ad6c576a01d0b240a3b1723fe9df54f8cabd971414a943a6",
"wall-union-isolation-view-light": "426ef2ada0e69febd73ab305aa99b600a95e69db68dc30dae4c5288ee1151604",
"wall-union-isolation-view-dark": "215ed9d8cba31c5de4cb9d0e2b347bf0c96c8d1bba4d35feca57d6f66fc25322",
"multiwall-junction-bevel-view-dark": "d64a9549ecb9bc166f99283901411f08651389ebe5ae3420c935a677c02d4b5c",
"orthogonal-strip-cell-5-view-dark": "05cdd744d09025719eda4c5e68cf3ebc5d7d201afd3028da91262d0f4874c126",
"orthogonal-strip-cell-1-view-dark": "9c0f91b11d47f1c5df4476774ae6028db21dc15cc6fb81d9f80009e0fa86bffe",
"wall-key-roundtrip-view-dark": "cc6f755c03b1a586684627fc1dc27eb1aa28d2673a6c79bcb687da94a6bf9b3b",
"geometry-view-dark-fit": "2d96e776ea9fc8a136896b58df30abb13a8a53e6c40e5b4f4e12c6dba522380f",
"geometry-view-light-fit": "60e8a908fc1c3be3ca5c9b82bf432120d808d913f743c04683b4a2c501d84659",
"vacuum-trail-smoothing-dark": "17d564fdee66630b99e8514c557c96d3428afbd753fa0f20d82efd51f11bbb76",
"room-label-parity-view-dark": "b35675251b85144bca1bc15ed7bf27a4dd1eaa0bfc288e3e3b0d1cdefac79ae5",
"room-label-parity-plan-dark": "34c1010481e2799b8598e3c6f659ca7ec8e695a064eecacf3562000639f44d6a",
"room-label-parity-view-light": "789f8f3b6211d35e70021094f2063dc9c6ac57599d9d64743a33b7ae9dd868c2",
"room-label-parity-plan-light": "272dc35d85b1f07efc4c2a05c31f6adbf7d10c648e3c678e7235e35167f83a7d",
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
"day-cycle-dawn-dark": "be3b891520637857b7f89dc88284753a15601ebae4db7db750714b9535348d33",
"day-cycle-day-dark": "1b791b8a773a5910a71338351ab12fdc36c22742a358b647ff4f295431fa4cfd",
"day-cycle-dusk-dark": "14182ad89301cb260ba21ee5077134ed408f038ea878ed4539b84f987182f910",
"day-cycle-night-dark": "407632fc54e13e6362b7e583d6e2b7bdd31b4093823846e4e630de0c8f542120",
"geometry-plan-editor-dark": "40a0083511c1ff9d0b8f1ec419af63e2a28f1f9a0ec3768387a10c89efe73905",
"safe-resize-handles-clamp-light": "f47a5bb09fa4415416d01ca5f4164014cc1d8dc7be54ea888e5399277104e94c",
"safe-resize-handles-clamp-dark": "3efd0ca2db8a06aa475e81e3635b878c3038d4763f840e6e856661b04a4fed96",
"space-tab-drop-before-light": "7024186821b13feb63ba254aee841379788bca1a5c01bfd07c42e15fe6b81179",
"space-tab-drop-after-dark": "5a2199e8b8364da9e85e0887334144e426f43b8022f2918bf130ea13cd099f5d",
"plan-snap-endpoint-light": "2185d5ec6726e07097ba1a911984a11d5ca9b460a1cd1e7b2a719255b7d0ed0b",
"plan-snap-line-gaps-dark": "276eb10f32be938640bd68a1cf837494f11cce8d10703721831360f3a1100d23",
"wall-junctions-plan-preview-light": "965860eb4302b60864b5e3191514c60b97bbcd6073960c437a973b4a880ecddb",
"wall-junctions-plan-t-dark": "258869fb3c5af9a223d63518bc6127bfde78c2c039d39583a965d68b225338ba",
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
"junction-patch-resilience-plan-dark": "90acdf0658772c2883e775dd6972141ad699e165afcd9bf55382fbd99c203717",
"junction-patch-resilience-view-dark": "afd83241d9a1e1f19ab59c3f1e6fa7af058c3e42a7fafa5ba271ff92ccea8f91",
"wall-union-isolation-view-light": "106f8ac97b82107fed36aa4456c0b9f7a39eeb44be86864b1d4f083497cdbd6e",
"wall-union-isolation-view-dark": "914451a813546189bdc9ac22dcf158bbc822b4403080a3c2fd4c6b02bb7780a4",
"multiwall-junction-bevel-view-dark": "b80c4584a649722897c206d242a9df88d80169806a73db579b45595173b698b8",
"orthogonal-strip-cell-5-view-dark": "66a288f7e7fc6228c3c63f10e1df8df0656ca0cd36681d998b126b03acb70b5a",
"orthogonal-strip-cell-1-view-dark": "c23e6d0410b8f5c2b7fb0707f6bea40408730af08f9e7a0b166aa8c0a3d6c85f",
"wall-key-roundtrip-view-dark": "d7b0749d0f6c0cfa74ad4d9f76d8a7b7962fadf4e2648260b5647548a988341c",
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
"opening-placement-door-thick-wall-dark": "d986d82fe68bec4b0b0556870d3ec7de280a00acb42cf0165a3d64f91f994bed",
"opening-placement-passage-thick-wall-dark": "f53908dbdf8cf6681c97317398e8a04cd98ebacdbc81571c2d15d38b2599e049",
"opening-placement-passage-thick-wall-light": "f7bdf033bbc511b34269044c450f8c93a3493e27bbbcf758050b7798197efd80",
"opening-symbol-room-wall-light": "c487c11f12b2bba1473559a82af3320b7995fa9c8d1b379274b942e431930beb",
"opening-symbol-diagonal-partition-dark": "77101350b15845207644b6df86a3bc5f48b4dc873b9cfd7443c0d23507412de3",
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
"opening-placement-door-thick-wall-dark": "c21c2af812b847e7fd9a3a5b9df832db95266f81eba3d7b48c97d2b32619e7fe",
"opening-placement-passage-thick-wall-dark": "90f9a837898169c2d9ff9ec3b44b2a3d247ea034fd84b1753519da7b0c89ebdb",
"opening-placement-passage-thick-wall-light": "dd17884027d85f5727450cd08f3fe6daaed290770dbd04fe5ce10c4c2b9d43db",
"opening-symbol-room-wall-light": "403f1c6e1c59122331fbe99019a3ebd80de4106dc6d33f5f5f33feb748229645",
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "7c4072fce11f91b2c894bdb5f4f9113e72089306d629c995d38dfd17f2b65cc8",
"device-inbox-desktop-en-light": "89587944da06872f610da33e8e39441d108cb34849d8b09d4925bff4d0977565",
"device-inbox-desktop-ru-dark": "fe9506408fc198c167f2d4b9cfe91bd9fdfb722b311772457ae44c9d06248680",
"device-inbox-narrow-ru-dark": "3ddb3b66a613c6406d3d9ce2d4d479c8d84eff04acd9b5b2dce02c4c777fc738",
"geometry-decor-editor-dark": "4023e36ff4521011153983538e4a43ad80efd92bf07d523c333982bbd2f80ad6",
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
"tray-wide-tool-ru": "56c09bb397b76b7e5c6f4d7eaf7ab59e5d96faa409e553c45c6ce7605e9a9418",
"tray-medium-group-en": "b38abb69d1fac7414fc24d6cda6b5698eb861f8af94054605902e8529fe1e993",
"tray-medium-selection-ru": "b53f9513b167a33cffed2177603ae5b562264a83d5544d938c2af42ec8a551a5",
"tray-narrow-palette-en": "ae23f1b1384bec23ec272156cb2b7d3fe4088addaefa2f575bc1daa0480569a1",
"furniture-categories-light": "02ce65879aca968d05c7400e32d3f38c73a53f3bc036ee306fdf851ed84ed88c",
"furniture-variants-dark": "f52db569690a67e84701aeb79cc8967c9ee117b543fd9bf3d6fb8c3fee7a660b",
"furniture-variants-light": "4a78f688423f0f0946f50ec1a92fe9a3de5bfa288c7e153425c1beadf5c5f839",
"furniture-placement-preview-light": "f983eda1bf42e27d17afb78eada8b96b192a7e91bebb0c70e6766994000aabe3",
"furniture-plan-art-dark": "e83a8c40add2bf33af304f86ad29fc77d8d47bd7228f17c152e962f3431e0c50",
"furniture-transform-light": "3e37e03457bac87c9d7eaf6bbeb0f4054fb6e6a66a7da5953714446a92ccf296",
"furniture-transform-dark": "15d7d719a2de7b8b142c8745f262da198a57ec92e3bdeecd39339414118cb367",
"tray-narrow-tool-ru": "fa8f7125ea9f5c43f47ad090cf78ab970950a03dc5a9fa1c348c880ced7c0c4c",
"geometry-diagonal-45-opening-dark": "0ddd027a7c899b5772c80555ac8de37c33e8539cc5d911ba454f562e787f6853",
"openings-thick-wall-dark": "5dfbd731752515ece69b39d39e6b0dcf43eb5c5d8eadc1bb5f55d43f94b82992",
"openings-filled-tunnel-dark": "362802a9b810e506b81eb4bd26b883592c0d1d5aa491823d6b81888deb96fb3f",
"decor-over-opaque-hover-light": "23cf9d8aaa544fea547b189bac77e8e8f41521ac98bd0508f96737311857d3c0",
"decor-over-glow-base-dark": "49f09a452295d8e281fbe3a4ed56517ac4039454fcc680bc76ead7c222095c42",
"openings-hidden-view-dark": "bd66bea4249806cc4fb02ae8813d97e8373a12063b734d9e32a538adf086e9da",
"lighting-glow-sun-dark": "7cc688674888ff726f5e834b37d100e065afaa6c7c1ee6b83600de93172bd8bc",
"device-value-badge-positions-dark": "0e94d408ea27669d0b227504bf5ce5a637e19e4d16bbd8314b9b49acad32c271",
"device-value-face-cover-source-dark": "df67d07b96fad02d62b6a7354e31c4be7c8fcf965de1709011f08d7c9d094c36",
"device-icon-state-table-light": "588daaee4d3747aae9f026a728d2769fdbb89f7a1a4b045dd8295dbe8dfdd680",
"device-icon-state-table-dark": "9b2e03ce937f92e63a87e215f6c3a7663c199a6f9d8f33ea438fecbd6466e18a",
"device-text-shell-long-light": "5b254c2c9c2fc1d977e7417cfcaae146e0dab30b6ee29ae6860650e8ee27b61c",
"device-text-shell-long-dark": "bad52d0c18e2d0255566c6a144d6267d2ccaf06739e11e9f7a3071ed2d04f889",
"lighting-sun-window-state-only-dark": "df3d1f55b9036edd9787327e705f4218157bc9a954ac36742809aa74ffbf7e97",
"lighting-fill-light-axis-split-dark": "62757164bb514acd4cda3a51166136d2199480a5341b75b2f7df5324276f094a",
"lighting-fill-temp-axis-split-dark": "bf0ec890e47087f117f14509954e3a2cf1a0d093014580f6e3bce6833c00c8f4",
"lighting-fill-lqi-axis-split-dark": "58f9d537812ff3cddafab305478e76ae1adee6368d735eb0ebb56be7ee287cc0",
"lighting-temp-glow-dark": "9a4d24c2a2a11fd08083aba0de97fe43ff53698b8893ab13f76910315b4f1119",
"lighting-temp-glow-light": "3fa32baec780312465ebb2237c97c486e65b19a1d93caff0e34a5a9f6c1f731e",
"lighting-custom-glow-dark": "ac97ac02b8c2de217cf0ddb81e91ec0c3ce9f2572b79d89e10e8c3077e22b60f",
"lighting-opaque-glow-two-doorways-dark": "9a8e3495c474019693c8e8eece4aa78a51889c66ca2ef1de882a1ecf1963282c",
"lighting-custom-glow-light": "99700dfc1ed1f29e72977e033d54814b41aa1f36ec57fd2b0d586dadd685d3df",
"lighting-temp-glow-no-sources-dark": "9828ecb9ecf6368b9e3b394cbcb0c87bf2185b79ad2ed2021543bd2a4ff39016",
"lighting-temp-glow-room-override-dark": "929af750e3d6fc09942fde9cfa8234e73953c6dfd287136af3d471d7bd7ce5ac",
"lighting-manual-auto-spill-overlap-dark": "992de7a2bc21955ce8bf79ec84506463094d5d88392e7f2dd06bdd10dced31b1",
"hover-over-glow-dark": "32cb8ee5a15eb6a1eb65e3b89429976c553e75e628a49308911ff5aefd00e76f",
"hover-nested-room-dark": "5733d93d50b2e8a715174c8138ddd88739db12f8ef2cbaa913501612c8ceb0b2",
"junction-t-90-equal15-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
"junction-t-90-bar50-leg15-dark": "8f9e9f8d73106918c2d85782ede967bb1db554a4d09ed57cb1b062497fb7d502",
"junction-t-90-bar70-leg15-dark": "9b9110cebdb141b326c9a4863306f0026d574191e28a9642b238008cfecea38d",
"junction-x-90-equal15-dark": "e973bbedfb3816a14ee26469187345bed73cfcaa4249d152a638ab9135e007f4",
"junction-x-90-mixed-dark": "c06ca8cf7f54b05278769945477834fb97a1bc679ab821a6b3a2543c9dfaccae",
"junction-x-45-alternating-dark": "2f407f8ae20f83b1fbf394a58ec75bbaf8c18136b08998e593c5b06512e1db96",
"junction-star5-equal15-dark": "0ee20d4710260ff0dc186fa6dace9232affc9df77f1ef9debbf70066ccf2cd87",
"junction-y-60-equal50-dark": "dd34fb276b09413e63769f17e4b9e11ad4635f5af5ebe28530ed65f27c871418",
"junction-acute30-mixed-dark": "314d1b732259f68f06b68b0c0ea84325962e29083c136202b3d785902003300f",
"junction-acute15-equal50-dark": "d3cd930fecd30d71b6cf2feb993de0ebe05fe8028bf8a4351bcc9d58e99f50fd",
"junction-splay10-170-dark": "bc5b5ce280135badf598d5b2995ab40b55689284af20e429abfe7e24bf198fc7",
"junction-t-virtual-arm-dark": "3e56730afa1b74c17a15007e46fd9d5bcd2b63643421db317a54cba6cd43ef2c",
"junction-x-virtual-through-dark": "4f03f100ab2f25059629fe3724f5612d2a91988360c6eaa1092dacc14d4ddfe9",
"junction-column-node-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
"junction-draft-end-node-dark": "e9b60c1a4a62b0ac27068a5980ef17fab37eb98f6ee310f239a54d6c9b752f3f",
"junction-309-step-dark": "ede9b8f9c75504b677f2c60a8ab5c72db9e989e86d347b685a676f6d225592a2",
"junction-309-spike-dark": "9e4df39fa724ad63c87f49fa221c8c4f3011246c69a6aafc648e5864acb26b58",
"junction-309-hump-dark": "b5cad52f701423f421d8561efb542e0bb1f2352570de36f6ccd1a6e9838121f8",
"sharp-apex-legacy-dark": "8112eda510a11993bbfda5b1febbbd8bf3a8ea0786afc276b3fbe85e6963bf00",
"junction-owner-repro-dark": "a4744295587d362ff58a57a68be202e69be67adc3d0504c9fd7c4d9e767494f8",
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
"device-dialog-desktop-en": "465175fc9960c7ceb5208cf62f919381d56179ef12a93bee20e5c7af63757e7a",
"device-dialog-mobile-ru": "8128783f4e9f9ff301f15db9fa7388b51aa7e8a4a74ac0aaf962b21ecda52c78",
"device-dialog-desktop-de": "8440d4e980fac5df53142b6b118e527d9c0272e7515b65faaba6a58bc9441075",
"german-view-mobile-light": "55112787d9a26be66dfd8b724115962c4b891d1f8276b7b5b36b0ff25da48257",
"toggle-entity-dialog-desktop-en": "7a8ac3071d8bb3f79f2bfe724d0138e2b309baab3104ef8afb0b2b081c8b82b1",
"toggle-entity-dialog-mobile-ru": "d3062e76da27218cac45ca4c76f34b358d9cb98e85288f24e64b7de1bcca949d",
"device-help-popover-light-ru": "c2146f13d03841c395b0772f1a21de358c62a8703d3d02e7d026fac8ae29955e",
"decor-color-popover-mobile-ru": "c65318327c6e4631c769419e791a749d667b4dfc36f2e3fc46d6fac53180cf1b",
"decor-color-popover-desktop-en": "60de64b1bc072327cb88d2e876732d50d5cb7441c850f52b7af6f4ed5c6de22b",
"general-color-popover-desktop-en": "5b47ab921f889c9ad41cab195e6efeed283f014d37f5377714b54ef30282ebf6",
"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",
"geometry-devices-editor-dark": "acc779a2a21d5c2a26f40d1a23495cafaffc460c57c1100660fb3cde554303f1",
"device-inbox-desktop-en-light": "c04254626f613b11e1b9165abf6bfad4b04a2670acab2887e38062d7d16b4533",
"device-inbox-desktop-ru-dark": "2e7e7175ed770fea657828beda68b62d6eb657a4b72222b1977df3d7d692b0d6",
"device-inbox-narrow-ru-dark": "5263f518d4a415caedbb7917c79f9acbb03de234feec11e3128c235d2bd5b39f",
"geometry-decor-editor-dark": "4a4d40844f4f92b0e1baf169a25f8a15a507682cea677c631dc57f68e31e373f",
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
"tray-medium-selection-ru": "44de98a53d7de665daf2a3f466c281caa66ef6d6cd2941125e2cbfd975f8c8f4",
"tray-narrow-palette-en": "392eeac327c899add9792e356d75f48ae541c38bd1c2ace6bdde8e9dcdf17fd6",
"furniture-categories-light": "13ab156ce69ff67f82b0aaa1e755d7b2ed63bda2a06f248f56d713c0d88e6fb7",
"furniture-variants-dark": "dc83d2fef638063f93a2474dba48d28f2b7c472bf7ed88147efbe65c745bcb1b",
"furniture-variants-light": "a2f4742c392ff73b0b68ee91970a81c4550295180c51f4b9b3d3f19fc58cf2a4",
"furniture-placement-preview-light": "282ecb96c5d251323c39ccc2fd27775d8c20a15e3a7d6b86eb2bd93dab46c312",
"furniture-plan-art-dark": "d12db5d0a3d99caed4228ba04983f762794909efd366b999e240eba310724110",
"tray-narrow-tool-ru": "3d002d2a153fe615e8dd7c9fca466525477318992d4bd4bb099dbfb3d9195c4a",
"geometry-diagonal-45-opening-dark": "ba9462322da34b20fff1ee1f950c516855dd7ac56131e13863f576e2d0ba5177",
"openings-thick-wall-dark": "539cdb9cb1460cd42c2e51901ea50f496e8214d02633e110e2780c3f177154db",
"openings-filled-tunnel-dark": "bb2a4343a285e2fd867c6090108589549facf87b1a9471f3ae331d743af6653c",
"decor-over-opaque-hover-light": "d6770c39d65f925c2e09bb31d49a7492af9129073d3fdf9b1ef17491b97c5dbc",
"decor-over-glow-base-dark": "5543264be96f517ef3e330efad90c898d357c7d2927b56131f2ef7192def6f63",
"openings-hidden-view-dark": "19ab4fd2ffce0fea0fe609b007a3e60f7abd84b40935cbf3c5fa9dc1fda21046",
"lighting-glow-sun-dark": "08e2964df6fd03abad4b9a81f1dbd0fe3812fb6b5f7908aeb027ad2953b2f88c",
"device-value-badge-positions-dark": "75ab0210d697f952b8a8977649e84308de3ce3b59c84ec93071179a1d6cb015d",
"device-icon-state-table-light": "85c785824ccafc2d5f50e834ad2d746319d64ecb292d1b9cf7fab8661d5d410d",
"device-icon-state-table-dark": "03a90523132c3dbe6c78d44d01fe89df93e2d0474f91ef84e7107e6e4bc7d337",
"device-text-shell-long-light": "97a113b5164f373b4af2922c8e71bc1f063d8c2761b72c3182697fcb1aac3cab",
"device-text-shell-long-dark": "c8fe32aced704c7e7562f0c8499183fad848a977063ab731401cc01e7f20ef38",
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
"lighting-fill-light-axis-split-dark": "e45d44496c83ad92f8d5bd058821e6d71c97c48a2b66c1933206535aea2655c1",
"lighting-fill-temp-axis-split-dark": "895e1e0beee38bf659b1313ed9951353a7792d2e8ae3a8620c4cf7a4f6a84a4d",
"lighting-fill-lqi-axis-split-dark": "4a029b17419aaf60feab23f7f0cb385d97353e3aec0e39ed67e9ac94f6c08029",
"lighting-temp-glow-dark": "3ce504d83fda2ba5d772c25f8cbb47c379b335addd8650f6933b58f22f5655a7",
"lighting-temp-glow-light": "e9f2db0a686a9e20bf71564c10bab826a2a473c73d572a6b67ef02a735d61e8e",
"lighting-custom-glow-dark": "94cc6135573aa9462de9e7f309dea62e236668b84970bae35d48f1aad338f8f4",
"lighting-opaque-glow-two-doorways-dark": "5326856baf1e8073d2cba507a5d073889249ba3acc73f87616c0e60034dd1807",
"lighting-custom-glow-light": "aa9477aaff4d137abfcb6d67a5cf62b91f41b3631383ee8edb7499802a5c9b87",
"lighting-temp-glow-no-sources-dark": "cd992af54d1c9dcc58bbd0b21b15b475be6f19eaabcf9ea866bfa82f205460c9",
"lighting-temp-glow-room-override-dark": "66a418829b51379d024c542f8b9a12bd42d72afa81a6bf66561d6498ac4cdb45",
"lighting-manual-auto-spill-overlap-dark": "0dc780335317d6dd577c8d5539f33223228074e1c95e936e4666f50a8db09739",
"hover-over-glow-dark": "49136f6073662482ebf49a28f8f22b698d2c20a9cb64b5667d111b9835717968",
"hover-nested-room-dark": "b546f923fd31d28d2881f074e3a67c5d9d3f9450169f9118502ecafc98c4467d",
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
"junction-x-45-alternating-dark": "49192516aabc14e84a2da4d1361c1be57d24e5c947b199cdf8b6c55fd7318504",
"junction-star5-equal15-dark": "2fd822f73f486173700405208168300d27d4e360b17cb278c7ee7bc1cda3769e",
"junction-y-60-equal50-dark": "7a61dbfc36437674c2c2bf5f450a64797e21a124f42c0e45bfb56e3236e0b53e",
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
"junction-acute15-equal50-dark": "e7e657137e8dcf6294d400f3114497318c055405dbc0d867603514b56c42b907",
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a",
"sharp-apex-legacy-dark": "93a46c66f7263a0325d0c4d50fa0677f3c902647ea5880960b99dc8fa04a01a5",
"junction-owner-repro-dark": "f5407d420449ebf897f94fcfcdaaf4630e818c043d3076b090f7d1944df5296b",
"large-house-zoom-040-dark": "736af1c340646c4c1cbab3d2270f67a535995e1ea1e2abe49c91d2c396052097",
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
"toggle-entity-dialog-mobile-ru": "77e8fc03817a784888993490cdfba675482b8c56483199c1efa512db8531516b",
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
"decor-color-popover-mobile-ru": "606c41d53f9a996b24990032505180159ce032e2b4f14dfb214c79a8c4b85702",
"decor-color-popover-desktop-en": "f626f246c8e29d1b1e5e067e0923438eb1222606388a33c3071e86307b4ca130",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
"space-room-color-popover-desktop-ru": "946c550232ff3819bb44058eff6c5deadfccf85527c23065137ee02fa457aed9",
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
"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",
"optimize-preflight-dialog-dark-en": "0473d7f16dd830aaddbec5c33f2b423fde48e2053a1094dbab052e6a2cbad9d4",
"optimize-preflight-dialog-light-ru": "a4eb6ef9930652bd7e55fbb725fd1bcc5a0e5b71e8060dbf6f772dd62cec5d36",
"optimize-orphan-references-dark-en": "f40417298e126fa804ddafb91d62b728c86a56c5a28b4fad12b732070faa4ad6",
"optimize-orphan-references-light-ru": "2d0319f681cfbe472ad5dede05d145a995dedee577d8d816e2fe46a519e019f5",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More