Compare commits

..
Author SHA1 Message Date
github-actions[bot] e2ff2acb7a dev-build: 674e589ad8
Source: 674e589ad8
Issue: #657
User-Visible: no
2026-09-29 06:30:42 +00:00
661 changed files with 5104 additions and 179184 deletions
-14
View File
@@ -1,14 +0,0 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.mp4 binary
*.webm binary
*.zip binary
*.woff binary
*.woff2 binary
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -eu
message_file=$1
# Git-generated merge commits do not represent an independently authored
# product change and inherit provenance from their parents.
case "${message_file##*/}" in
MERGE_MSG) exit 0 ;;
esac
repo_root=$(git rev-parse --show-toplevel)
node "$repo_root/scripts/validate-commit-provenance.mjs" \
--message-file "$message_file" --staged --check-hook-mode
-80
View File
@@ -1,80 +0,0 @@
#!/bin/sh
set -eu
# PROCESS.md 10.1: the blocking process gate lives here, because commits go
# straight to dev without pull requests and GitHub blocks nothing on its side.
# CI still runs the same script (10.3), but by then the code is already in dev —
# that catch-up pass reports, it does not prevent.
#
# Git feeds one line per ref on stdin:
# <local ref> <local sha> <remote ref> <remote sha>
repo_root=$(git rev-parse --show-toplevel)
gate="$repo_root/scripts/process-gate.mjs"
zero=$(printf '%040d' 0)
# The gate reasons about commits. A repository without it — an old checkout, a
# bisect, a worktree from before the script existed — must still be pushable.
if [ ! -f "$gate" ]; then
exit 0
fi
# Reading issue status needs gh, and a hook that cannot work on a train is a
# hook people disable. Offline the checks that need no network still run, and the
# strict pass happens in CI, where gh is always present.
issues_flag=""
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
issues_flag="--issues"
else
echo "process-gate: gh недоступен, проверка статуса issue пропущена — её выполнит CI" >&2
fi
status=0
while read -r local_ref local_sha remote_ref remote_sha; do
# Deleting a remote branch pushes nothing to examine.
if [ "$local_sha" = "$zero" ]; then
continue
fi
# Tags carry no process state of their own: the commit they point at was
# already checked when it was pushed.
case "$local_ref" in
refs/tags/*) continue ;;
esac
if [ "$remote_sha" = "$zero" ]; then
# A branch that does not exist on the remote yet. Everything it adds on top
# of dev is new, so that is the range — not the whole history, which would
# drag in every violation committed before the gate existed.
base=$(git merge-base "$local_sha" refs/remotes/origin/dev 2>/dev/null || true)
if [ -z "$base" ]; then
echo "process-gate: не нашёл общего предка с origin/dev, проверяю последние 20 коммитов" >&2
base="$local_sha~20"
fi
else
base="$remote_sha"
fi
echo "process-gate: $local_ref, диапазон ${base}..${local_sha}" >&2
# shellcheck disable=SC2086
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
status=1
fi
done
if [ "$status" -ne 0 ]; then
cat >&2 <<'EOF'
Push остановлен: нарушен процесс (PROCESS.md §10.2).
Починить надо причину, а не симптом. Если нарушение уже опубликовано, его
исправляет следующий коммит плюс issue с меткой `process` — не force-push
(§12, правило 17).
Обойти проверку можно через `git push --no-verify`, и тогда то же самое найдёт
job `process-gate` в Validate — уже после того, как код окажется в dev.
EOF
fi
exit "$status"
-36
View File
@@ -1,36 +0,0 @@
name: Bug report
description: Something in House Plan does not work as expected
labels: [bug]
body:
- type: input
id: version
attributes:
label: House Plan version
placeholder: v1.13.0
validations:
required: true
- type: input
id: ha_version
attributes:
label: Home Assistant version
placeholder: "2026.6"
validations:
required: true
- type: textarea
id: what
attributes:
label: What happened / what did you expect?
description: Steps to reproduce help a lot.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / browser console errors
description: "Settings → System → Logs (search: houseplan) and the browser console (F12)."
render: text
- type: textarea
id: diagnostics
attributes:
label: Diagnostics
description: "Settings → Devices & services → House Plan → ⋯ → Download diagnostics (personal fields are redacted automatically)."
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Telegram chat (@ha_houseplan)
url: https://t.me/ha_houseplan
about: Questions, setup help, ideas and screenshots — the fastest way to get an answer.
- name: 💡 GitHub discussions
url: https://github.com/Matysh/houseplan-card/discussions
about: Longer-form ideas and show-and-tell.
@@ -1,14 +0,0 @@
name: Feature request
description: An idea to make House Plan better
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What problem would this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: How do you imagine it working?
-95
View File
@@ -1,95 +0,0 @@
name: Announce release
# Telegram notifications for t.me/ha_houseplan (owner request, 2026-08-07).
# Stable releases are announced; prereleases are deliberately silent.
# workflow_dispatch exists purely as a connectivity test button and therefore
# remains allowed to send a test message.
on:
release:
types: [published]
workflow_dispatch: {}
workflow_call:
inputs:
reusable:
required: true
type: boolean
tag:
required: true
type: string
release_name:
required: true
type: string
url:
required: true
type: string
prerelease:
required: true
type: boolean
ref:
required: true
type: string
secrets:
TELEGRAM_BOT_TOKEN:
required: true
TELEGRAM_CHAT_ID:
required: true
permissions:
contents: read
jobs:
telegram:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
runs-on: ubuntu-latest
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
env:
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
CALLED: ${{ inputs.reusable }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_NAME: ${{ inputs.release_name }}
INPUT_URL: ${{ inputs.url }}
INPUT_PRE: ${{ inputs.prerelease }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
RELEASE_BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
if [ "$CALLED" = "true" ]; then
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
else
TAG=$RELEASE_TAG
NAME=$RELEASE_NAME
URL=$RELEASE_URL
PRE=$RELEASE_PRE
BODY=$RELEASE_BODY
fi
if [ "$PRE" = "true" ]; then
echo "Prerelease Telegram announcement is disabled"
exit 0
fi
KIND="🏠 Релиз"
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
"$KIND" "$TAG" "$NAME" "$SUMMARY" "$URL")
fi
curl -sS --fail-with-body -X POST \
"https://api.telegram.org/bot$TOKEN/sendMessage" \
--data-urlencode "chat_id=$CHAT" \
--data-urlencode "text=$TEXT" \
-d disable_web_page_preview=true
-73
View File
@@ -1,73 +0,0 @@
name: Mutation gate
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой: пересборка бандла на каждого мутанта. Поэтому он не входит в
# Validate и не идёт на каждый push. Его место — перед стабильным релизом
# (PROCESS.md §8) и раз в неделю по расписанию, чтобы дрейф тестов не копился
# до релиза. Дешёвая половина — «якоря патчей живы, guard-файлы существуют» —
# идёт с обычными юнитами: test/mutation-gate.test.mjs.
on:
workflow_dispatch:
inputs:
ref:
description: Git ref whose mutation guards must be proved
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
permissions:
contents: read
concurrency:
group: mutation-gate
cancel-in-progress: true
jobs:
mutants:
runs-on: ubuntu-latest
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
# гейт предрелизный. Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Установить backend test dependencies
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Реестр применим к текущему коду
run: node scripts/mutation-gate.mjs --check
- name: Каждый тест ловит свою поломку
run: node scripts/mutation-gate.mjs
-178
View File
@@ -1,178 +0,0 @@
name: Full Performance
on:
# Every main promotion is a stable-release candidate and must have an
# exact-SHA full comparison before stable assets are published.
push:
branches:
- main
schedule:
- cron: "0 4 * * 1"
workflow_dispatch:
inputs:
comparison_ref:
description: "Optional baseline tag, branch or SHA; empty uses the candidate parent"
required: false
type: string
permissions:
contents: read
concurrency:
group: full-performance-${{ github.ref }}
cancel-in-progress: false
jobs:
performance:
# Base and candidate stay sequential on one hosted runner. Splitting them
# across runners would turn machine variance into a false regression.
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out candidate
uses: actions/checkout@v7
with:
path: candidate
fetch-depth: 2
- name: Resolve comparison SHA
id: base
working-directory: candidate
env:
EVENT_NAME: ${{ github.event_name }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
MANUAL_BASE: ${{ inputs.comparison_ref }}
run: |
set -euo pipefail
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --force --tags --prune --unshallow origin
else
git fetch --force --tags --prune origin
fi
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "$MANUAL_BASE" ]; then
sha="$(git rev-parse "${MANUAL_BASE}^{commit}" 2>/dev/null || true)"
source="manual comparison ref $MANUAL_BASE"
elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && ! printf '%s' "$PUSH_BEFORE_SHA" | grep -Eq '^0+$'; then
sha="$PUSH_BEFORE_SHA"
source="push before"
else
sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
source="candidate parent"
fi
requested_sha="$sha"
usable=true
reason=""
if [ -z "$sha" ] || ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
usable=false
reason="commit is not present after fetching all remote refs"
elif [ "$source" = "push before" ] && ! git merge-base --is-ancestor "$sha" HEAD; then
usable=false
reason="commit is no longer an ancestor of the pushed revision"
fi
if [ "$usable" != true ]; then
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
if [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
sha="$parent_sha"
source="candidate parent (unusable requested-base fallback)"
echo "::warning::Comparison SHA ${requested_sha:-none} is unusable ($reason); using candidate parent $sha."
usable=true
fi
fi
if [ "$usable" != true ]; then
fallback_tag=""
fallback_sha=""
head_sha="$(git rev-parse HEAD)"
while IFS= read -r tag; do
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) continue ;;
esac
tag_sha="$(git rev-list -n 1 "$tag")"
if [ "$tag_sha" != "$head_sha" ]; then
fallback_tag="$tag"
fallback_sha="$tag_sha"
break
fi
done < <(git tag --merged HEAD --sort=-version:refname)
if [ -z "$fallback_sha" ]; then
echo "::error::No usable comparison commit or previous release tag is reachable from HEAD."
exit 1
fi
sha="$fallback_sha"
source="release tag $fallback_tag"
echo "::warning::Using $fallback_tag ($sha) as the comparison base."
fi
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
sha="$(git rev-parse HEAD^)"
source="candidate parent (HP-PERF-01 compatibility)"
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
- name: Check out base SHA
uses: actions/checkout@v7
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
cache-dependency-path: |
candidate/package-lock.json
baseline/package-lock.json
- name: Install candidate and baseline dependencies
run: npm ci --prefix candidate && npm ci --prefix baseline
- name: Install pinned Chromium
working-directory: candidate
run: npx playwright install --with-deps chromium
- name: Build both exact source trees
run: |
npm --prefix candidate run build
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
npm --prefix baseline run build
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
- name: Capture base and candidate profiles
working-directory: candidate
run: |
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/overlay-candidate.json
if ! grep -q "glow_enabled" ../baseline/src/logic.ts; then
echo "Base predates independent Glow; bootstrap relative overlay baseline, keep absolute gate"
cp ../artifacts/performance/overlay-candidate.json ../artifacts/performance/overlay-baseline.json
fi
- name: Enforce relative and absolute performance budgets
working-directory: candidate
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
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
- name: Upload full performance reports
if: always()
uses: actions/upload-artifact@v7
with:
name: full-performance
path: artifacts/performance
-510
View File
@@ -1,510 +0,0 @@
name: Process
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
# сообщение: она порождает событие, событие запускает следующий шаг.
#
# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec
# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress
#
# Три вещи, без которых конвейер молча не работает:
#
# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub
# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы
# не было циклов — цепочка оборвалась бы после первого шага.
# 2. Этот файл обязан лежать в ветке по умолчанию (main). Для события `issues`
# GitHub берёт workflow только оттуда, независимо от того, что в dev.
# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым
# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
# Проверять не только YAML, но и каждый `run` через `bash -n`.
on:
issues:
types: [labeled]
concurrency:
# Два события по одному issue не должны запускать два прогона.
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
issues: write
# Обязательно: claude-code-action получает OIDC-токен для авторизации
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
id-token: write
jobs:
guard:
runs-on: ubuntu-latest
outputs:
stage: ${{ steps.decide.outputs.stage }}
cycle: ${{ steps.decide.outputs.cycle }}
limit: ${{ steps.decide.outputs.limit }}
steps:
- id: decide
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
LABEL: ${{ github.event.label.name }}
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
NUM: ${{ github.event.issue.number }}
run: |
# Этап определяется первым: от него зависит, какие вердикты считать.
stage=""; marker=""
case "$LABEL" in
S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;;
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
# код-ревью упиралось бы в review-4 после одной правки.
#
# Этап опознаётся по имени документа в теле комментария. Если документа
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
done_cycles=0
if [ -n "$stage" ]; then
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
--json comments \
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
# Так и вышло на #123: чужой issue довели до S4-spec-review, guard
# отказался за 9 секунд, и в issue не было ни слова.
#
# Пишем только когда пытались запустить ревью, то есть stage опознан.
# Иначе комментарий уходил бы на каждую смену любой метки.
refuse() {
echo "$1"
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Конвейер ревью не запущен: $2
Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
stage=""
}
# Автор issue здесь не проверяется (решение владельца 2026-08-13).
# Проверка стоит на входе в процесс, а не на каждом шаге: как только
# задача получила статусную метку, она в работе, и кто её завёл — не
# имеет значения. Само присвоение метки и есть явное подтверждение
# владельца, причём проверенное платформой: метки может ставить только
# тот, у кого есть право записи в репозиторий. Прежняя проверка здесь
# дублировала эту гарантию и заставляла переоформлять чужие отчёты
# своими issue — чистая работа впустую, как на #123.
if [ -z "$stage" ]; then
:
elif [ "$BLOCKED" = "true" ]; then
refuse "стоит blocked — конвейер не запускается" \
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
elif [ "$EXHAUSTED" = "true" ]; then
refuse "стоит review-4 — решение за владельцем" \
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
elif [ "$done_cycles" -ge "$limit" ]; then
echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан"
gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
stage=""
else
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
fi
echo "stage=$stage" >> "$GITHUB_OUTPUT"
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
needs: guard
if: needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: dev
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
# contents: read. Отказ обязан быть громким и правильным.
persist-credentials: false
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
# только на публикации документа — после сорока минут работы ревьюера.
- name: Секрет HP_PROCESS_TOKEN жив
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
exit 1
fi
if ! login=$(gh api user -q .login 2>/dev/null); then
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
exit 1
fi
echo "токен жив, действует от: $login"
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
- name: Перейти на ветку задачи
id: branch
env:
NUM: ${{ github.event.issue.number }}
run: |
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
branches=$(git for-each-ref --sort=-committerdate \
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
branch=$(printf '%s\n' "$branches" | head -1)
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
fi
if [ -n "$branch" ]; then
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
fi
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Review
id: review
uses: anthropics/claude-code-action@v1
with:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Ты ревьюер проекта House Plan. Язык ответа — русский.
Issue: #${{ github.event.issue.number }}
Репозиторий: ${{ github.repository }}
Этап: ${{ needs.guard.outputs.stage }}
spec — ревью ТЗ (PROCESS.md §2.4)
code — код-ревью (PROCESS.md §2.7)
Прочитай в этом порядке, прежде чем судить:
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
ограничитель: «features are built, improved and accepted only
if they serve a job listed here». Первый вопрос к задаче —
какую строку Core user jobs она закрывает.
2. AGENTS.md и PROCESS.md — процесс, классы изменений, трейлеры,
лимит циклов, формат вердикта.
3. Тело issue #${{ github.event.issue.number }} и все комментарии.
4. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
терминология интерфейса берётся оттуда, а не изобретается.
5. Канонический документ затронутой подсистемы: docs/SUN.md,
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
Проверь обязательные разделы §7.1, однозначность каждого AC и
указание способа доказательства. Отдельно проверь, что автор не
выдал догадку за решение: утверждение о поведении, которого нет ни
в одном документе и которое не помечено как предположение, —
замечание. Не бывает сложной задачи без единого открытого вопроса.
Владельцу задаются только продуктовые вопросы: что человек видит или
делает и каков объём видимых изменений в этом issue. Технический
вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и
решаешь по существу в своём вердикте.
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
чтением, не исполнением». «Verified» без названной команды и её
результата доказательством не является. Зависимости уже установлены
workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь
трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба
changelog в том же коммите.
**Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
Всегда, они дешёвые:
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
копий бандла.
По необходимости, и «необходимость» определяется diff'ом и AC:
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
только когда задача действительно задевает всё;
- `npm run golden:verify` — если diff может изменить видимый
результат: рендер, геометрия, стили, слои;
- `python -m pytest tests_backend -q` — если тронут
`custom_components/**/*.py`;
- performance-профили — если названы в AC либо тронуты
чувствительные к перфу пути.
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
тем тестам, которые ты прогонял.
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
почему.** Это условие честности такого сужения: непрогнанный гейт
становится видимым решением, а не молчаливым пропуском. Раздел «чего
не проверял» в документе ревью — не формальность, а главный его
раздел на коротких задачах.
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
Low либо правится, либо снимается с записью. Жёлтый вердикт
допустим при полностью выполненных AC, если изменение не решает
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
Каждую Medium-находку заведи отдельным issue со ссылкой на
#${{ github.event.issue.number }} и метками: тип, приоритет,
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
запрещено §12.
Напиши полный документ ревью в файл
docs/reviews/<SPEC|CODE>-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md
(SPEC для этапа spec, CODE для code): скоуп, как проверялось,
находки с воспроизведением, что проверено и корректно, чего не
проверял. Каталог docs/reviews/ создай, если его нет. Больше не
пиши ничего: любой файл вне docs/reviews/ опубликован не будет.
Затем оставь в issue краткий комментарий: вердикт, ключевые находки
и ссылка на документ. Первой строкой — вердикт в формате §7.2:
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
без него метка не переставится и конвейер встанет.
claude_args: |
--max-turns 150
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
run: |
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
# комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что
# ничего не сделал. Документ ложится туда же, где лежит само ТЗ.
target="${BRANCH:-dev}"
if [ -z "$BRANCH" ]; then
echo "::warning::ветки задачи нет — документ ревью ляжет в dev"
fi
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
git checkout -- . 2>/dev/null || true
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
git add docs/reviews 2>/dev/null || true
if git diff --cached --quiet; then
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
# коммиттер GitHub). Провал — когда файла нет и на ветке.
git fetch -q origin "$target"
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "документ уже опубликован ревьюером: $doc"
exit 0
fi
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
# остались только комментариями, метки переставились, и пропажу
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
echo "::error::вердикт есть, а документа $doc нет ни в рабочей копии, ни в $target — ревью без артефакта (#171)"
exit 1
fi
git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
commit -q -F - <<EOF
docs: review document for #$NUM
Issue: #$NUM
User-Visible: no
EOF
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"; then
git fetch -q origin "$target"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase "origin/$target"; then
git rebase --abort || true
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
exit 1
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
# документом, названным не по формату, — тот же вердикт без
# артефакта, только дороже в обнаружении.
git fetch -q origin "$target"
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
exit 1
fi
echo "документ опубликован в $target: $doc"
- name: Решение по вердикту
id: decide
env:
OUT: ${{ steps.review.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
run: |
verdict=$(echo "$OUT" | jq -r '.verdict')
high=$(echo "$OUT" | jq -r '.high')
echo "вердикт: $verdict, High: $high"
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
# по инструкции. Оба считаются циклом.
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
green=true
case "$STAGE" in
spec) from=S4-spec-review; to=S5-ready ;;
code) from=S7-code-review; to=S8-merged ;;
esac
else
green=false
case "$STAGE" in
spec) from=S4-spec-review; to=S3-spec ;;
code) from=S7-code-review; to=S6-in-progress ;;
esac
fi
echo "green=$green" >> "$GITHUB_OUTPUT"
echo "from=$from" >> "$GITHUB_OUTPUT"
echo "to=$to" >> "$GITHUB_OUTPUT"
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
# ДО метки, иначе она врёт в промежутке.
#
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
- name: Слить ветку в dev
id: merge
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
run: |
if [ -z "$BRANCH" ]; then
echo "::error::ветки задачи нет — сливать нечего"
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch -q origin dev
git checkout -q -B merge-into-dev "origin/$BRANCH"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
git rebase --abort || true
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
cat > /tmp/conflict.md <<EOF
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
2. запушить ветку;
3. вернуть метку \`S7-code-review\`.
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
echo "merged=true" >> "$GITHUB_OUTPUT"
echo "слито в dev: $(git rev-parse --short HEAD)"
- name: Переставить метку
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
FROM: ${{ steps.decide.outputs.from }}
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
# автору: метка утверждала бы, что код в dev, а его там нет.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
run: |
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label "$TO" --remove-label "$FROM"
echo "$FROM -> $TO"
- name: Позвать владельца, если ревью упало
if: failure()
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Тело через heredoc, а не многострочный --body: строка с нулевым
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
cat > /tmp/failure.md <<EOF
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
EOF
gh issue comment "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" --body-file /tmp/failure.md
-259
View File
@@ -1,259 +0,0 @@
name: Publish prerelease
run-name: Publish ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: "Exact prerelease tag, for example v1.61.0-beta.4"
required: true
type: string
permissions:
contents: write
actions: read
concurrency:
group: publish-prerelease-${{ inputs.tag }}
cancel-in-progress: false
jobs:
gate:
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Pin the current dev candidate
id: candidate
env:
TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
test "$REF_NAME" = "dev" || {
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
exit 1
}
SHA=$(git rev-parse HEAD)
git fetch origin dev
test "$(git rev-parse origin/dev)" = "$SHA" || {
echo "::error::The dispatched SHA is no longer the origin/dev tip"
exit 1
}
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Verify version, changelogs and bilingual release notes
env:
TAG: ${{ inputs.tag }}
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
- name: Require green Validate for this exact SHA
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
publish:
needs: gate
runs-on: ubuntu-latest
outputs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Build and verify both release assets before publication
env:
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
npm ci
npm run build
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
VERSION=${TAG#v}
grep -Fq "$VERSION" dist/houseplan-card.js
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
unzip -l houseplan.zip | grep -q "manifest.json"
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
test "$ZIP_VERSION" = "$VERSION" || {
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
exit 1
}
test -s dist/houseplan-card.js
test -s houseplan.zip
- name: Create or verify the annotated tag
env:
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$PEELED" || {
echo "::error::Existing remote tag $TAG is not annotated"
exit 1
}
test "$PEELED" = "$SHA" || {
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
exit 1
}
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" "$SHA" -m "$TAG"
git push origin "$TAG"
fi
- name: Stage, verify and publish the prerelease
id: release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
fi
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
--repo "$GITHUB_REPOSITORY" --clobber
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
- name: Verify the public release and assets
id: verify
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
throw new Error('release is not a public prerelease for the requested tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
echo "url=$URL" >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v9
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((release) => release.prerelease && !release.draft);
if (first?.tag_name !== process.env.EXPECTED_TAG) {
core.setFailed(
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
process.env.EXPECTED_TAG,
);
}
# PROCESS.md 10.2 item 10: closing issues and stripping status labels happens
# because a beta was published, not because someone remembered to do it. The
# manual step was skipped twice, and both times it broke the invariant that a
# closed issue carries no status label — the one thing `verify` relies on.
#
# A manual step after a successful release is the worst kind: by the time it is
# due, the work already looks finished, which is exactly why it gets forgotten.
close-merged:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
# not start workflows, so removing the label cannot wake the review
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
issues: write
steps:
- name: Close the S8-merged queue and strip status labels
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ needs.gate.outputs.tag }}
URL: ${{ needs.publish.outputs.url }}
run: |
set -euo pipefail
# Only the owner's issues take part in the process; issues filed by
# anyone else never carry status labels and are not ours to close.
numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \
--author Matysh --limit 100 --json number --jq '.[].number')
if [ -z "$numbers" ]; then
echo "the S8-merged queue is empty, nothing to close"
else
for n in $numbers; do
gh issue comment "$n" --repo "$REPO" \
--body "Выпущено в \`$TAG\` · [релиз]($URL)"
# Label first, then close. If the run dies between the two steps an
# open issue without a status is visible and fixable in the flow;
# the reverse order would recreate the exact breakage this job is
# here to prevent.
gh issue edit "$n" --repo "$REPO" --remove-label S8-merged
gh issue close "$n" --repo "$REPO" --reason completed
echo "closed #$n"
done
fi
# Targeted at the defect that actually recurs, not at the invariant in
# general: no closed issue may still carry S8-merged.
leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \
--limit 100 --json number --jq 'length')
test "$leftover" = "0" || {
echo "::error::$leftover closed issues still carry S8-merged"
exit 1
}
announce:
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ needs.gate.outputs.tag }}
release_name: ${{ needs.gate.outputs.tag }}
url: ${{ needs.publish.outputs.url }}
prerelease: true
ref: ${{ needs.gate.outputs.tag }}
secrets: inherit
-40
View File
@@ -1,40 +0,0 @@
name: Attach HACS zip to release
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
# dev, stable tags once it reaches main.
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
# once for the latest stable after the hacs.json change reaches main).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach the zip to"
required: true
permissions:
contents: write
jobs:
zip:
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: unzip -l houseplan.zip | grep -q "manifest.json"
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
-98
View File
@@ -1,98 +0,0 @@
name: Release
on:
release:
types: [published]
permissions:
contents: write
actions: read
jobs:
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
# workflow only built and uploaded, so an asset shipped while both Validate
# runs for the very same commit were red. The asset now waits for a green
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
#
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Require a green Validate for this exact commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# HEAD is the peeled commit even when TAG is annotated. Do not trust
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Full Performance"
build:
needs: gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v7
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Verify compositor frame continuity for a stable release
if: ${{ !github.event.release.prerelease }}
run: |
npx playwright install --with-deps chromium
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v7
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
- name: Attach card to release
uses: softprops/action-gh-release@v3
with:
files: dist/houseplan-card.js
hacs-discovery:
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
# sorting SemVer. A valid asset can therefore be invisible to beta users
# (beta.10 appeared after beta.9). Keep the release asset, but
# make that distribution failure impossible to miss in the release run.
if: ${{ github.event.release.prerelease }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v9
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const first = releases.find((r) => r.prerelease && !r.draft);
const expected = context.payload.release.tag_name;
if (first?.tag_name !== expected) {
core.setFailed(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
`Use an rc/new version line or correct the release ordering before announcing the update.`,
);
}
-275
View File
@@ -1,275 +0,0 @@
name: Validate
on:
push:
# The branch commit is the release-gate authority. An annotated tag points
# to the same SHA and must not duplicate the browser validation jobs.
branches:
- '**'
pull_request:
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
concurrency:
group: validate-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate public documentation
run: node scripts/check-docs.mjs --external
provenance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Validate commit trailers and hook mode
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev
# без PR — GitHub на своей стороне не блокирует ничего. Это последнее место,
# где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и
# не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим.
process-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22 }
- name: Process gate
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
DEVELOPMENT_BRANCH: dev
TARGET_REF: ${{ github.ref }}
# Публичный репозиторий: штатного токена хватает на чтение issue.
GH_TOKEN: ${{ github.token }}
run: |
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
node scripts/process-gate.mjs --github-range --issues
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/dev" ]; then
echo "dev: без фильтров, всё true"
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
exit 0
fi
zero=$(printf '%040d' 0)
base="$BEFORE_SHA"
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
# иначе классифицировалась бы вся история.
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|| ! git cat-file -e "$base" 2>/dev/null; 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/|pytest\.ini$)')"
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
} >> "$GITHUB_OUTPUT"
hacs:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: HACS validation
uses: hacs/action@main
with:
category: integration
hassfest:
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Build
run: npm run build
- name: Card bundle snapshots in sync
run: |
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
smoke:
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromium
- name: Build a fresh bundle for the smokes
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Smoke suite
run: |
fail=0
mkdir -p /tmp/smoke-logs
for f in demo/smoke_*.mjs; do
name=$(basename "$f" .mjs)
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
echo "ok $name"
else
echo "FAIL $name"
tail -20 "/tmp/smoke-logs/$name.log"
fail=1
fi
done
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: smoke-logs
path: /tmp/smoke-logs
golden:
# Deterministic visual correctness stays in every prerelease gate: it is
# inexpensive and catches a different class of regressions than timings.
needs: frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
- name: Build the exact source under review
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- name: Capture or verify golden matrix
id: golden
run: |
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
npm run golden:verify
else
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
npm run golden:capture
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v7
with:
name: golden-images
path: artifacts/golden
performance_smoke:
# Candidate-only catastrophic-regression guard for ordinary pushes and
# prereleases. The expensive same-runner comparison lives in performance.yml.
needs: frontend
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Install pinned Chromium
run: npx playwright install --with-deps chromium
- name: Build the exact candidate source
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
- 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
- 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
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v7
with:
name: performance-smoke
path: artifacts/performance-smoke
backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v7
with: { node-version: 22 }
- uses: actions/setup-python@v7
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
-9
View File
@@ -1,9 +0,0 @@
node_modules/
tsout/
test-build/
*.log
__pycache__/
.pytest_cache/
.venv-backend/
artifacts/
.agents/
-416
View File
@@ -1,416 +0,0 @@
# AGENTS.md
House Plan is one HACS package with two parts plus a demo harness:
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
## Read this first
**`docs/SCOPE.md` before anything else.** It was fixed with the owner and states
its own authority: features are built, improved and accepted **only** if they
serve a job listed there. It carries the mission, the three personas, the core
user jobs and the out-of-scope list.
Its central consequence: **View mode is the product for two of the three
personas.** Editors are admin-only tools and must never leak interactions into
View.
For work that changes visible behaviour, also read `docs/USER-GUIDE.ru.md` —
interface wording comes from there and is not invented, or the UI starts speaking
developer.
Then `PROCESS.md` (the full process), `docs/STATUS.md` (where the release line
is), and for non-trivial changes `docs/ARCHITECTURE.md` plus the canonical
document of the subsystem you touch: `SUN.md`, `LIGHT.md`, `CANVAS.md`,
`WALL-THICKNESS.md`, `UX-MODES.md`, `CONFIG-COMPATIBILITY.md`,
`TOUCH-SUPPORT.md`.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and
`docs/DEVELOPMENT.md`.
## Canonical backlog and status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical
task records: problem, scope, acceptance criteria and discussion.
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. Labels are the whole of it: GitHub Projects is no longer used.
Two shortcuts exist for small work. `small` — the light track: the spec lives in
the issue body and its review is a comment. `trivial` — the short track: no spec
stage at all, `S2-analysis` straight to `S5-ready`, with the AC written into the
issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
`PROCESS.md` §5 and §5.1 hold the criteria.
An issue filed by an outsider is worked exactly like one of the owner's own, once
the owner has decided to take it. The check sits **at the entrance**, not on every
step: while an issue carries no status label it is outside the process and the
invariants do not apply to it; once a label is on, the task is in flight and **who
filed it stops mattering**.
Applying that first label *is* the owner's explicit decision, and the platform
already guarantees it — only someone with write access can label. The earlier rule
made outside reports be refiled as the owner's own issues, which turned out to be
work for nothing: on #123 the spec was already written by the time the guard
refused.
Specs, audits and ADRs may live under `docs/`, but must link to their issue and
must not become a parallel task list. When repository documentation disagrees with
Issues, the issue wins.
## Rule #1
> Changing product code without an issue is forbidden. Code changes only when the
> issue exists and sits in "Ready for development" or later.
Check before touching product code:
```
gh issue view <NN> --repo Matysh/houseplan-card --json number,state,labels
```
The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything
else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits.
Start with the spec?" is the correct answer, not a smaller patch.
## Change classes
| Class | Paths | Issue required |
|---|---|---|
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | never changes on its own |
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
configuration files this one omits — `package.json`, `package-lock.json`,
`pytest.ini`, `.gitignore`, `.gitattributes`, `.githooks/**` and the rest of
`.github/**` are class B. Where paths overlap, **D beats A**: the built bundle
lives inside `custom_components/houseplan/frontend/` and would otherwise read as
product source.
## Commits
Hooks install themselves: `package.json` runs `"prepare": "node
scripts/install-hooks.mjs"`, so `npm ci` sets `core.hooksPath` in every fresh
clone. Verify with `git config core.hooksPath` — expect `.githooks`.
Every non-merge commit carries **terminal** trailers:
```text
Issue: #123
User-Visible: yes
```
One `Issue:` line per issue if a commit closes several. `User-Visible: no` for
tests, refactors, tooling and documentation that does not change the product.
`User-Visible: yes` requires edits to **both** changelogs — `docs/CHANGELOG.md`
and `docs/CHANGELOG.ru.md` — in the same commit.
A commit touching `demo/golden/baselines/**` additionally requires:
```text
Release: v1.62.0-beta.9
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/<run-id>
```
Never invent a review link and never rewrite published history to satisfy
trailers. `.githooks/commit-msg` and the `provenance` CI job both run
`scripts/validate-commit-provenance.mjs`.
Branch: `issue/<NN>-slug`. Direct commits to `dev`, no PR — the owner's decision;
CI checks after the fact, and a violation is fixed with a follow-up commit, never
a force-push.
**Push after every task, not before a beta.** While work sits unpushed there is
nothing to review, and reviewing twenty tasks at once is not review. `dev` may hold
unreviewed code while a task is in flight; what matters is its state when the
reviewer says it is accepted.
**Standing permission: push `issue/<NN>-slug` without asking.** The reviewer runs
in CI and can only read what is on the remote — an unpushed spec or commit means
the review either stalls or judges the wrong tree. Pushing a task branch publishes
nothing to users and does not touch the integration branch, so it needs no command.
**Do not merge into `dev` by hand.** On a green code review the pipeline rebases
the task branch onto `dev`, pushes it, and only then sets `S8-merged` — the label
asserts the code is in `dev`, so the merge has to happen first or the label lies
in between.
If the rebase conflicts the pipeline says so in the issue and sends the task back
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
remaining work is the rebase. Resolve it, push the branch, re-apply
`S7-code-review`. The second review run is not a formality — after a rebase onto a
moved `dev` this is different code, and accepting it unchecked is how regressions
arrive. Cycles are counted per stage, so a code review spends its own budget.
Everything else still requires the owner's explicit command: pushing `main`,
creating tags, publishing betas and releases, closing issues.
## Working trees (#115)
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
branch, and twice in one hour a commit landed on someone else's task branch that
way. The layout is therefore fixed:
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
here; nobody else commits in it. Unfamiliar local changes belong to the author
or the owner — never reset or clean them away.
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
and publishes through the GitHub API; it makes no local commits at all, so it
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
pruned after use.
A worktree is only usable on the machine that created it: the `.git` file records
an absolute path in that machine's format. One created from a Linux sandbox is
dead on Windows and vice versa — create worktrees on the machine that will use
them, which for `hp-dev` means the owner's.
## Two-agent workflow
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
code and owns infrastructure and distribution. The owner rules on disputes, closes
issues and commands releases.
Author and reviewer are different models, which is what "a fresh session without
implementation context" means in practice. The reviewer never edits product code;
the author never grades their own work.
**Infrastructure-only work runs outside this flow.** CI, scripts, labels, demo
stands, the landing page and distribution are Claude's alone, and running them
through spec-writing and review buys nothing: the spec would restate what is
already unambiguous, and author and reviewer would be the same role. So no spec
file, no spec review, no code review, no walk through `S1`…`S8`.
The test for "infrastructure only" is mechanical: **not a single class A file** —
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
task that touches class A even once is not infrastructure and takes the full flow;
there is no such thing as "mostly infrastructure". The strictness is deliberate:
a loose reading would turn this into the route by which product changes skip
review.
What stays mandatory either way: an issue exists, both trailers are on every
commit, `typecheck`, `test` and `build` are green, and any non-obvious decision is
written down in the code or the issue rather than kept in someone's head.
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
**Having applied one of those labels, wait for the result instead of ending the
session.** Reporting "handed over for review" stops a conveyor that could have kept
moving on its own. An agent has no clock — it exists only during its own turn — so
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
the command timeout. Watch the **label**, not the comment: the label is the state,
the comment only explains it. Do not wait at all while `blocked` is set — the task
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
tell the owner: a failed run leaves the label where it was, forever.
What the new label means:
| Now reads | What happened | What you do |
|---|---|---|
| `S5-ready` | the spec is accepted | write the code |
| `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` |
| `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which |
| `S8-merged` | accepted and already in `dev` | nothing |
| `review-4` | the cycle limit is spent | stop, the owner decides |
**After a review run the label always changes.** If it did not, the run itself
failed rather than the work — say so to the owner instead of polling on.
**A failed pre-release gate does not send the issue back to review.** The
implementation loop runs only typecheck, unit and build; golden, browser smokes,
performance and the full HA harness run before a beta, which is after the code
review has passed and the issue sits in `S8-merged`. Some defects cannot surface
any earlier.
Fix it, re-run what failed, and a green run is enough for the release to continue.
The issue stays in `S8-merged`. Record the **exact command and its result** in the
issue — "verified" without a command proves nothing. Trailers as usual, and
`User-Visible: yes` still means both changelogs in the same commit.
The exception covers repairing the defect the gate named, not carrying on
development under the name of a repair. It goes through the normal flow — a new
issue, or back to `S6-in-progress` — if the fix changes a behaviour contract, gives
the user something new, reaches a subsystem the task never touched, or is
comparable in size to the task itself. And editing the gate so it stops failing is
concealment, not repair; the exception is a defect proven to be **in the fixture**,
as on #89, where the sun sat at azimuth 180° and the only window faced north, so no
ray was ever built.
Baselines are still accepted only via `npm run golden:accept -- --reviewed` on a
complete Linux CI artefact. "So the gate goes green" is not a reason.
The exchange happens in **issue comments** — there is no local message bus. Verdict
format:
```text
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
```
High blocks. Medium must become its own issue. Low is fixed or waived with a note
in the review document. A yellow verdict is legitimate even when every acceptance
criterion passes, if the change does not solve the stated scenario or degrades a
neighbouring one.
**Four review cycles** (two on the light track). The counter lives in the document
name, `-r1`…`-r4`; the fourth adds the `review-4` label. There is no fifth attempt:
the owner splits the task, rejects it, or arbitrates.
On the light track (`small`: complexity ≤3, one surface, no config migration, no
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
body and the spec review is a comment. Code review is never skipped.
## Specs
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
on which surface, at what moment; and what the person sees before and after, in one
sentence without implementation terms.
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
fact is the worst kind of defect: it passes review because it looks like a decision.
The owner answers exactly two kinds of question: **what a person sees or does**, and
**how much user-visible change belongs in this issue**. Behaviour in a boundary case,
which persona wins when two conflict, what counts as acceptable degradation, whether
a neighbouring behaviour is in scope here or becomes its own issue.
Everything a user cannot observe is yours to settle: where state is stored, which
module carries the guard, naming, file layout, test strategy, migration mechanics,
development policy. Decide it, record it in an explicit "assumed, change freely"
block, and let the reviewer challenge it. A technical disagreement between author and
reviewer is settled by the verdict, not by the owner; it reaches him only when the
cycle limit is exhausted.
Split a mixed question instead of escalating all of it. "Where does this state live"
is technical. "Does it survive a page reload and follow the plan across screens" is
product. Ask the second, decide the first.
Ask in one batched issue comment, each question carrying a proposed default, and put
`blocked` on top of `S3-spec` while waiting. A question with a default costs the
owner seconds; one without costs him minutes.
## Gates
```
npm run typecheck
npm test
npm run build
npm run inventory # the only correct way to get test counts
```
Never copy test counts into documents by hand; they go stale in days.
After building, keep all three bundle snapshots in sync — CI compares them
byte-for-byte:
```
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
```
During the implementation cycle the fast gates always run. Since 2026-08-14 the
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
environment, which changes one thing (#151): **before moving an issue to
`S7-code-review`, run the smokes named in its AC locally** — `node
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
whole review round that a local run would have caught immediately.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
Locally only the pure subset runs; `python -m pytest tests_backend/ -q` without
Home Assistant **silently skips** `test_ha_*.py` (`conftest.py` ignores them when
`homeassistant` is not importable), so a green result proves nothing. Say so in the
report instead of claiming the backend was verified. Cloud agents have the harness
at `.venv-backend/bin/python`.
**Running the app / smoke suite**: build a fresh bundle and copy it into the demo
assets first, then run `node demo/smoke_*.mjs`. No real Home Assistant server is
required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
**Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a
stale demo bundle. Build and copy first, then review `artifacts/golden/actual/` and
`diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the
complete Linux CI artifact; never accept a partial scenario or images merely to make
CI green. See `demo/golden/README.md`.
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
TypeScript and package-lock build inputs. Benchmark and golden tooling must call
`assertFreshDemoBundle` before recording any result; a missing or mismatched
fingerprint is a hard failure, not a warning.
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
success` run for the candidate's SHA, not "the last green one"; a new push cancels
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
**"Verified" without a named command and its result is not evidence.**
## Environments
**Local Windows checkout** is the day-to-day environment: Node 22 as in CI, Python
3.13 in a venv, `gh` authenticated. `.venv-backend` does **not** exist there — it is
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
Playwright Chromium.
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
reproduces against the pristine committed bundle, so treat it as
pre-existing/pixel-precision, not a regression you introduced.
## Labs flags
`src/labs.ts` is the single registry and resolver for hidden presentation
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
YAML/config switch for a Labs-only experiment. A new entry needs a unique
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
unit/browser coverage. Invalid or duplicate registry entries fail closed.
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
experiment or graduate it through its own reviewed issue; never extend expiry as
an incidental change. Labs may alter presentation only and must not gate data,
migrations, stores, HA actions or network calls. Current renderer details are in
`docs/ISOMETRIC.md`.
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
page directly in a browser renders the floor plan but **device icons only appear
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does
not. This is a harness limitation, not a card bug.
## Promotion rule
Every new feature or material behaviour change must be published as a beta/RC
before it can enter a stable release, even when its local audit is clean. The
stable release commit is promotion-only: version fields, generated bundle
snapshots and changelog/release metadata. Do not add feature source code in
that commit. An explicit owner-requested emergency hotfix is the only exception
and must be called out in the release handoff.
A `Release vX.Y.Z-beta.N candidate` commit is **not** promotion-only: it carries
the work itself and follows the ordinary rules, trailers included.
Issues are closed in a batch when a beta ships, not when implementation ends: that
way a bug found in the beta returns to the same task, and the beta announcement can
list what went in. Status labels are stripped as the issues close.
-63
View File
@@ -1,63 +0,0 @@
# Contributing to House Plan
Thanks for your interest! The project is one HACS package: a storage **integration**
(`custom_components/houseplan/`, Python) and a **Lovelace card** (`src/`, TypeScript + Lit).
## Changelog
User-visible changes go into **both** changelogs in the same commit:
`docs/CHANGELOG.md` (English) and `docs/CHANGELOG.ru.md` (Russian). Entries
older than v1.42.0 exist only in the English file — no need to backfill them.
## Where to ask
Not sure whether something is a bug, or just want to discuss an idea before
writing code? The **[Telegram chat @ha_houseplan](https://t.me/ha_houseplan)**
is the quickest route to the author and other users. Bugs and concrete feature
requests still belong in [issues](https://github.com/Matysh/houseplan-card/issues).
## Backlog and work status
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
active backlog. An issue owns scope and acceptance criteria; its **labels** own
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
starting planned work, link it to an existing issue or create one, and keep it
current until the verified result is closed. Design specs and ADRs may support an
issue, but they do not replace it or maintain a separate checklist.
## Five-minute setup
```bash
git clone https://github.com/Matysh/houseplan-card && cd houseplan-card
npm ci # frontend toolchain
npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
npm run build # tsc + rollup → dist/houseplan-card.js
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
npm install # also installs .githooks through the prepare script
```
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
every push — locally they are skipped when `homeassistant` is not importable.
## Ground rules
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
- Every UI string goes through `src/i18n/<lang>.json` (tests enforce en/ru key parity).
Adding a language = adding one JSON file + registering it in `src/i18n.ts`.
- The built card must be committed in sync: `cp dist/houseplan-card.js
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
- Tap actions have a security model (locks/alarms never toggle from the plan) —
see `resolveToggleIntent` in `src/device-toggle.ts`; don't weaken it.
- Every commit follows the issue and trailer contract in `PROCESS.md`.
- Follow the Integration Quality Scale where applicable —
`custom_components/houseplan/quality_scale.yaml` tracks the self-assessment.
## Architecture
Start with `docs/ARCHITECTURE.md` (data model, WS API, coordinate system) and
`docs/STATUS.md` (current state). Release: bump the version in `package.json`,
`manifest.json`, `const.py`, `CARD_VERSION`, tag `vX.Y.Z`, publish a GitHub release —
the workflow attaches the card bundle.
+7
View File
@@ -0,0 +1,7 @@
{
"schema": 1,
"source": "674e589ad841c6580f31d1a495bb207f13e6931e",
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
"files": 30,
"builtAt": "2026-09-29T06:30:42.564Z"
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 JB (justbusiness)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-884
View File
@@ -1,884 +0,0 @@
# Процесс работы над House Plan
> **Статус документа: канон** (редакция 2026-08-13). Решения владельца, на
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
> имена английские · лёгкий трек **включён** · автор и ревьюер — разные модели ·
> инфраструктурные задачи идут **вне** флоу.
>
> **Область действия:** обязателен для владельца и для любого агента. Читается
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
> его не содержал вовсе.
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
> заводится issue с меткой `process`.
>
> При расхождении процесса и привычки побеждает процесс.
---
## 1. Основное правило
**Изменение продуктового кода без issue запрещено.** Код меняется только тогда,
когда issue существует и находится в статусе «Готово к разработке» или дальше.
Исключения — только §11, и каждое оставляет след.
Правило работает лишь при точной границе «продуктового кода», иначе спор
переносится на границу:
| Класс | Что входит | Нужен ли issue |
|---|---|---|
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
бандл» перестают быть лазейками.
Классы неупорядочены, но при пересечении путей **D сильнее A**: собранный бандл
лежит внутри `custom_components/houseplan/frontend/`, и без этого правила он
считался бы продуктовым исходником.
**Инфраструктурная задача идёт вне флоу** (решение владельца 2026-08-13,
issue #118). Признак механический: **ни одного файла класса A**. Такая задача
делается без ТЗ, ревью ТЗ, код-ревью и без прохода по статусам — флоу построен
для изменений, у которых есть персона и видимое поведение, а в инфраструктуре ТЗ
пересказывало бы очевидное, и автор с ревьюером оказались бы одной ролью.
Проверкой служат гейты и CI. Обязательным остаётся issue, трейлеры и зелёные
`typecheck`, `test`, `build`.
Задача, задевающая класс A хотя бы одним файлом, инфраструктурной **не
является** и идёт полным флоу. «В основном инфраструктурная» не бывает: иначе
это дорога, по которой продуктовые правки минуют ревью. Признак задан через
класс файлов, а не через самоощущение исполнителя, именно поэтому.
---
## 2. Жизненный цикл
Восемь рабочих статусов и два служебных. Фазы тестирования в цикле сознательно
**нет**: найденные позже дефекты заводятся отдельными issue и проходят цикл
заново. Issue закрывается после выпуска беты.
```
S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
порождает событие, событие запускает ревью (§10.4). Остальные ставит исполнитель.
### 2.1 Новое — заведение задачи
- **Кто:** любой — владелец, агент, пользователь (Telegram, GitHub).
- **Вход:** проблема в пользовательских терминах; как проявляется или зачем нужно.
Решение **не требуется** и не приветствуется.
- **Запрещено:** ставить приоритет, оценивать, писать ТЗ, начинать код.
### 2.2 Аналитика и оценка
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
однозначно.
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
- **Чек-лист**, результат — комментарием в issue:
1. дубликаты проверены (ссылки на похожие issue);
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
3. **пользовательская ценность 1–10** и **ценность для разработки** — что
упрощает или разблокирует;
4. **сложность и риск 1–10** — трудоёмкость плюс вероятность задеть смежное;
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
владельцем на любой стадии.
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
него в ТЗ пишется блок принятых предположений.
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
это единственный случай, когда аналитика останавливается и ждёт владельца.
### 2.3 ТЗ в работе — написание ТЗ
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
- **Выход:** полная первая редакция по §7.
### 2.4 ТЗ на ревью
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
4 циклов (§4).
### 2.5 Готово к разработке (DoR)
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
Все пункты обязательны:
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
- перечислены затронутые файлы и модули;
- i18n: ключи en + ru перечислены;
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
- влияние на производительность и бюджеты названо (или явно «нет»);
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
документация, golden/скриншоты, performance/security);
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
- открытых продуктовых вопросов нет; риски перечислены.
Если хоть один пункт не выполнен — статус не «Готово к разработке», как бы ни
хотелось начать.
### 2.6 В разработке — реализация
- **Занятие (claim):** назначить себя, поставить метку, комментарий
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры
`Issue: #<NN>` и `User-Visible: yes|no`.
- **Автотесты — часть реализации, а не отдельная фаза.** Каждый AC, помеченный
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
тестирования, а не отсутствие тестов.
- **Скоуп не расширяется.** Найденное по пути становится новым issue в «Новое».
Если находка блокирует — текущий issue уходит в «Заблокировано» со ссылкой.
Попутных правок «раз уж я здесь» не бывает.
- **Документация — в том же коммите,** что и поведение (действующая политика
`docs/STATUS.md`): changelog RU+EN для пользовательского, `STATUS.md` для
состояния, `DEVELOPMENT.md` для новых грабель, `ARCHITECTURE.md` для дизайна.
- **Выход:** локальный гейт зелёный (§8), хендофф-комментарий (§7.2).
### 2.7 Код-ревью
- **Ревьюер ≠ исполнитель**, свежая сессия без контекста реализации.
- **Артефакт:** `docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md` в действующем
формате: скоуп, как проверялось (таблица гейтов с результатами), находки
High/Medium/Low с воспроизведением, что проверено и корректно, чего не проверял.
- **Ревьюер отвечает за AC.** Раз ручного тестирования в цикле нет, именно ревью
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **High блокируют.** Medium **обязаны** превратиться в issue.
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
4 циклов (§4).
### 2.8 Закрытие после выпуска беты
- **Вход:** изменение вошло в опубликованную бету/RC, CI Validate зелёный на
**точном SHA** тега (промоушен-правило: ни одна фича не попадает в стабильный
релиз, не побывав в бете).
- **Закрывает** релиз-менеджер, не исполнитель. Комментарий закрытия: тег беты,
ссылка на прогон CI, ссылка на бюллетень changelog.
- **Стабильный релиз статусов не двигает** — issue уже закрыты; релизный коммит
promotion-only, changelog ссылается на закрытые issue.
- **Что приходит потом:** дефект, найденный на стенде, дома или пользователем, —
**новый issue** типа «баг» со ссылкой на исходный. Исходный не переоткрывается.
### 2.9 Заблокировано / Отклонено
- **Заблокировано:** обязательна ссылка на блокирующий issue или внешнюю причину
и дата пересмотра. Без причины статус не ставится.
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
оправдана). Тихое закрытие без причины запрещено.
---
## 3. Правила
Продолжение черновика владельца. Каждое правило проверяемо — глазами или машиной.
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
разработке» или дальше.
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
пронумерованных AC с указанием доказательства и назначенного исполнителя.
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
комментарием с именем ветки. У одного исполнителя одновременно не более одного
issue в разработке.
4. **Статус меняется до действия, а не после.** Взял — поставил метку; отдал на
ревью — поставил метку. Метка, поставленная задним числом, — дефект процесса.
5. **Ровно одна метка статуса** на issue в любой момент. Ноль или две — дефект,
еженедельная гигиена его показывает.
6. **Автор не ревьюит своё** — ни ТЗ, ни код. Никто не переводит свою работу через
ревью-гейт.
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
отклонить или арбитраж (§4).
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
решением ревьюера с записью в документе.
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
том же коммите.
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
коммитом документация не бывает.
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
принятие эталонов со ссылкой на прогон CI.
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
полному Linux-артефакту. Принятие ради зелёного CI — нарушение процесса.
14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не
раньше, не «по факту наличия кода», не исполнителем.
15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой.
16. **Стабильный релиз — promotion-only:** версии, сгенерированные бандлы,
changelog и release-метаданные. Продуктового кода там нет.
17. **История `dev` не перезаписывается.** На неё ссылаются теги. Нарушение
исправляется следующим коммитом плюс issue с меткой `process` — не
force-push'ем.
18. **AC доказывает автотест или запись ревьюера.** Фразы «проверил локально, всё
работает» в процессе не существует: либо тест, который умеет падать, либо
честное «проверено чтением, не исполнением».
19. **Параллельных бэклогов нет.** Планы, разборы и приоритеты живут в issue;
файловые отчёты — разовые и датированные.
20. **Аварийный хотфикс — только решением владельца** и только по §11.2.
---
## 4. Лимит циклов ревью: 4
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
решение одно из трёх:
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
ясным скоупом (частый настоящий диагноз: ТЗ было слишком большим);
2. **отклонить** — цена решения оказалась выше ценности;
3. **арбитраж владельца** — владелец фиксирует решение в issue, оно принимается
как есть; несогласие ревьюера записывается, но не блокирует.
- **Граница между «циклом» и «новым багом»:** до закрытия беты находка ревьюера —
возврат на правки; после закрытия — новый issue. Иначе лимит 4 обходится
заведением issue вместо возврата.
- Для лёгкого трека лимит ревью ТЗ — **2** цикла: задача на три часа, которую
переписывают трижды, лёгкой не была.
---
## 5. Лёгкий трек (метка `small`)
**Критерии — все одновременно:**
- сложность и риск ≤ 3;
- одна поверхность (один диалог, один модуль, один эндпоинт);
- нет миграции конфига и новых compatibility-полей;
- нет нового UX-контракта — меняется поведение в рамках уже описанного;
- нет влияния на производительность и на touch-контракт.
**Что упрощается:**
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
доказательством · откат. Файл в `docs/specs/` не создаётся;
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
- лимит ревью ТЗ — 2 цикла.
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается
никогда — именно оно в этом процессе заменяет тестирование. Единственное
исключение — починка упавшего предрелизного гейта, §11.4.
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
### 5.1 Короткий трек (метка `trivial`)
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
обходится без него совсем.
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
**Критерии, все обязательны:**
- тип `bug`;
- правка ограничена одной поверхностью, нового UX-контракта нет;
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
- AC выражаются тремя проверяемыми утверждениями или меньше;
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
где владелец утверждает и приоритет.
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
Лимит циклов код-ревью — 2, как на лёгком треке.
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
диагностика.
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
---
## 6. Роли
Один агент может исполнять несколько ролей в разных issue, но **не две роли в
одном артефакте**.
| Роль | Делает | Не имеет права |
|---|---|---|
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
| Релиз-менеджер | пре-релиз, стабильный релиз, закрытие issue | добавлять код в релизный коммит |
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
**Правило разделения:** ревьюер работает состязательно. Ему передаётся тег или
диапазон коммитов и ТЗ — не рассказ автора о том, как всё хорошо.
**Роли закреплены за исполнителями** (решение владельца 2026-08-12):
| Исполнитель | Роли |
|---|---|
| **Codex** | аналитик, автор ТЗ, разработчик, релиз-инженер по команде владельца |
| **Claude** | ревьюер ТЗ, ревьюер кода, вся инфраструктура и дистрибуция |
| **Владелец** | приоритет, скоуп, арбитраж, закрытие issue, команда на выпуск |
Автор и ревьюер — **разные модели**, и это сильнее требования «другая сессия»:
одна модель, читая свой же артефакт заново, повторяет свои же слепые пятна.
Ревью ТЗ и код-ревью держатся в **разных сессиях** Claude: ревьюер кода не должен
приходить с контекстом того, как обсуждали ТЗ.
---
## 7. Артефакты и трассируемость
### 7.1 Цепочка
```
issue #NN
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
↔ ветка issue/NN-slug
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
↔ ревью кода docs/reviews/CODE-REVIEW-<tag|NN>-rN.md
↔ changelog бюллетень RU+EN со ссылкой на #NN
↔ бета тег, зелёный CI на точном SHA → закрытие
```
Обязательные разделы ТЗ: **сценарий** · **что человек увидит до и после** ·
проблема · скоуп и **не-скоуп** · контракт поведения · UX · модель данных и
миграция · i18n · критерии приёмки AC1…ACn с указанием доказательства · план
автотестов · риски · откат · release-артефакты.
Два первых раздела — продуктовые, и они идут первыми не случайно. **Сценарий:**
какая персона (`docs/SCOPE.md`), на какой поверхности, в какой момент это
встретит. **Что человек увидит:** одной фразой, без терминов реализации. ТЗ,
которое не может ответить на эти два вопроса, описывает работу, а не изменение
продукта.
**Размытое место не додумывается, а выносится владельцу.** Догадка, записанная
как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением.
Но спрашивать обо всём нельзя: владелец один, и анкета из двадцати пунктов хуже
угадывания. Порог такой (решение владельца 2026-08-13).
**Владельцу задаются только продуктовые вопросы** — что человек видит или делает
и какой объём видимых изменений входит в этот issue. Поведение в пограничном
случае; какая из персон важнее в конфликте; что считать приемлемой деградацией;
относится ли смежное поведение сюда или становится отдельной задачей.
**Всё, чего пользователь не наблюдает, агенты решают сами** либо согласовывают
между собой: где хранится состояние, в каком модуле стоит гвард, именование,
раскладка файлов, стратегия тестов, механика миграции. Решение записывается явным
блоком в конце ТЗ — «принято предположительно, поменять свободно», и ревьюер
вправе его оспорить. Технический спор автора и ревьюера решается вердиктом, а не
владельцем; до него он доходит только при исчерпании лимита циклов (§4).
**Смешанный вопрос делится, а не эскалируется целиком.** «Где живёт это
состояние» — техническое. «Переживает ли оно перезагрузку страницы и общее ли оно
для всех экранов» — продуктовое.
Вопросы задаются **одним комментарием, пачкой**, каждый в форме: что неясно ·
что изменится от ответа · **предлагаемый вариант по умолчанию**. Вопрос с готовым
вариантом стоит владельцу пяти секунд, вопрос без него — пяти минут. Пока ждём
ответа, issue остаётся в `S3-spec` и получает `blocked`: статус не подменяется,
`blocked` его дополняет, иначе конвейер считает задачу в работе, а она стоит.
### 7.2 Шаблоны комментариев
Короткие и однообразные, чтобы читались и человеком, и машиной.
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
High: N · Medium: N → #… · Документ: docs/reviews/…`
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
разница между ними содержательна для человека, но не для маршрута. Первая
редакция конвейера (§10.4) пропускала жёлтый при `High: 0`, и первый же живой
прогон показал, почему это неверно: жёлтый там означал, что AC описывает неверное
изменение контракта — реализовать такое ТЗ значило бы сделать ошибку по инструкции.
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
таблицу «issue ↔ ТЗ».
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
ревью кладёт в `docs/reviews/` сам конвейер, в ветку задачи.
---
## 8. Гейты
**Локальный гейт перед выходом из «В разработке»** — минимальный набор,
покрывающий изменённые поверхности (действующее правило владельца):
```
npx tsc --noEmit
npm test
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
node demo/smoke_<целевые>.mjs
npm run golden:verify # если менялся визуал
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
```
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
результата, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
пропуском.
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
Упавший предрелизный гейт автор чинит и повторно прогоняет; зелёный прогон
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA; статусов issue не касается.
---
## 9. Метки — канонический статус
Статус читается из меток: их видно в списке issue, их читает любой токен с
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
представление статуса рядом с метками требовало отдельного скоупа токена,
синхронизации и внимания, а давало вид доски. Два источника одного факта
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
документе были только на бумаге; репозиторий с самого начала жил на английских.
| Метка | Статус |
|---|---|
| `S1-new` | Новое, не разобрано |
| `S2-analysis` | Аналитика и оценка |
| `S3-spec` | ТЗ в работе |
| `S4-spec-review` | ТЗ на ревью |
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код |
| `S6-in-progress` | В разработке, занято исполнителем |
| `S7-code-review` | Код-ревью |
| `S8-merged` | Ревью пройдено, код в `dev`, ждёт беты. Issue закрывается пачкой при выпуске |
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
Инварианты: **ровно одна `S*`-метка** на открытом issue; закрытый issue статусных
меток не несёт; `blocked` не заменяет статус, а дополняет его.
**Чужой issue берётся в работу так же, как свой — после явного решения
владельца** (решение владельца 2026-08-13, уточнено в тот же день). Репозиторий
публичный, отчёты заводят и посторонние; проверка стоит **на входе**, а не на
каждом шаге.
Входом служит присвоение первой статусной метки: пока меток нет, issue вне
процесса и инварианты на него не распространяются. Как только метка стоит, задача
в работе, и **кто её завёл, дальше не имеет значения** — статусы, ревью и лимиты
работают одинаково.
Присвоение метки и есть то самое явное решение, причём проверенное платформой:
метки может ставить только тот, у кого есть право записи в репозиторий. Прежняя
редакция требовала переоформлять чужой отчёт своим issue со ссылкой на исходный;
это оказалось работой впустую — на #123 к моменту отказа ТЗ уже было написано.
`S8-merged` появился позже остальных и закрывает разрыв, который раньше
закрывался памятью человека: код принят, но бета ещё не вышла, и issue закрывать
рано. Без него принятая задача либо висела в `S7-code-review`, либо закрывалась
досрочно.
---
## 10. Механизация при прямых коммитах в `dev`
Решение владельца — работать без PR. Значит, GitHub не может ничего заблокировать
на своей стороне: **основной гейт переезжает на клиента, CI остаётся страховкой.**
### 10.1 Хуки, которые невозможно забыть поставить
`.githooks/` в репозитории, `core.hooksPath` выставляется автоматически при
установке зависимостей:
```json
"scripts": { "prepare": "node scripts/install-hooks.mjs" }
```
`npm ci` вызывает `prepare` сам — значит, хуки появляются в каждом окружении,
включая свежий контейнер облачного агента, без отдельного шага в инструкции.
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
трогающих `demo/golden/baselines/**`, — `Release:` плюс `Baseline-Reviewed:`.
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
job `provenance` в `validate.yml`.
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
пушимому ref и останавливает push при нарушении. Это и есть блокирующий гейт
вместо PR. Удаление ветки и теги пропускаются: в первом случае проверять нечего,
во втором коммит уже проверен, когда его пушили. Для новой ветки диапазон
считается от `merge-base` с `origin/dev`, а не от начала истории — иначе в него
попали бы все нарушения, совершённые до появления гейта.
При возврате `main` в `dev` диапазон merge-коммита содержит второй родитель —
уже опубликованные в `main` коммиты с закрытыми issue. Для destination `dev`
общий скрипт pre-push/CI исключает только SHA, доказанно достижимые из
`origin/main`; сам merge и новые post-merge коммиты остаются под всеми
проверками. На `main`, beta/issue-ветки и обычный push в `dev` это исключение
не распространяется (issue #155).
Проверка статуса issue требует `gh`, поэтому при его отсутствии хук печатает
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
который не работает в самолёте, отключают целиком, а строгий проход всё равно
делает CI.
**Хук обязан быть исполняемым, и это тише всего ломается.** Git **молча** не
запускает файл без бита `+x`: гейт сообщает об успехе тем, что его нет. Проверено
на настоящем push — при `644` от гейта ноль строк и push проходит, при `755` он
останавливается.
Через GitHub API режим не выставляется: файл, отправленный так, приезжает
`100644`. Поэтому `scripts/install-hooks.mjs` восстанавливает бит при каждой
установке зависимостей, а `assertHookMode` дополнительно проверяет бит
`.githooks/commit-msg` в индексе. Правится вручную:
`git update-index --chmod=+x .githooks/<хук>`.
### 10.2 Что проверяет `process-gate.mjs`
Реализовано, `scripts/process-gate.mjs`, issue #105. Офлайн, без GitHub API:
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
2. имя ветки `issue/NN-slug` соответствует трейлерам;
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
`Baseline-Reviewed: <ссылка на прогон CI>`;
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
С токеном GitHub:
8. `--issues` тянет каждый упомянутый issue и требует метку из
{`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`}; закрытый,
недоступный или помеченный `blocked` — отказ (**fail closed**).
Три оговорки к проверке 8 выяснились при реализации.
**`S8-merged` входит в множество**, хотя по смыслу задача уже принята. Причина
механическая: конвейер (§10.4) сливает ветку в `dev` **раньше**, чем ставит метку,
Validate стартует от этого push и успевает прочитать issue уже в `S8-merged`.
Строгое множество красило бы каждую принятую задачу. Локальная строгость
возвращается флагом `--no-merged`.
**Статус спрашивается только у коммитов класса A/B.** Правило №1 говорит о
продуктовом коде и инструментах, а не о документации. Иначе краснел бы каждый
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
`S7-code-review`, то есть заведомо вне рабочего множества.
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
Pre-push передаёт целевую remote ref через `--target-ref`, а Validate — через
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
зелёный вердикт код-ревью;
10. закрытие issue и снятие статусных меток при публикации беты делаются руками —
`node process-labels/apply.mjs cleanup --apply`, а не `publish-prerelease.yml`.
Пропуск этого шага уже ломал инвариант «закрытый issue без статусной метки».
### 10.3 Страховка и разбор
- **`process-gate.mjs` — job `process-gate` в `validate.yml`**, без `needs`:
краснеет сам и не роняет остальные. При прямом push проверка догоняющая: код уже
в `dev`, CI краснеет после. Это принятая цена отказа от PR: `pre-push` ловит
нарушение до отправки, а этот job — то, что прошло мимо хука, включая
`--no-verify` и окружение без установленных зависимостей.
- **Нарушение не откатывается force-push'ем** (правило 17): исправляющий коммит
плюс issue с меткой `process`. Починить надо проверку, а не только симптом.
- **Еженедельная гигиена** (workflow): issue в `S1-new` дольше 14 дней и в
`S6-in-progress` дольше 7; issue класса A в `S5-ready` без ТЗ; issue с нулём или
двумя `S*`-метками; коммиты без трейлера за неделю — **цель 0**; rework rate и
число issue, дошедших до `review-4`; **баги, заведённые после закрытия беты** —
прямая цена отказа от фазы тестирования.
### 10.4 Событийный конвейер: метка как триггер
`.github/workflows/process.yml`, issue #114. Смена статусной метки — не запись в
журнал, а **сообщение**: она порождает событие, событие запускает следующий шаг.
```
S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-spec
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
```
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
вердикту, а не модель.**
Четыре вещи, без которых конвейер молча не работает:
1. метки переставляет **PAT**, а не `GITHUB_TOKEN`: GitHub намеренно не порождает
события от `GITHUB_TOKEN`, чтобы не было циклов, и цепочка обрывалась бы после
первого шага без ошибок в логах;
2. `process.yml` обязан лежать в **ветке по умолчанию**: для события `issues`
GitHub берёт workflow только оттуда, независимо от содержимого `dev`;
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
промежутке — она утверждает, что код в `dev`;
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
секунд, не более 30 попыток. Смотреть на метку, а не на комментарий: метка и есть
состояние. При `blocked` не ждать — задача ждёт владельца.
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
автор ждёт смену метки, метка не менялась, и он тридцать раз опрашивал впустую,
чтобы отчитаться «лимит исчерпан» при зелёном вердикте. Состояние, из которого
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
ошибки.
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
метка `S7-code-review` возвращается и ревью идёт заново — не формальность:
после ребейза на ушедший вперёд `dev` это другой код.
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
сообщать владельцу, а не продолжать опрос.
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
считались все вердикты подряд, и первое код-ревью #89 получило `r2/4`.
---
## 11. Исключения
### 11.1 Лёгкий трек
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
Разрешено писать код до появления issue. Обязательно:
- issue создан в **той же сессии до коммита**, метка `hotfix`;
- ТЗ «как сделано» + раздел «почему нельзя было ждать»;
- в течение 24 часов задача ретроспективно проходит код-ревью;
- аварийность названа явно в релизном хендоффе (действующее правило `AGENTS.md`).
### 11.3 Гигиена репозитория
Механические изменения без изменения поведения (форматирование, мёртвые файлы)
идут под квартальный umbrella-issue «Гигиена репозитория»; каждый коммит
ссылается на него. Трассируемость 1:1 сохраняется.
### 11.4 Починка предрелизных гейтов без повторного код-ревью
Решение владельца 2026-08-13.
В цикле реализации гоняется только лёгкий набор — typecheck, unit, build (§8).
Golden, браузерные смоки, performance и полный HA-харнесс запускаются перед бетой,
то есть **после** того, как код-ревью пройдено и issue в `S8-merged`. Часть
проблем физически не может быть найдена раньше.
**Если предрелизный гейт упал, автор правит, повторно прогоняет упавшее, и
зелёного прогона достаточно, чтобы релиз продолжился.** Issue остаётся в
`S8-merged` и на повторное код-ревью не отправляется.
Причина: полный цикл ревью в момент выпуска стоит дороже, чем риск, который он
здесь снимает. Гейт уже назвал дефект точно, а исправление проверяется тем же
гейтом — то есть проверка объективна и не зависит от чьего-либо суждения.
**Что при этом обязательно:**
- прогон упавшего гейта записан в issue: **точная команда и её результат**.
«Verified» без команды доказательством не является (§8);
- трейлеры на коммите как обычно, `Issue: #NN` того же issue;
- при `User-Visible: yes` — правки в оба changelog в том же коммите;
- эталоны golden принимаются только через `npm run golden:accept -- --reviewed`
на полном артефакте Linux CI. «Чтобы гейт позеленел» основанием не является.
**Границы, за которыми исключение не действует.** Оно про починку названного
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
обычным путём — новым issue либо возвратом в `S6-in-progress` — если она:
- меняет контракт поведения или добавляет пользователю что-то новое;
- задевает подсистему, которой в исходной задаче не было;
- по объёму сопоставима с самой задачей;
- меняет сам гейт вместо кода — правка теста, чтобы он перестал падать, это не
починка, а сокрытие. Исключение — когда дефект **в фикстуре** и это доказано
разбором, как на #89: солнце на азимуте 180° и единственное окно на северной
стене, поэтому луч честно не строился.
Границу определяет автор, и здесь процесс сознательно отдаёт ему то, что в
остальных местах не доверяет — оценку собственной работы. Плата за скорость в
единственной точке, где цикл ревью стоит дороже всего. Компенсируется тем, что
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
выпуском.
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
единственное, и относится только к окну между `S8-merged` и выпуском.
---
## 12. Запрещено
- код без issue или из статуса раньше «Готово к разработке»;
- ТЗ, написанное после кода (кроме §11.2, и тогда с пометкой «как сделано»);
- ревью своей работы; перевод своей работы через ревью-гейт;
- пятый цикл ревью вместо разбора по §4;
- заведение issue вместо возврата на правки, чтобы обойти лимит циклов;
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
- закрытие issue до выпуска беты с зелёным CI;
- переоткрытие закрытого issue вместо нового бага;
- Medium-находки, оставленные как TODO в документе ревью;
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
- ревью-документы вне репозитория;
- попутные правки «раз уж я здесь»;
- фича или материальное изменение поведения в стабильном релиз-коммите;
- force-push в `dev`;
- ручное копирование на домашний инстанс.
**Нарушение процесса — тоже issue** (метка `process`): если правило удалось
нарушить незаметно, виновата проверка.
---
## 13. Внедрение
Состояние на 2026-08-13.
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
одна `S*`-метка, инварианты чистые.
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
код, которого уже нет.
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
4. ✅ **Долг ревью списан решением владельца.** Беты `beta.2`…`beta.10` сделаны по
прежнему процессу и не пересматриваются. Точка отсчёта — релиз 1.62.0; отсчёт
начинается с первой беты следующей линии.
5. ⏳ Завести issue на находку «смок `visual_continuity` не умеет падать» — это
ровно тот класс дефектов, который в процессе без ручного тестирования стоит
дороже всего.
6. ✅ `BACKLOG-2026-08-11.md` — разовый отчёт, решения живут в issue.
7. ✅ `AGENTS.md` переписан целиком, шире блока §14.
8. ✅ **Канон перенесён в репозиторий** (issue #112). До этого полный процесс жил
только в папке владельца, а в репозитории лежал файл на 51 строку про трейлеры
коммитов — из свежего клона канон не был виден вообще.
9. ✅ **`pre-push` написан** (§10.1, issue #121). Блокирующая проверка на клиенте
есть; обойти её можно только `--no-verify`, и тогда то же найдёт CI.
---
## 14. Блок для AGENTS.md
```markdown
## Процесс: код только через issue
Изменение продуктового кода без issue запрещено. Код меняется только из статуса
«Готово к разработке» или дальше. Полные правила, критерии статусов и гейты —
`docs/PROCESS.md`, читать до начала работы.
Жизненный цикл (статус = метка issue): `S1-new` → `S2-analysis` → `S3-spec` →
`S4-spec-review` → `S5-ready` → `S6-in-progress` → `S7-code-review` → `S8-merged`
→ закрытие пачкой при выпуске беты. Оба ревью возвращают на правки не более 4
циклов; пятый заход — разбор у владельца (разделить / отклонить / арбитраж).
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review` и идёт до
45 минут. Поставив такую метку, автор не заканчивает работу, а ждёт смены метки
опросом и продолжает по тому, чем она стала.
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
`pre-push` (ставится автоматически через `npm ci`), CI — страховка. Force-push
в `dev` запрещён;
- автор ≠ ревьюер, ни для ТЗ, ни для кода;
- фазы ручного тестирования нет: автотесты пишутся в реализации, AC проверяет
код-ревью, найденные позже дефекты — новые issue типа «баг»;
- мелкие задачи (метка `small`, сложность ≤3): ТЗ в теле issue, ревью ТЗ
комментарием, код-ревью — как обычно;
- найденное вне скоупа — новый issue, а не попутная правка;
- issue закрывает релиз-менеджер после выпуска беты, не исполнитель.
```
-157
View File
@@ -1,157 +0,0 @@
# 🏠 House Plan — a live home map for Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![Live demo](https://img.shields.io/badge/demo-try_it_live-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-chat-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
<!-- docs-section: overview -->
## Your whole home at a glance
House Plan turns Home Assistant into a live map of your home. Upload a plan or
draw rooms directly on the dashboard, bind them to Home Assistant areas, and
the area's devices appear automatically. You can immediately see where a light
is on, a door is open, a room is too cold, Zigbee signal is weak, or a leak
sensor has fired.
![Synthetic home in View mode with rooms, devices, light and climate](docs/images/01-view-desktop.png)
Setup is entirely graphical: no floor-plan YAML, Inkscape, or external editor.
Plan data and device positions live on the Home Assistant server and stay in
sync across screens.
> **Edit on a desktop computer.** View and kiosk are fully supported on phones
> and tablets. The editors are designed primarily for a mouse and keyboard;
> individual touch editing operations may be awkward or unavailable. See the
> exact [touch support contract](docs/TOUCH-SUPPORT.md).
<!-- docs-section: features -->
## What House Plan provides
- **Live state and safe actions.** Lights and other safe devices can toggle from
the plan; a lock cannot be opened by an accidental plan tap.
- **Three built-in editors.** Plan creates rooms, walls and openings; Device
places and configures markers; Background adds lines, labels and furniture.
- **Area-aware rooms.** New devices appear automatically, while room cards can
show temperature, humidity, light state and average LQI.
- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
backdrop and sunlight through windows.
- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
a robot can show its position, dock and travelled path.
- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
and a separate initial floor for each card.
- **Wall-display kiosk.** A plan-only view with fullscreen navigation and icon
sizes saved for that display.
![The same synthetic home in touch View mode](docs/images/02-view-touch.png)
<!-- docs-section: first-run -->
## Your first working room
1. Install the integration and add the card to a dashboard.
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
or choose no image and draw the plan by hand.
3. In Plan, select **Room outline**, place vertices, and click the first point to
close the outline.
4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
that has no devices.
5. Open Device: devices from the bound area are already placed; drag their
markers to the correct positions.
6. Optionally use Background for lines, text and furniture.
7. Return to View. The plan now displays live state and accepts safe actions.
![Creating the first space](docs/images/03-space-create.png)
![Closing a room outline on its first point](docs/images/04-room-contour-close.png)
![A selected partition and the Plan context tray](docs/images/05-plan-context-tray.png)
![Device settings with binding provenance and the exact action result](docs/images/06-device-editor.png)
![Live presentation preview for the same device](docs/images/06-device-display-preview.png)
Every workflow and edge case is in the [full user guide](docs/USER-GUIDE.md).
The [Background editor contract](docs/DECOR-EDITOR.md) and
[vacuum guide](docs/VACUUM.md) are the authorities for those subsystems.
<!-- docs-section: installation -->
## Installation
### HACS
[![Open the repository in HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. In HACS open **⋮ → Custom repositories**.
2. Add `https://github.com/Matysh/houseplan-card` as an **Integration**.
3. Install House Plan and restart Home Assistant.
4. Open **Settings → Devices & services → Add integration → House Plan**.
The card is registered automatically. If you manage Lovelace resources
manually, use the URL served by the integration:
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
Do not use the on-disk path inside `custom_components`; Home Assistant does not
serve that path as a JavaScript module.
### Manual installation
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
Assistant, and add the House Plan integration.
### Add the card
Create a dashboard view (Panel works best) and add the card in the UI or as:
```yaml
type: custom:houseplan-card
title: House plan
```
Different screens may start on different spaces:
```yaml
type: custom:houseplan-card
default_floor: ground
```
All cards share server-side rooms and coordinates. Current mode, viewport and
selected space remain local to the screen. Revision checks and live sync cover
concurrent clients, but avoid editing the same object in two browsers at once.
## Detailed documentation
- [Full user guide](docs/USER-GUIDE.md)
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
- [Background editor](docs/DECOR-EDITOR.md)
- [Robot vacuums](docs/VACUUM.md)
- [Touch support](docs/TOUCH-SUPPORT.md)
<!-- docs-section: support -->
## Support and feedback
- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Before reporting, update House Plan, restart HA and hard-refresh the page.
Include the version, browser, logs and reproduction steps; private entity IDs
may be replaced with fictional ones.
Documentation screenshots are produced by the reproducible
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
source fingerprint and every image hash are recorded in the
[screenshot index](docs/images/screenshots.json).
License: [MIT](LICENSE).
-161
View File
@@ -1,161 +0,0 @@
# 🏠 House Plan — живой план дома для Home Assistant
[![HACS Custom](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub release](https://img.shields.io/github/v/release/Matysh/houseplan-card)](https://github.com/Matysh/houseplan-card/releases)
[![CI](https://github.com/Matysh/houseplan-card/actions/workflows/validate.yml/badge.svg)](https://github.com/Matysh/houseplan-card/actions)
[![Live demo](https://img.shields.io/badge/демо-попробовать-00c853?logo=homeassistant&logoColor=white)](https://demo.houseplan.tech)
[![Telegram chat](https://img.shields.io/badge/Telegram-чат-2CA5E0?logo=telegram&logoColor=white)](https://t.me/ha_houseplan)
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
<!-- docs-section: overview -->
## Дом целиком — одним взглядом
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
датчик протечки.
![Синтетический дом в режиме просмотра: комнаты, устройства, свет и климат](docs/images/01-view-desktop.png)
Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
внешнего редактора плана. Данные плана и расположение устройств хранятся на
сервере Home Assistant и синхронизируются между экранами.
> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
<!-- docs-section: features -->
## Что умеет House Plan
- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
переключаются с плана; замок нельзя открыть случайным нажатием.
- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
«Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
подписи и мебель.
- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
а карточки комнат показывают температуру, влажность, свет и средний LQI.
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
солнечные лучи из окон.
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
робот показывает позицию, базу и пройденный путь.
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
локальный масштаб и отдельный стартовый этаж для каждой карточки.
- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
значков, сохранённые отдельно для этого устройства.
![Тот же синтетический дом в touch-режиме просмотра](docs/images/02-view-touch.png)
<!-- docs-section: first-run -->
## Первая рабочая комната
1. Установите интеграцию и добавьте карточку на дашборд.
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
вариант без изображения, чтобы нарисовать план вручную.
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
контур нажатием на первую точку.
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
устройств выберите «Без зоны».
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
перетащите маркеры в нужные места.
6. При необходимости оформите подложку линиями, текстом и мебелью.
7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
безопасные действия.
![Создание первого пространства](docs/images/03-space-create.png)
![Замыкание контура комнаты на первой точке](docs/images/04-room-contour-close.png)
![Выбранная перегородка и контекстная панель редактора плана](docs/images/05-plan-context-tray.png)
![Настройка устройства с источником привязки и точным результатом действия](docs/images/06-device-editor.png)
![Живой предпросмотр отображения того же устройства](docs/images/06-device-display-preview.png)
Пошаговые сценарии, все инструменты и особые случаи описаны в
[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
[руководстве по пылесосам](docs/VACUUM.md).
<!-- docs-section: installation -->
## Установка
### Через HACS
[![Открыть репозиторий в HACS](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
1. В HACS откройте **⋮ → Пользовательские репозитории**.
2. Добавьте `https://github.com/Matysh/houseplan-card` с типом **Интеграция**.
3. Установите House Plan и перезапустите Home Assistant.
4. Откройте **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
добавьте именно URL, который публикует интеграция:
```yaml
resources:
- url: /houseplan_files/houseplan-card.js
type: module
```
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
публикует его как JavaScript-модуль.
### Вручную
Скопируйте `custom_components/houseplan` в `config/custom_components`,
перезапустите Home Assistant и добавьте интеграцию House Plan.
### Добавление карточки
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
```yaml
type: custom:houseplan-card
title: План дома
```
Для нескольких экранов можно задать разные стартовые пространства:
```yaml
type: custom:houseplan-card
default_floor: ground
```
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
масштаб и выбранное пространство локальны для экрана. Одновременное
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
лучше не менять параллельно в двух браузерах.
## Где искать подробности
- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
- [Редактор подложки](docs/DECOR-EDITOR.md)
- [Роботы-пылесосы](docs/VACUUM.md)
- [Поддержка touch](docs/TOUCH-SUPPORT.md)
<!-- docs-section: support -->
## Помощь и обратная связь
- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
воспроизведения; приватные entity ID можно заменить вымышленными.
Скриншоты в документации получены воспроизводимой командой
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
fingerprint исходников и хеш каждого изображения находятся в
[индексе снимков](docs/images/screenshots.json).
Лицензия: [MIT](LICENSE).
-369
View File
@@ -1,369 +0,0 @@
"""House Plan: server-side house plan configuration + Lovelace card serving."""
from __future__ import annotations
import inspect
import logging
from datetime import timedelta
from pathlib import Path
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from . import websocket_api as hp_ws
from .const import (
DOMAIN,
FILES_DIR,
FILES_URL,
FRONTEND_URL,
PLANS_DIR,
PLANS_URL,
VERSION,
)
from .geometry_migration import migrate_config, migrate_layout, pending_from_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import (
HouseplanConfigEntry,
async_save_config_state,
async_save_layout_state,
create_data,
)
_LOGGER = logging.getLogger(__name__)
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
hass.http.register_view(HouseplanUploadView())
hass.http.register_view(HouseplanContentView())
hass.http.register_view(HouseplanImportPreviewView())
return True
async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
"""Config entry: stores in runtime_data, static paths, card auto-registration."""
data = create_data(hass)
# Home Assistant's installation id never leaves the instance. Exports
# carry only a salted SHA-256 fingerprint so same-instance internal files
# can be distinguished from cross-instance references.
try:
from homeassistant.helpers import instance_id as ha_instance_id
value = ha_instance_id.async_get(hass)
data.instance_id = str(await value if inspect.isawaitable(value) else value)
except Exception: # noqa: BLE001 - old HA/test harness fallback
data.instance_id = str(entry.entry_id)
# test-before-setup: storage must be readable, otherwise retry later
try:
await data.store.async_load()
await data.config_store.async_load()
except Exception as err: # noqa: BLE001 — corrupt/unreadable .storage
raise ConfigEntryNotReady(f"House Plan storage is not readable: {err}") from err
try:
await data.virtual_light_store.async_load()
except Exception: # noqa: BLE001 — operational state fails safe to default on
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
entry.runtime_data = data
# server-side vacuum trails: the integration records the path itself
from .trails import TrailRecorder
recorder = TrailRecorder(hass, data)
await recorder.async_setup()
# setdefault: the CI harness sets entries up without async_setup, so
# hass.data[DOMAIN] may not exist yet — a KeyError here failed EVERY
# downstream WS test with unknown_error
hass.data.setdefault(DOMAIN, {})["trail_recorder"] = recorder
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))
await hass.async_add_executor_job(
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.
if not hass.data[DOMAIN].get("static_registered"):
hass.data[DOMAIN]["static_registered"] = True
static_paths = []
try:
from homeassistant.components.http import StaticPathConfig
if card_path.exists():
static_paths.append(StaticPathConfig(FRONTEND_URL, str(card_path), cache_headers=False))
# NOTE (audit B1): plans and marker files are NO LONGER static.
# They are served by HouseplanContentView, which requires auth.
# Only the card bundle stays public — Lovelace resources must be.
if static_paths:
await hass.http.async_register_static_paths(static_paths)
except ImportError: # very old HA versions
if card_path.exists():
hass.http.register_static_path(FRONTEND_URL, str(card_path), cache_headers=False)
if not card_path.exists():
_LOGGER.warning("houseplan-card.js not found next to the integration: %s", card_path)
return True
# Register the card. Preferably as a Lovelace resource (the frontend AWAITS
# resources before rendering dashboards, so the card is available even on a cold
# start of the mobile app). If the resource registry is unavailable (YAML-mode
# Lovelace, old versions) — fall back to extra_module_url.
module_url = f"{FRONTEND_URL}?v={VERSION}"
registered = await _register_lovelace_resource(hass, module_url)
if not registered:
add_extra_js_url(hass, module_url)
# Tell the user exactly where the card lives — the #1 support issue is people adding a
# Lovelace resource pointing at the on-disk path (/custom_components/...), which HA does
# not serve (wrong MIME → "Custom element doesn't exist"). The correct served URL is below.
if registered:
_LOGGER.info("House Plan card auto-registered as a Lovelace resource: %s", module_url)
else:
_LOGGER.info(
"House Plan card is served at %s . Lovelace resources look YAML-managed — add it "
"manually under `resources:` as { url: %s, type: module }. Do NOT use the on-disk "
"path /custom_components/houseplan/frontend/houseplan-card.js (HA does not serve it).",
module_url, module_url,
)
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
# square and a plan is centred inside it. Nothing about the drawing changes
# — the box is padded and the numbers re-expressed against it.
# The two stores are written independently, and the lock is no transaction:
# a crash between the writes used to leave the config in square coordinates
# with the layout still in the old ones — permanently, because the config
# write had already deleted the `aspect` fields the layout half needed
# (HP-1490-01). So the intent is made durable FIRST, in the layout store,
# and each half carries its own trigger with its own write: the config half
# removes `aspect`, the layout half removes the saved intent. Whatever
# half is missing after a crash, the next start finishes exactly it.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
pending = {
str(k): v for k, v in (lay_stored.get("geom_pending") or {}).items()
}
merged = {**pending, **pending_from_config(cfg)}
if merged:
lay_rev = int(lay_stored.get("rev", 0))
if merged != pending: # 1. the durable intent, before anything moves
await async_save_layout_state(
data, lay_stored, layout, lay_rev,
metadata={"geom_pending": merged}, remove=("geom_pending",),
)
rev = int(stored.get("rev", 0))
if cfg and migrate_config(cfg): # 2. the config half
rev += 1
await async_save_config_state(data, cfg, rev, previous_rev=rev - 1)
migrate_layout(layout, merged) # 3. the layout half + intent cleared
await async_save_layout_state(
data, lay_stored, layout, lay_rev + 1, remove=("geom_pending",)
)
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(merged)
)
# only once both halves are durable — a client refetching on this
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
# Finish an explicit whole-plan optimization/undo interrupted between the
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
recovered_import = False
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
pending = lay_stored.get("optimize_pending")
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
and isinstance(pending.get("layout"), dict):
target_config = pending["config"]
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
target_config_rev = int(pending.get(
"config_rev", config_rev + (stored.get("config") != target_config)
))
target_layout_rev = int(pending.get(
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
previous_config_rev = config_rev
config_rev = max(config_rev, target_config_rev)
await async_save_config_state(
data,
target_config,
config_rev,
previous_rev=previous_config_rev,
)
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
replace_metadata = isinstance(exact_metadata, dict)
metadata = dict(exact_metadata) if replace_metadata else None
if not replace_metadata and not pending.get("clear_backup") \
and "optimize_backup" in lay_stored:
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
remove_metadata = ["optimize_pending", "optimize_backup"]
if pending.get("clear_backup"):
# A recovered whole-plan undo replaces the complete layout;
# a point-wise repair snapshot from the replaced layout must
# not survive and later restore coordinates into the new pair.
remove_metadata.append("repair_backup")
await async_save_layout_state(
data,
lay_stored,
target_layout,
layout_rev,
metadata=metadata,
remove=tuple(remove_metadata),
replace_metadata=replace_metadata,
)
optimize_revs = (config_rev, layout_rev)
recovered_import = str(pending.get("kind") or "").startswith("import")
_LOGGER.warning(
"House Plan: completed an interrupted %s",
str(pending.get("kind") or "plan optimization").replace("_", " "),
)
if optimize_revs is not None:
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
if recovered_import:
await recorder.async_refresh()
current = (await data.config_store.async_load() or {}).get("config") or {}
live_ids = {str(marker.get("id")) for marker in current.get("markers") or []}
for marker_id in list(recorder.book.data):
if marker_id not in live_ids:
await recorder.async_delete(marker_id)
await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing.
#
# A commit collects what that commit superseded, which is the right rule for
# a commit — but it only ever runs when somebody saves. Cancel a dialog
# after the file has already uploaded, lose the connection after the upload
# succeeded, or call the API directly, and the file is unreferenced with no
# future write to notice it (HP-1461-01). The earlier version of this sweep
# only removed streaming temporaries, which are a different, narrower case.
#
# Passing the CURRENT configuration as both sides means "nothing was
# superseded": every referenced file is preserved and only unreferenced ones
# past PLAN_ORPHAN_TTL_S go. It runs under the same lock as a config write,
# so it cannot decide from a snapshot that a commit is about to replace.
async def _sweep(_now=None) -> None:
files_dir = Path(hass.config.path(FILES_DIR))
plans_dir = Path(hass.config.path(PLANS_DIR))
try:
# `data` from the closure, NOT get_data(hass): during
# async_setup_entry the entry is still SETUP_IN_PROGRESS, so
# async_loaded_entries() does not list it and the lookup returned
# None. The startup pass then silently degraded to removing
# streaming temporaries only, and the real collection waited a full
# day — restarting more often than that meant it never ran at all
# (HP-1462-01). The callback is unregistered with the entry, so
# closing over its runtime data matches the lifecycle exactly.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config") or {}
def _collect() -> int:
n = sweep_upload_temps(files_dir)
# same config on both sides: nothing is superseded, so this
# only ever collects what the shared rules call abandoned
n += collect_attachments(files_dir, cfg, cfg)
n += collect_plans(plans_dir, cfg, cfg)
return n
n = await hass.async_add_executor_job(_collect)
if n:
_LOGGER.info("House Plan: removed %s unreferenced file(s)", n)
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
_LOGGER.exception("House Plan: sweeping unreferenced files failed")
data.sweep = _sweep
await _sweep()
entry.async_on_unload(
async_track_time_interval(hass, _sweep, timedelta(hours=24))
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -> bool:
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
if rec:
rec.teardown()
"""Unload the entry.
WS commands and the HTTP view are global (async_setup) and stay registered —
their handlers resolve runtime data per call and answer `not_ready` while no
entry is loaded. Static paths cannot be unregistered by design.
"""
return True
async def async_remove_entry(hass: HomeAssistant, entry) -> None:
"""Clean up on integration removal: drop our Lovelace resource entry."""
try:
resources = _lovelace_resources(hass)
if resources is None or not hasattr(resources, "async_delete_item"):
return
for item in list(resources.async_items()):
if str(item.get("url", "")).split("?", 1)[0] == FRONTEND_URL:
await resources.async_delete_item(item["id"])
_LOGGER.debug("House Plan Lovelace resource removed: %s", item.get("url"))
except Exception as err: # noqa: BLE001 — best-effort cleanup
_LOGGER.debug("Could not remove the Lovelace resource on uninstall: %s", err)
def _lovelace_resources(hass: HomeAssistant):
lovelace = hass.data.get("lovelace")
resources = getattr(lovelace, "resources", None)
if resources is None and isinstance(lovelace, dict):
resources = lovelace.get("resources")
return resources
async def _register_lovelace_resource(hass: HomeAssistant, module_url: str) -> bool:
"""Register (or update) the card in the Lovelace resource registry.
Returns True on success. Idempotent: if a resource with our path exists —
update the URL on version change; otherwise create it. Any exception → False
(fall back to extra_module_url).
"""
try:
resources = _lovelace_resources(hass)
if resources is None:
return False
# the resource registry must be loaded
if hasattr(resources, "loaded") and not resources.loaded:
await resources.async_load()
resources.loaded = True
elif hasattr(resources, "async_get_info"):
await resources.async_get_info()
# only storage mode allows creating items
if not hasattr(resources, "async_create_item"):
return False
base = FRONTEND_URL
existing = [
item for item in resources.async_items()
if str(item.get("url", "")).split("?", 1)[0] == base
]
if existing:
item = existing[0]
if item.get("url") != module_url and hasattr(resources, "async_update_item"):
await resources.async_update_item(item["id"], {"url": module_url})
return True
await resources.async_create_item({"res_type": "module", "url": module_url})
_LOGGER.debug("House Plan card registered as a Lovelace resource: %s", module_url)
return True
except Exception as err: # noqa: BLE001 — any failure → fallback
_LOGGER.debug("Could not register the Lovelace resource (%s), falling back to extra_module_url", err)
return False
-31
View File
@@ -1,31 +0,0 @@
"""Single source of truth for the write-authorization policy.
The WS and HTTP paths used to duplicate this decision and drifted apart: the
WS copy was fixed to fail closed while the upload view still failed OPEN when
the config entry was unavailable (audit follow-up B2, 2026-07-27). One helper,
one behaviour.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import CONF_ADMIN_ONLY
from .store import get_entry
def may_write(hass: HomeAssistant, user) -> bool:
"""True when `user` may modify House Plan data.
Fails CLOSED: when the entry cannot be read — during a reload, or while the
integration is disabled — the policy is unknown, and "unknown" is not the
same as "permissive": only admins are allowed through.
"""
is_admin = bool(getattr(user, "is_admin", False))
entry = get_entry(hass)
if entry is None:
return is_admin
# Default TRUE when the key is absent (audit P0-4, 2026-08-05): the card
# UI has always been admin-gated, and an unset option must not open every
# write WS/HTTP path to every authenticated household user.
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
return is_admin if admin_only else True
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

@@ -1,40 +0,0 @@
"""Config flow: a single entry with no parameters."""
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from .const import CONF_ADMIN_ONLY, DOMAIN
class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""One-step setup."""
VERSION = 1
async def async_step_user(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="House Plan", data={}, options=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
)
@staticmethod
def async_get_options_flow(config_entry):
return HouseplanOptionsFlow()
class HouseplanOptionsFlow(config_entries.OptionsFlow):
"""Option: layout editing by administrators only."""
async def async_step_init(self, user_input=None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
# Match auth.may_write: missing key ⇒ admin-only (audit P0-4).
current = self.config_entry.options.get(CONF_ADMIN_ONLY, True)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=current): bool}),
)
-69
View File
@@ -1,69 +0,0 @@
"""Constants of the House Plan integration."""
DOMAIN = "houseplan"
STORAGE_KEY = f"{DOMAIN}.layout"
STORAGE_CONFIG_KEY = f"{DOMAIN}.config"
STORAGE_VIRTUAL_LIGHTS_KEY = f"{DOMAIN}.virtual_lights"
STORAGE_VERSION = 1
STORAGE_MINOR_VERSION = 2
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
PLANS_URL = "/houseplan_files/plans"
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
# authenticated read path (audit B1): /api/houseplan/content/<plans|files>/<sub>/<name>
CONTENT_URL = "/api/houseplan/content"
# How many paths one houseplan/content/sign call may carry. The card batches to
# the same number; a client that sends more used to get a partial answer with no
# way to tell which paths were dropped (review R2-2).
MAX_SIGN_PATHS = 200
# Nothing is ever deleted for being old (docs/SCOPE.md), so growth has to be
# stopped at the door instead. These bound the whole store, not one request: by
# default any authenticated user may upload, and a per-request cap of 8/50 MB
# says nothing about how many requests there are (HP-1470-01).
MAX_PLANS_BYTES = 256 * 1024 * 1024
MAX_PLANS_FILES = 200
# How many the picker asks for at once — newest first.
MAX_PLANS_LISTED = 60
MAX_FILES_BYTES = 1024 * 1024 * 1024
MAX_FILES_COUNT = 1000
# 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
# An uploaded plan that no accepted configuration references is collected only
# once it is this old. Age is a race guard, not a policy: a plan uploaded
# seconds ago may belong to another client's transaction that has not written
# its configuration yet (review R3-1).
PLAN_ORPHAN_TTL_S = 3600
# Kept for compatibility with anything reading it; the collectors no longer use
# a long grace at all. Every attempt to age files out ended badly — first by
# deleting detached plans, then by racing the save that was about to reference a
# retried upload. What is left is deliberately simple: files go when the user's
# action says so, plus staging folders after PLAN_ORPHAN_TTL_S.
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.65.0-beta.1"
# 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 = 6
EXPORT_VERSION = 1
MAX_EXPORT_BYTES = 8 * 1024 * 1024
IMPORT_PREVIEW_TTL_S = 10 * 60
MAX_IMPORT_PREVIEWS_PER_USER = 3
# Parsed documents are larger than their wire representation. Keep the
# original three-preview memory ceiling global as well as per user so turning
# off the admin-only policy cannot multiply it by the number of household
# accounts.
MAX_IMPORT_PREVIEWS_TOTAL = 3
DEFAULT_CONFIG: dict = {
"spaces": [],
"markers": [],
"settings": {"bg_mode": "daynight"},
}
@@ -1,43 +0,0 @@
"""Diagnostics for House Plan (Settings → ... → Download diagnostics)."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .store import HouseplanConfigEntry
# Marker metadata may contain personal notes, external links and manual filenames.
TO_REDACT = {"link", "description", "pdfs", "name"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: HouseplanConfigEntry
) -> dict[str, Any]:
"""Return a redacted dump of the stores."""
data = entry.runtime_data
cfg_raw = await data.config_store.async_load() or {}
layout_raw = await data.store.async_load() or {}
config = cfg_raw.get("config", {})
layout = layout_raw.get("layout", {})
return {
"options": dict(entry.options),
"rev": cfg_raw.get("rev", 0),
"spaces": [
{
"id": s.get("id"),
"aspect": s.get("aspect"),
"has_plan": bool(s.get("plan_url")),
"rooms": len(s.get("rooms", [])),
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
"room_drafts": len(s.get("room_drafts", [])),
"partitions": len(s.get("partitions", [])),
"wall_columns": len(s.get("wall_columns", [])),
}
for s in config.get("spaces", [])
],
"markers": async_redact_data(config.get("markers", []), TO_REDACT),
"settings": config.get("settings", {}),
"layout_entries": len(layout),
}
@@ -0,0 +1,438 @@
{
"schema": 1,
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 300323,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301845,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2325,
"lazyFiles": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/de-E17v3gXt.js",
"houseplan-assets/editor-CbeYCXUv.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/fr-CpDKyK4Z.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/pdf-export-DL7VmwpB.js",
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
],
"lazyGzipBytes": 456389,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/editor-CbeYCXUv.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
],
"lazyEditorGzipBytes": 238192,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"lazyOnboardingGzipBytes": 27950,
"lazyLocaleFiles": [
"houseplan-assets/de-E17v3gXt.js",
"houseplan-assets/fr-CpDKyK4Z.js"
],
"lazyLocaleGzipBytes": 56330,
"lazyIsometricFiles": [
"houseplan-assets/iso-scene-render-BxXtKC5L.js"
],
"lazyIsometricGzipBytes": 17885,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js"
],
"lazyFurnitureArtGzipBytes": 16940,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/pdf-export-DL7VmwpB.js"
],
"lazyPdfGzipBytes": 126938,
"lazyNamespaceLocaleFiles": [
"houseplan-assets/settings-de-DfJ7EKF9.js",
"houseplan-assets/settings-fr-Bl79E-6s.js",
"houseplan-assets/settings-ru-CkNFmChi.js",
"houseplan-assets/support-de-DZoi-fxk.js",
"houseplan-assets/support-fr-Daosy89Q.js",
"houseplan-assets/support-ru-e8-BrqtH.js",
"houseplan-assets/topology-de-Cs36ouVE.js",
"houseplan-assets/topology-fr-B8yoJGvc.js",
"houseplan-assets/topology-ru-BRKShmrQ.js"
],
"lazyNamespaceLocaleGzipBytes": 21057,
"files": [
{
"path": "houseplan-assets/backdrop-pick-WDIF6iao.js",
"sha256": "5f6dcedf274594075d8acd31295d8c23f6d1408c890c086825db6bcbaa6c9b21",
"rawBytes": 54017,
"gzipBytes": 17576,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"dynamicImports": [
"houseplan-assets/settings-de-DfJ7EKF9.js",
"houseplan-assets/settings-fr-Bl79E-6s.js",
"houseplan-assets/settings-ru-CkNFmChi.js"
]
},
{
"path": "houseplan-assets/de-E17v3gXt.js",
"sha256": "ed10d6c5e7a82b275c9422be67c2213219a75dba9aab0ddb3e98813249cbd1d0",
"rawBytes": 94196,
"gzipBytes": 28393,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CbeYCXUv.js",
"sha256": "75887638c4d76040651a3cf52c56de10dc19e0cbc7583ee169d9b20cc5987788",
"rawBytes": 3833,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-v5DlO12L.js",
"sha256": "cdab9e5e612be9b98c49193a2a6287080c13f62aaefa9a28b69bac94dd934012",
"rawBytes": 25600,
"gzipBytes": 5056,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-CpDKyK4Z.js",
"sha256": "ee9fe5934eea69e549ff48d2e3fdabd7c20e0d9906a6f4623314f6eea48bf269",
"rawBytes": 96627,
"gzipBytes": 27937,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"sha256": "f668d3c0a47a53e096f26728187b1666fbb6b421c1baa2ff1a649fa1ba5340d5",
"rawBytes": 52035,
"gzipBytes": 16940,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BYwTiVnu.js",
"sha256": "055b6f8ba3a472be163a15ef6e2348727b5b03e3660b9362f3aed21d7be90426",
"rawBytes": 1059790,
"gzipBytes": 299520,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-E17v3gXt.js",
"houseplan-assets/editor-CbeYCXUv.js",
"houseplan-assets/fr-CpDKyK4Z.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
"houseplan-assets/pdf-export-DL7VmwpB.js",
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
"sha256": "f25cbcb0453279e3ac64eda67df52af100d96c54bb93cb657bc08c355e0484ed",
"rawBytes": 688848,
"gzipBytes": 192327,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
],
"dynamicImports": [
"houseplan-assets/support-de-DZoi-fxk.js",
"houseplan-assets/support-fr-Daosy89Q.js",
"houseplan-assets/support-ru-e8-BrqtH.js",
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
"sha256": "4ea3584c8b2bfc86f96c587549ddce49eaf0b918923df2df8e98c9c0a548f835",
"rawBytes": 15886,
"gzipBytes": 4908,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-WDIF6iao.js",
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
"sha256": "2a0f9af108b8a39889f90d7119d8e7789716c9960ea8e28af38ada07cdc91a8c",
"rawBytes": 10958,
"gzipBytes": 3765,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-BxXtKC5L.js",
"sha256": "4150eb9120fdaafa745761242ff3285056ceaccd7b016a16364574b90e3e2cfe",
"rawBytes": 52970,
"gzipBytes": 17885,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
"sha256": "66e70baa00f4c9470ac9a37a229652cf5c5585caa2364de4d467a1c2ebe24358",
"rawBytes": 8370,
"gzipBytes": 3280,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/namespace-language-zIuNvgGV.js",
"sha256": "244bce2d5211106846215be6a9d3dacda4c910d06020144f9ffd5e432c5e8c00",
"rawBytes": 675,
"gzipBytes": 410,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-DL7VmwpB.js",
"sha256": "548dced1a8a54f860dbe57e61019d50f058d3ca13abb738d750a3c107c88de8b",
"rawBytes": 236714,
"gzipBytes": 109998,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-de-DfJ7EKF9.js",
"sha256": "0580b9f6191edb664c21dee21e5263489eaf22876bcad67ef12e440abc03e28a",
"rawBytes": 7378,
"gzipBytes": 2879,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-fr-Bl79E-6s.js",
"sha256": "f1bde9db7fe884aabd25130f783a84d8fe27bc9553923c17bcb9b82ee3976475",
"rawBytes": 7620,
"gzipBytes": 2842,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-ru-CkNFmChi.js",
"sha256": "94f96c4a15a3a82ca8be3066629734379dd14eb2ff9e8f6acf369da2378676cb",
"rawBytes": 10304,
"gzipBytes": 3325,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-C_vpFucN.js",
"sha256": "d45dbdfa17a1da9f45bfbca6d6c3cc98849fd5ebcc06c8f72fd6310b04b93aad",
"rawBytes": 14862,
"gzipBytes": 3425,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-D47J-OjO.js",
"sha256": "909d2195fcfca30c2f1279e07e2d7506d470c3daec6fe864c97f7ecc4101e33d",
"rawBytes": 2701,
"gzipBytes": 1344,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
"sha256": "e44860a1af9ec7ffc7ac289c8c76ed1d0dcf0177bd91bbfe1981fdd0d4a62c46",
"rawBytes": 72959,
"gzipBytes": 20242,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-v5DlO12L.js",
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-C_vpFucN.js",
"houseplan-assets/summary-panel-metrics-D47J-OjO.js"
]
},
{
"path": "houseplan-assets/support-de-DZoi-fxk.js",
"sha256": "f01d2d00a169519f5f402e99e9c85c9f21a23aeee2a9605d4444910f3250d6de",
"rawBytes": 6167,
"gzipBytes": 2396,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-fr-Daosy89Q.js",
"sha256": "e503a1b4e1b1ed31d8fa09bfe0cdc069a82c1d1627d40407472b336fbc98ccf5",
"rawBytes": 6333,
"gzipBytes": 2432,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-ru-e8-BrqtH.js",
"sha256": "76742313c4b510163c380f8fd9c7fcaf1cc7f8bde6a41bcb0da48621a7c3a3b3",
"rawBytes": 8376,
"gzipBytes": 2876,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-de-Cs36ouVE.js",
"sha256": "7c808f1139bb1c232627bfe884a579177ec653d16147d6b96066851893b312a4",
"rawBytes": 2698,
"gzipBytes": 1337,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-fr-B8yoJGvc.js",
"sha256": "6ee1530fd0bd7a8c476c79b83404c7d8a5b98cd0b86ee8a73048be75a70cc2c4",
"rawBytes": 2718,
"gzipBytes": 1354,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-ru-BRKShmrQ.js",
"sha256": "7fc89542fa860318d459ce1467a5a8931f3214a9d17f57c177ba01f2cfbbd3e8",
"rawBytes": 3669,
"gzipBytes": 1616,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-DOAnbvlu.js",
"sha256": "313f71677ba0739d9c978185c2136e73e8037b770eb3f9ef8561f692d654406f",
"rawBytes": 10958,
"gzipBytes": 4299,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js"
],
"dynamicImports": [
"houseplan-assets/topology-de-Cs36ouVE.js",
"houseplan-assets/topology-fr-B8yoJGvc.js",
"houseplan-assets/topology-ru-BRKShmrQ.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CKlry6R8.js",
"sha256": "39e4a250de0e67e80d8afc02c39c1a79e42d4b3e113107bb3817f4fa90cab1e2",
"rawBytes": 3983,
"gzipBytes": 1789,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js",
"houseplan-assets/namespace-language-zIuNvgGV.js",
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "d4b480db1327152db7b1459c45692ea6b72fb7ac3bccb525bc06adfe1aab3b8d",
"rawBytes": 1205,
"gzipBytes": 803,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "5aaa677a9c85f4df0f176be31a91ebb5022f5ce129774919269bb0409c310233",
"rawBytes": 6165,
"gzipBytes": 2325,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BYwTiVnu.js"
],
"dynamicImports": []
}
]
}
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__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{aD as e,l as o,aG as t,ez as s,A as a,eA as i,eB as l,eC as n,E as r,c}from"./houseplan-card-BYwTiVnu.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);
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
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 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eE as e,eF as n,eB as a}from"./houseplan-card-BYwTiVnu.js";function c(a,c){return new e([{code:"en",dictionary:a},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",console.warn,n)}function o(e){return function(e,n){return{state:a=>{const c=e.state(a);return"pending"!==c&&n.some(e=>"pending"===e.state(a))?"pending":c},dictionary:n=>e.dictionary(n),ensure:a=>Promise.all([e,...n].map(e=>e.ensure(a))).then(()=>{})}}(a,e)}export{c as n,o as s};
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,203 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{c as a,A as e}from"./houseplan-card-BYwTiVnu.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-B0Dq0vzN.js";import"./form-kit.styles-v5DlO12L.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
.checked=${d.localShow}
?disabled=${d.busy} aria-label=${b("summary.show_local")}
aria-describedby="summary-local-show-hint"
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
.title=${b("summary.settings")} wide flex-content
dismiss-on-scrim aria-busy=${String(d.busy)}
@hp-close=${k}>
<div class="body summary-editor" @click=${()=>m.closeSource()}>
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
<section class="summary-general" aria-labelledby="summary-general-title">
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
<div class="summary-general-grid">
${d.localOnly?e:a`<div class="summary-field">
<label for="summary-panel-title">${b("summary.panel_title")}</label>
<input id="summary-panel-title" type="text" .value=${d.draft.title}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&"title"===y?.path)}
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
</div>`}
${g}
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
</div>
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
</section>
${d.localOnly?e:a`
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
<header class="summary-blocks-heading">
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
<p>${b("summary.blocks_hint")}</p></div>
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
</header>
<div class="summary-editor-blocks">
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
@dragover=${a=>a.preventDefault()}
@drop=${a=>m.drop(a,`block:${t}`)}>
<div class="summary-block-head">
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
@dragstart=${a=>m.dragStart(a,`block:${t}`)}>${l("grip")}</span>
<div class="summary-order">
<button class="summary-icon-button" type="button" title=${b("summary.up")}
aria-label=${b("summary.up")} ?disabled=${0===t||d.busy}
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t-1)})}>${l("up")}</button>
<button class="summary-icon-button" type="button" title=${b("summary.down")}
aria-label=${b("summary.down")}
?disabled=${t===d.draft.blocks.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
</div>
<input class="summary-block-title" type="text" .value=${s.title}
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
<button class="summary-icon-button summary-visibility" type="button"
?disabled=${d.busy} aria-pressed=${String(s.visible)}
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
<div class="summary-scope">
<select .value=${s.scope.type} ?disabled=${d.busy}
aria-label=${b("summary.scope")}
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
</select>
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
aria-invalid=${"error"===n?.kind?"true":"false"}
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
${c._model.map(e=>a`<option value=${e.id}
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
</select>`:e}
</div>
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
</div>
<div class="summary-block-body">
<div class="summary-editor-values">
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
@dragstart=${a=>m.dragStart(a,`value:${t}:${c}`)}>${l("grip")}</span>
<div class="summary-order">
<button class="summary-icon-button" type="button" title=${b("summary.up")}
aria-label=${b("summary.up")} ?disabled=${0===c||d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
<button class="summary-icon-button" type="button" title=${b("summary.down")}
aria-label=${b("summary.down")}
?disabled=${c===s.values.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
</div>
<div class="summary-value-label">
<input type="text" .value=${i.label} ?disabled=${d.busy}
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
</div>
<div class="summary-source-field">
<button class="summary-source" type="button"
?disabled=${d.busy}
data-summary-source-owner=${S}
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
aria-invalid=${"error"===$?.kind?"true":"false"}
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
<span class="summary-source-caption"><strong>${g}</strong>
${_?a`<small>${_}</small>`:e}</span>
${l("chevron")}
</button>
${k&&f?a`<div class="summary-source-picker"
@click=${a=>a.stopPropagation()}
@keydown=${a=>{"Escape"===a.key&&(a.preventDefault(),a.stopPropagation(),m.closeSource(!0))}}>
<label>${b("summary.search_entities")}</label>
<input type="search" data-summary-picker-search
?disabled=${d.busy}
aria-label=${b("summary.search_entities")} .value=${d.entityFilter}
@input=${a=>m.setDialog({...d,entityFilter:a.target.value})} />
<div class="summary-source-results" role="listbox"
aria-label=${b("summary.select_source")}>
<div class="summary-source-group">${b("summary.system_group")}</div>
${["device_count","total_area","datetime"].map(e=>a`
<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`system:${e}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
${b(`summary.system.${e}`)}
</button>`)}
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
<button type="button" role="option" class="broken" aria-selected="true"
?disabled=${d.busy}
@click=${()=>m.setSource(s.id,i.id,p)}>
${g}
</button>`:e}
<div class="summary-source-group">${b("summary.entities_group")}</div>
${f.entries.map(e=>a`<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`entity:${e.id}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
<span>${e.label}</span><small>${e.id}</small>
</button>`)}
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
</div>
</div>`:e}
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
</div>
<button class="summary-icon-button summary-value-remove" type="button"
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
</div>`})}
<button class="summary-add summary-add-value" type="button"
?disabled=${s.values.length>=20||d.busy}
title=${s.values.length>=20?b("summary.limit_values"):""}
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
${l("plus")}${b("summary.add_value")}
</button>
</div>
<footer class="summary-block-footer">
<button class="summary-delete-block" type="button" ?disabled=${d.busy}
@click=${()=>m.deleteBlock(t)}>${b("summary.delete_block")}</button>
</footer>
</div>
</article>`})}
</div>
<button class="summary-add summary-add-block" type="button"
?disabled=${d.draft.blocks.length>=10||d.busy}
title=${d.draft.blocks.length>=10?b("summary.limit_blocks"):""}
@click=${()=>m.mutate(a=>{a.blocks.push(u(b))})}>
${l("plus")}${b("summary.add_block")}
</button>
</section>
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
?disabled=${d.busy} @click=${()=>m.reload()}>
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
</button>`:e}
`}
</div>
<div class="row summary-editor-footer" slot="footer">
<button class="btn ghost" data-hp="dialog-cancel"
?disabled=${d.busy} @click=${k}>${b("btn.cancel")}</button>
<button class="btn on" data-hp="dialog-confirm"
?disabled=${d.localOnly?!$||d.busy:!p}
@click=${()=>m.save()}>${b("btn.save")}</button>
</div>
</hp-dialog>`};export{m as renderSummaryPanelEditor};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eH as e,N as t,b2 as n,L as i,ae as o,ac as r,c9 as s,eI as a,c8 as c,b1 as l,b0 as d,b3 as u,eJ as f,eK as m,cb as g,eL as v}from"./houseplan-card-BYwTiVnu.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
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 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,n as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,s as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const t="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,t as fingerprint};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{n as t,c as n}from"./zigbee-topology-DOAnbvlu.js";import{aM as e}from"./houseplan-card-BYwTiVnu.js";import"./namespace-language-zIuNvgGV.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
+1 -6583
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,171 +0,0 @@
"""One-time migration to a square canvas — pure, so it can be tested alone.
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
it: x by the width, y by the HEIGHT. Making every canvas square without touching
the numbers would stretch every plan vertically.
Nothing about the drawing changes here. The canvas is padded to a square —
top and bottom for a wide plan, left and right for a tall one — and the
coordinates are re-expressed against that larger box. In render units it is a
uniform scale plus an offset, so angles, room proportions and relative positions
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
tall plan the width grew, so the same wall would otherwise measure less.
"""
from __future__ import annotations
import logging
from typing import Any
_LOGGER = logging.getLogger(__name__)
def transform_for(aspect: float) -> tuple[float, float, float, float]:
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
axis's factor; both are the same uniform scale in RENDER units, which is
why angles are preserved.
"""
a = float(aspect)
if not a or a <= 0:
a = 1.0
k = min(1.0, a) # how much the old box shrinks inside the square
kx = k # x was normalised by the width
ky = k / a # y was normalised by the height (= width / aspect)
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
if isinstance(p, (list, tuple)) and len(p) >= 2:
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
return p
def migrate_space(space: dict[str, Any]) -> bool:
"""Rewrite one space in place. Returns True when anything was changed."""
if "aspect" not in space:
return False
try:
aspect = float(space.get("aspect") or 1)
except (TypeError, ValueError):
aspect = 1.0
dx, dy, kx, ky = transform_for(aspect)
space.pop("aspect", None)
for room in space.get("rooms") or []:
if room.get("x") is not None:
room["x"] = dx + float(room["x"]) * kx
if room.get("y") is not None:
room["y"] = dy + float(room["y"]) * ky
if room.get("w") is not None:
room["w"] = float(room["w"]) * kx
if room.get("h") is not None:
room["h"] = float(room["h"]) * ky
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for draft in space.get("room_drafts") or []:
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
for part in space.get("partitions") or []:
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
for column in space.get("wall_columns") or []:
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
# a length is measured along the wall, and the render scale is uniform
if op.get("length") is not None:
op["length"] = float(op["length"]) * kx
for shape in space.get("decor") or []:
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
if shape.get(a) is not None:
shape[a] = dx + float(shape[a]) * fx
if shape.get(b) is not None:
shape[b] = dy + float(shape[b]) * fy
if shape.get("w") is not None:
shape["w"] = float(shape["w"]) * kx
if shape.get("h") is not None:
shape["h"] = float(shape["h"]) * ky
# The viewport becomes the whole square rather than the transformed old
# rectangle. It is what the grid is drawn over and what "fit to screen"
# fits, so keeping the old box would leave the new margins outside the
# canvas — no dots, nothing to draw on — which is exactly the room this
# change was meant to give.
space["view_box"] = [0.0, 0.0, 1.0, 1.0]
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
# canvas, so a wall now covers fewer cells; without this every measurement
# in the plan would silently shrink.
if kx != 1:
try:
cell = float(space.get("cell_cm") or 5)
except (TypeError, ValueError):
cell = 5.0
space["cell_cm"] = round(cell / kx, 4)
# The image keeps its own proportions and is centred; the space no longer
# has any of its own.
if space.get("plan_url") and not space.get("plan_aspect"):
space["plan_aspect"] = round(aspect, 6)
return True
def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
"""{space_id: old aspect} for every space still carrying one.
This is the migration INTENT. The two stores are written independently and
either write can fail, so the intent has to survive on its own: it is saved
into the layout store BEFORE anything changes (HP-1490-01), and cleared by
the same write that stores the migrated layout. A crash between the writes
leaves the intent behind, and the next start finishes the missing half —
each half is idempotent because its trigger (`aspect` in the config, the
saved intent for the layout) travels with that half's own write.
"""
out: dict[str, float] = {}
for space in (config or {}).get("spaces") or []:
if "aspect" not in space:
continue
try:
out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
except (TypeError, ValueError):
out[str(space.get("id"))] = 1.0
return out
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
"""The config half: migrate every space still carrying an `aspect`.
`layout` is accepted for backward compatibility and migrated with the
factors found in the config — callers that can crash between store writes
should use `pending_from_config()` + `migrate_layout()` instead, so the
layout half does not depend on state the config half just deleted.
"""
factors = pending_from_config(config)
if not factors:
return False
for space in config.get("spaces") or []:
migrate_space(space)
if layout:
migrate_layout(layout, factors)
return True
def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
"""The layout half: marker and label positions of the spaces in `pending`."""
changed = False
for pos in (layout or {}).values():
if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
continue
dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
if pos.get("x") is not None:
pos["x"] = dx + float(pos["x"]) * kx
if pos.get("y") is not None:
pos["y"] = dy + float(pos["y"]) * ky
changed = True
return changed
-322
View File
@@ -1,322 +0,0 @@
"""HTTP endpoint for uploading House Plan manual files.
Files (PDF and the like) are uploaded not over WebSocket (its message size limit
breaks the connection on a large PDF) but via a plain multipart POST — like media in HA itself.
"""
from __future__ import annotations
import logging
import os
import tempfile
from functools import partial
from pathlib import Path
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
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 .const import (
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
from .store import get_data
from .validation import (
FILE_EXTENSIONS,
MAX_FILE_BYTES,
file_ext,
sanitize_filename,
sanitize_marker_id,
)
_LOGGER = logging.getLogger(__name__)
_CHUNK = 64 * 1024
# batch disk writes: one executor job per megabyte instead of per chunk
_FLUSH_AT = 1024 * 1024
_MIME = {
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".webp": "image/webp",
".gif": "image/gif",
".txt": "text/plain",
}
class HouseplanImportPreviewView(HomeAssistantView):
"""Upload a bounded JSON backup and return a server-side preview token."""
url = "/api/houseplan/import/preview"
name = "api:houseplan:import-preview"
requires_auth = True
async def post(self, request: web.Request) -> web.Response:
hass: HomeAssistant = request.app[KEY_HASS]
user = request.get("hass_user")
if not may_write(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)
policy = request.query.get("duplicate_policy", "skip")
if policy not in ("skip", "virtual"):
return web.json_response({"error": "invalid_format"}, status=400)
declared = request.content_length
if declared is not None and declared > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks: list[bytes] = []
size = 0
async for block in request.content.iter_chunked(_CHUNK):
size += len(block)
if size > MAX_EXPORT_BYTES:
return web.json_response({"error": "too_large"}, status=413)
blocks.append(block)
owner_id = str(getattr(user, "id", ""))
try:
# Hold the global writer only while taking one coherent store
# snapshot. Parsing up to 8 MiB, schema validation and space remap
# are CPU work and apply will revalidate both revisions anyway.
async with runtime.write_lock:
config_data = await runtime.config_store.async_load() or {}
layout_data = await runtime.store.async_load() or {}
try:
registry_snapshot = import_registry_snapshot(hass)
except Exception: # noqa: BLE001 - summary must not block a valid backup
_LOGGER.debug("House Plan import registry summary unavailable", exc_info=True)
registry_snapshot = None
result = await hass.async_add_executor_job(
partial(
create_preview,
runtime,
b"".join(blocks),
owner_id=owner_id,
duplicate_policy=policy,
current_config_data=config_data,
current_layout_data=layout_data,
config_root=Path(hass.config.path("")),
registry_snapshot=registry_snapshot,
)
)
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
_LOGGER.exception("House Plan import preview failed")
return web.json_response({"error": "invalid_format"}, status=400)
return web.json_response(result)
class HouseplanContentView(HomeAssistantView):
"""Authenticated read access to plans and marker files (audit B1).
The directories used to be exposed as unauthenticated static paths, so
anyone who could reach the HA endpoint could pull floor plans and uploaded
manuals without logging in. This view keeps the same URLs but requires a
Home Assistant session (or a signed path, which the frontend uses for
<image href> inside the SVG).
"""
url = "/api/houseplan/content/{kind}/{sub}/{name}"
name = "api:houseplan:content"
requires_auth = True
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"):
return web.Response(status=404)
safe_sub = sanitize_marker_id(sub)
safe_name = sanitize_filename(name)
if not safe_sub or not safe_name:
return web.Response(status=404)
base = Path(hass.config.path(PLANS_DIR if kind == "plans" else FILES_DIR)).resolve()
# plans live flat in one directory: the sub segment is a placeholder ("_")
path = (base / safe_name if kind == "plans" else base / safe_sub / safe_name).resolve()
# defence in depth: the sanitizers already strip separators
if not str(path).startswith(str(base)):
return web.Response(status=404)
if not await hass.async_add_executor_job(path.is_file):
return web.Response(status=404)
suffix = path.suffix.lower()
headers = {
"Cache-Control": "private, max-age=3600",
"Content-Type": _MIME.get(suffix, "application/octet-stream"),
}
if suffix == ".svg":
# An uploaded SVG is user content served from Home Assistant's own
# origin. Inside the card it is referenced by <image>, where scripts
# never run — but the same url opened as a top-level document is a
# live document of this origin, and a <script> in it reaches the
# session's localStorage and API (HP-1454-01, 2026-07-28: uploading
# needs write access, which by default every authenticated user has,
# and the signed url is easy to hand to an admin).
#
# `sandbox` with no allow-* tokens drops the document into an opaque
# origin: no scripts, no same-origin access, no forms. The explicit
# directives below are belt and braces for older engines. Only SVG
# gets this — a CSP on a PDF response can break the browser's built-in
# viewer, and a raster image cannot execute anything in the first place.
headers["Content-Security-Policy"] = (
"sandbox; default-src 'none'; script-src 'none'; object-src 'none'; "
"base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:"
)
# FileResponse streams from disk: a 50 MB manual used to be read whole
# into memory and copied into the response body, so a couple of parallel
# downloads could push a small Home Assistant host into swap (HP-1454-06).
return web.FileResponse(path, chunk_size=_CHUNK, headers=headers)
class HouseplanUploadView(HomeAssistantView):
"""POST /api/houseplan/upload — save a marker file, return its URL."""
url = "/api/houseplan/upload"
name = "api:houseplan: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)
files_root = Path(hass.config.path(FILES_DIR))
marker_id = "misc"
filename: str | None = None
# Every temporary file this request creates, promoted or not. The outer
# `finally` removes whatever is left: a dropped connection, a second
# `file` part or a failure while promoting used to leave a `.upload-*`
# behind for good, and the collector only ever walks marker folders, so
# nothing would have picked it up (HP-1460-02).
temps: list[Path] = []
error: tuple[dict, int] | None = None
def _new_tmp() -> Path:
files_root.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
os.close(fd)
return Path(name)
def _flush(target: Path, blocks: list[bytes]) -> None:
with open(target, "ab") as fh:
for block in blocks:
fh.write(block)
def _cleanup(paths: list[Path]) -> None:
for path in paths:
try:
path.unlink()
except OSError:
pass
try:
try:
reader = await request.multipart()
async for part in reader:
if part.name == "marker_id":
marker_id = sanitize_marker_id(await part.text())
elif part.name == "file":
if filename is not None:
# one upload per request: a second part would strand
# the first temporary file and make the response
# ambiguous about which url was returned
error = ({"error": "one_file_only"}, 400)
break
filename = part.filename or "file"
if file_ext(filename) not in FILE_EXTENSIONS:
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
break
# Stream to a temporary file instead of collecting the
# whole upload in memory and copying it again into one
# buffer: a 50 MB manual used to cost ~100 MB of RSS
# mid-request (HP-1454-06). Blocks are batched so this
# is one executor job per megabyte, not per 64 KB.
tmp = await hass.async_add_executor_job(_new_tmp)
temps.append(tmp)
size = 0
pending: list[bytes] = []
buffered = 0
while chunk := await part.read_chunk(_CHUNK):
size += len(chunk)
if size > MAX_FILE_BYTES:
error = (
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
413,
)
break
pending.append(chunk)
buffered += len(chunk)
if buffered >= _FLUSH_AT:
await hass.async_add_executor_job(_flush, tmp, pending)
pending, buffered = [], 0
if error:
break
if pending:
await hass.async_add_executor_job(_flush, tmp, pending)
except Exception as err: # noqa: BLE001
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
error = ({"error": "bad_request"}, 400)
if error:
return web.json_response(error[0], status=error[1])
if not temps or not filename:
return web.json_response({"error": "no_file"}, status=400)
tmp_path = temps[0]
try:
await hass.async_add_executor_job(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT,
)
except QuotaError as err:
_LOGGER.warning("House Plan upload refused: %s", err.detail)
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
except OSError:
pass
target_dir = files_root / marker_id
safe_name = filename
def _promote() -> str:
"""Claim a free name, then move the finished upload onto it.
Never overwrite an existing attachment: its bytes may be
referenced by the stored configuration, and this upload is not
part of that transaction — a cancelled dialog or a rejected save
would leave the old url serving the new content (HP-1454-02).
The name is reserved atomically, so two uploads racing on the
same filename cannot agree on it (HP-1460-01).
"""
name = reserve_filename(target_dir, safe_name)
try:
os.replace(tmp_path, target_dir / name)
except OSError:
(target_dir / name).unlink(missing_ok=True)
raise
return name
try:
name = await hass.async_add_executor_job(_promote)
except OSError as err:
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
return web.json_response({"error": "io_error"}, status=500)
temps.remove(tmp_path) # it is the attachment now, not a temporary
return web.json_response(
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
)
finally:
# BaseException too: cancelling the request task raises
# asyncio.CancelledError, which an `except Exception` never saw —
# an aborted large upload leaked its temporary file every time
if temps:
await hass.async_add_executor_job(_cleanup, list(temps))
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
{
"domain": "houseplan",
"name": "House Plan",
"codeowners": [
"@Matysh"
],
"config_flow": true,
"dependencies": [
"http",
"frontend",
"websocket_api"
],
"documentation": "https://github.com/Matysh/houseplan-card",
"integration_type": "service",
"iot_class": "local_push",
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.65.0-beta.1"
}
-357
View File
@@ -1,357 +0,0 @@
"""Blob lifecycle — pure, so it is unit-testable without Home Assistant.
The file system is not part of the configuration store's transaction, so who
may write or delete a plan or an attachment, and when, is a correctness
question rather than housekeeping. It lives here, apart from the WebSocket and
HTTP plumbing, precisely because it is the part that has to be reasoned about
and tested.
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
from typing import Any
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
_LOGGER = logging.getLogger(__name__)
# Streaming uploads land here first. The prefix is a dot so the name can never
# collide with an attachment (sanitize_filename strips leading dots) and is easy
# to sweep.
TMP_PREFIX = ".upload-"
def reserve_filename(directory: Path, name: str) -> str:
"""Atomically claim a free name inside `directory` and return it.
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
moment it is chosen. The previous version asked `exists()` and returned a
string; two uploads racing between the check and the write agreed on the
same name and one silently overwrote the other, both reporting success
(HP-1460-01). The caller writes the real bytes over the placeholder — it
owns the name by then — and must remove it if it never gets that far.
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
the content view sanitises the name in the request too, so a name it would
shorten or rewrite is a file that is written and then never served.
"""
directory.mkdir(parents=True, exist_ok=True)
# Split the extension off the RAW name: sanitize_filename() truncates to
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
# attachment would be stored — and served — without its type.
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
stem, dot, suffix = base.rpartition(".")
if not dot:
stem, suffix = base, ""
stem = sanitize_filename(stem)
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
i = 1
while True:
tag = "" if i == 1 else f"-{i}"
# budget the stem so the WHOLE name fits, including the collision tag —
# appending "-2" to an already maximal name produced a url the view
# truncated back to something else, i.e. a permanent 404
room = MAX_FILENAME - len(ext) - len(tag)
candidate = (stem[:room] if room > 0 else "f") + tag + ext
candidate = sanitize_filename(candidate)
if candidate.startswith("."): # a name that is only an extension
candidate = "file" + candidate
try:
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
i += 1
if i > 10000: # pathological directory; do not spin forever
raise
continue
os.close(fd)
return candidate
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
""""<marker>/<file>" for every attachment a configuration references."""
out: set[str] = set()
for m in (cfg or {}).get("markers") or []:
for pdf in m.get("pdfs") or []:
url = pdf.get("url") if isinstance(pdf, dict) else None
if not isinstance(url, str) or "/files/" not in url:
continue
rel = url.split("?", 1)[0].split("/files/", 1)[1]
if rel.count("/") == 1:
out.add(rel)
return out
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
"""Remove abandoned streaming temporaries (HP-1460-02).
The request itself deletes its own, but a hard kill — a restart mid-upload,
an OOM — leaves one behind, and the attachment collector only walks marker
folders, so it would never be seen. Age-gated for the same reason as the
rest: a fresh one belongs to a request still in flight.
"""
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
try:
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
except OSError as err:
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
return 0
for item in items:
if not item.name.startswith(TMP_PREFIX):
continue
try:
if item.stat().st_mtime >= cutoff:
continue
item.unlink()
removed += 1
except OSError:
continue
return removed
def collect_attachments(
files_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
A file the old revision referenced and the new one does not, whose marker
still exists, was removed on purpose — the dialog has a trash button and
promises nothing. It goes. Everything else is kept, except a staging folder
(`up_*`), which by construction only ever holds an upload from a dialog that
was never saved: those go after PLAN_ORPHAN_TTL_S. Never raises: it runs
behind a durable write.
"""
new_refs = attachment_refs(new_cfg)
old_refs = attachment_refs(old_cfg)
# Removing an attachment from a device that still exists is the user saying
# "drop this one" — a trash button, no promise that anything is kept. A
# device that is GONE is a different transition, and its files follow the
# same rule as a deleted space's plan: kept.
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
# Same distinction as for plans. A staging folder (`up_*`) is different: it
# only ever holds an upload from a dialog that was never saved, so the short
# rule is exactly right there even on the timer.
now_s = time.time() if now is None else now
staging_cutoff = now_s - PLAN_ORPHAN_TTL_S
removed = 0
try:
folders = sorted(p for p in files_dir.iterdir() if p.is_dir()) if files_dir.is_dir() else []
except OSError as err:
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
return 0
removed += sweep_upload_temps(files_dir, now)
for folder in folders:
# A staging folder only ever holds an upload from a dialog that was never
# saved — unambiguous, so an hour is right, and no device owns it.
staging = folder.name.startswith("up_")
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
continue
for item in items:
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
dropped = rel in old_refs and folder.name in live_markers
if not dropped:
if not staging:
# Same rule as for plans: not asked for, so kept. A file in
# a device's folder that the device does not list is an
# upload whose save was rejected — and ageing those out
# raced the retry that was about to reference them.
continue
try:
if item.stat().st_mtime >= staging_cutoff:
continue
except OSError:
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the attachment %s: %s", item, err)
try:
next(folder.iterdir())
except StopIteration:
try:
folder.rmdir()
except OSError:
pass
except OSError:
pass
return removed
class QuotaError(Exception):
"""A store limit would be exceeded. Carries what to tell the user."""
def __init__(self, reason: str, detail: str) -> None:
super().__init__(detail)
self.reason = reason
self.detail = detail
def dir_usage(path: Path) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read."""
total = count = 0
if not path.is_dir():
return 0, 0
for item in path.rglob("*"):
try:
if item.is_file():
total += item.stat().st_size
count += 1
except OSError:
continue
return total, count
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
"""Raise QuotaError unless `incoming` more bytes fit.
Deliberately not an age rule. Files are never removed for getting old — that
cost real plans twice — so the limit sits where a decision is being made
anyway: at the moment somebody asks to store something new.
"""
import shutil
used, count = dir_usage(path)
if count + 1 > max_files:
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
if used + incoming > max_bytes:
raise QuotaError(
"quota_exceeded",
f"{(used + incoming) // 1024 // 1024} MB would be stored, the limit is "
f"{max_bytes // 1024 // 1024} MB",
)
try:
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
except OSError:
return
if free - incoming < MIN_FREE_BYTES:
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
def plan_basename(url: Any) -> str:
"""File name a stored plan_url points at ('' when there is none)."""
if not isinstance(url, str) or not url:
return ""
return url.split("?", 1)[0].rsplit("/", 1)[-1]
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
"""Plan file names a configuration references."""
out: set[str] = set()
for sp in (cfg or {}).get("spaces") or []:
name = plan_basename(sp.get("plan_url"))
if name:
out.add(name)
return out
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
"""space id -> the plan file it references ('' when it has none)."""
return {
str(sp.get("id")): plan_basename(sp.get("plan_url"))
for sp in (cfg or {}).get("spaces") or []
}
def is_plan_file(name: str) -> bool:
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
parts = name.split(".")
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
def collect_plans(
plans_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""Drop plan files the accepted configuration made obsolete (review R3-1).
Called inside the config write lock, right after the new revision is
stored, so it decides from the two configurations that actually bracket the
commit instead of trusting a client to say what may be deleted. The earlier
design — a `plan/cleanup` command carrying `keep` — could not be ordered
against another client's commit: a delayed call removed the file that
client had just saved, leaving the accepted configuration pointing at
nothing, which is the damage copy-on-write was introduced to prevent.
Two rules, both conservative:
* a file the OLD configuration referenced and the new one does not was
authoritative and has been superseded — remove it;
* any other unreferenced plan file is a rejected or abandoned upload, and
is KEPT — see the rule above; only a staging folder ages out: a fresh one may
belong to a transaction that has not committed yet.
Never raises: the configuration is already stored by the time this runs, so
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
# file stays on disk. So the scheduled pass keeps anything belonging to a
# space that still exists, and waits a month for the rest.
# A space with NO plan_url has had its image detached — reversible, and the
# editor promises the file stays. A space that HAS one is different: any
# other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28.
# The short rule fits exactly one case: a space that HAS a plan, where any
# other file of its own can only be a superseded or rejected upload.
old_by_space = plan_by_space(old_cfg)
new_by_space = plan_by_space(new_cfg)
# A file that left the configuration tells us nothing on its own: replacing a
# plan, detaching one and deleting a space all look identical from
# `old_refs - new_refs`. Only the first is a deletion the user asked for
# (HP-1465-01 — the guards below were written and then never reached,
# because the code decided "superseded" before asking why).
replaced = {
name for space, name in old_by_space.items()
if new_by_space.get(space) and new_by_space[space] != name
}
removed = 0
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
except OSError as err:
# The directory can vanish or turn unreadable between the check and the
# walk. This is housekeeping running behind a commit that is already
# durable, so it reports "nothing collected" instead of failing (R4-1).
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
return 0
for item in items:
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
if item.name not in replaced:
# PRODUCT RULE (owner's decision, 2026-07-28): a plan file we were
# not told to delete is kept, however long it sits there. Detaching
# is one click to undo and the editor says the image stays; deleting
# a space is deliberate but the image was imported and may be
# nowhere else. The errors are not symmetrical — unnecessary
# megabytes can be removed by hand, a deleted file cannot be
# brought back.
#
# There is deliberately no age rule here. An earlier version aged
# out "rejected uploads" — a file of a space that has a plan, which
# was never the plan — and that raced a save: the sweep deleted the
# upload from the failed attempt while a retry was committing a
# reference to it. A rule that can delete a file somebody is about
# to point at is not worth the disk it reclaims.
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
return removed
@@ -1,112 +0,0 @@
# Integration Quality Scale self-assessment.
# Custom integrations are not formally graded (they sit in the "Custom" tier),
# but we track the official checklist here. done = implemented, exempt = not
# applicable with the reason.
rules:
# ---- Bronze ----
action-setup:
status: exempt
comment: The integration registers no service actions.
appropriate-polling:
status: exempt
comment: No polling — storage + WebSocket API + frontend serving only.
brands:
status: done
comment: Local brand images in custom_components/houseplan/brand/ (HA >=2026.3 mechanism).
common-modules:
status: done
comment: const.py, store.py (stores + runtime data), validation.py (pure schemas).
config-flow:
status: done
config-flow-test-coverage:
status: done
comment: tests_backend/test_ha_config_flow.py (runs in CI on Python 3.13).
dependency-transparency:
status: done
comment: No external requirements.
docs-actions:
status: exempt
comment: No service actions.
docs-high-level-description:
status: done
comment: README.md.
docs-installation-instructions:
status: done
comment: README.md (HACS + manual).
docs-removal-instructions:
status: done
comment: README.md uninstall section.
entity-event-setup:
status: exempt
comment: No entities.
entity-unique-id:
status: exempt
comment: No entities.
has-entity-name:
status: exempt
comment: No entities.
runtime-data:
status: done
comment: entry.runtime_data holds HouseplanData (stores + write lock).
test-before-configure:
status: exempt
comment: No external device/service to validate during the flow.
test-before-setup:
status: done
comment: Storage load is verified in async_setup_entry (ConfigEntryNotReady on failure).
unique-config-entry:
status: done
comment: single_config_entry in manifest.
# ---- Silver ----
action-exceptions:
status: exempt
comment: No service actions; WS handlers reply with typed error codes.
config-entry-unloading:
status: done
comment: Unload supported; WS commands and static paths are global by design (documented in __init__).
docs-configuration-parameters:
status: done
comment: README documents the admin_only option and card options.
docs-installation-parameters:
status: done
entity-unavailable:
status: exempt
comment: No entities.
integration-owner:
status: done
log-when-unavailable:
status: exempt
comment: No external service.
parallel-updates:
status: exempt
comment: No entities/polling.
reauthentication-flow:
status: exempt
comment: No authentication against an external service.
test-coverage:
status: todo
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
comment: diagnostics.py with redaction of personal marker fields.
reconfiguration-flow:
status: exempt
comment: Nothing to reconfigure — no host/credentials; all data is edited in the card UI.
repair-issues:
status: done
comment: Missing plan files raise repair issues (translation_key broken_plan).
docs-troubleshooting:
status: todo
docs-examples:
status: todo
# ---- Platinum ----
strict-typing:
status: todo
comment: Python is annotated; mypy strict pass is planned.
async-dependency:
status: exempt
comment: No dependencies.
inject-websession:
status: exempt
comment: No outgoing HTTP.
@@ -1,47 +0,0 @@
"""Small registry projection shared by import HTTP and WebSocket previews."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
def import_registry_snapshot(hass: HomeAssistant) -> dict[str, set[str]]:
"""Return non-sensitive target inventory used only for preview counts."""
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
entities = list(er.async_get(hass).entities.values())
active_entity: set[str] = set()
disabled_entity: set[str] = set()
entities_by_device: dict[str, list[Any]] = {}
for entry in entities:
entity_id = str(entry.entity_id)
if getattr(entry, "disabled_by", None) is None:
active_entity.add(entity_id)
else:
disabled_entity.add(entity_id)
if entry.device_id:
entities_by_device.setdefault(str(entry.device_id), []).append(entry)
# Synthetic/runtime entities may legitimately have no registry row.
active_entity.update(str(state.entity_id) for state in hass.states.async_all())
active_device: set[str] = set()
disabled_device: set[str] = set()
for entry in dr.async_get(hass).devices.values():
device_id = str(entry.id)
children = entities_by_device.get(device_id, [])
disabled = getattr(entry, "disabled_by", None) is not None or (
bool(children)
and all(getattr(child, "disabled_by", None) is not None for child in children)
)
(disabled_device if disabled else active_device).add(device_id)
return {
"active_device": active_device,
"disabled_device": disabled_device,
"active_entity": active_entity,
"disabled_entity": disabled_entity - active_entity,
"areas": {str(entry.id) for entry in ar.async_get(hass).areas.values()},
}
-67
View File
@@ -1,67 +0,0 @@
"""Repair issues for House Plan.
The check runs at entry setup AND after every config save (ws_config_set),
so a plan file that goes missing — or gets re-uploaded — is reflected in the
Repairs UI without waiting for a restart.
"""
from __future__ import annotations
from pathlib import Path
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from .const import CONTENT_URL, DOMAIN, PLANS_DIR, PLANS_URL
from .store import HouseplanConfigEntry
async def async_check_plan_files(hass: HomeAssistant, entry: HouseplanConfigEntry) -> None:
"""Raise an issue for every space whose plan file is missing on disk."""
cfg_raw = await entry.runtime_data.config_store.async_load() or {}
spaces = cfg_raw.get("config", {}).get("spaces", [])
plans_dir = Path(hass.config.path(PLANS_DIR))
def _missing() -> list[tuple[str, str]]:
res = []
for sp in spaces:
url = sp.get("plan_url") or ""
# both the legacy static URL and the authenticated content URL
prefix = None
if url.startswith(PLANS_URL + "/"):
prefix = PLANS_URL + "/"
elif url.startswith(CONTENT_URL + "/plans/_/"):
prefix = CONTENT_URL + "/plans/_/"
if prefix is None:
continue # external/legacy URL — not ours to verify
fname = url[len(prefix) :].split("?", 1)[0]
if not (plans_dir / fname).is_file():
res.append((sp.get("id", "?"), fname))
return res
missing = await hass.async_add_executor_job(_missing)
broken = set()
for space_id, fname in missing:
broken.add(space_id)
ir.async_create_issue(
hass,
DOMAIN,
f"broken_plan_{space_id}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="broken_plan",
translation_placeholders={"space": space_id, "file": fname},
)
# Clear stale issues. Iterating the CURRENT spaces could only ever clear
# issues for spaces that still exist, so deleting or renaming a space with a
# missing plan left its warning in Repairs forever, with nothing left to fix
# it (HP-1454-09). Enumerate what we actually published instead.
registry = ir.async_get(hass)
stale = [
issue_id
for (domain, issue_id) in list(registry.issues)
if domain == DOMAIN
and issue_id.startswith("broken_plan_")
and issue_id[len("broken_plan_") :] not in broken
]
for issue_id in stale:
ir.async_delete_issue(hass, DOMAIN, issue_id)
-225
View File
@@ -1,225 +0,0 @@
"""Storage helpers: versioned stores and per-entry runtime data."""
from __future__ import annotations
import asyncio
import copy
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import (
DOMAIN,
STORAGE_CONFIG_KEY,
STORAGE_KEY,
STORAGE_MINOR_VERSION,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
)
_LOGGER = logging.getLogger(__name__)
_BG_MODES = frozenset({"static", "daynight"})
def migrate_config_background_mode(old_data: dict[str, Any]) -> dict[str, Any]:
"""Materialize the legacy implicit background mode without changing its view.
Only the config-store document has a top-level ``config`` object. Layout
and virtual-light stores pass through this helper unchanged even though
they share the same Store subclass and minor version.
"""
config = old_data.get("config")
if not isinstance(config, dict):
return old_data
settings = config.get("settings")
mode = settings.get("bg_mode") if isinstance(settings, dict) else None
if mode in _BG_MODES:
return old_data
data = copy.deepcopy(old_data)
migrated_config = data["config"]
migrated_settings = migrated_config.get("settings")
if not isinstance(migrated_settings, dict):
migrated_settings = {}
migrated_config["settings"] = migrated_settings
migrated_settings["bg_mode"] = "static"
return data
class HouseplanStore(Store):
"""Store with a migration hook.
Bump STORAGE_MINOR_VERSION for backward-compatible schema additions and
STORAGE_VERSION for breaking changes, then handle them here. Keeping the
skeleton in place from day one means old installations always pass through
a single, tested upgrade path.
"""
async def _async_migrate_func(
self,
old_major_version: int,
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
if old_major_version == 1 and old_minor_version < 2:
return migrate_config_background_mode(old_data)
return old_data
@dataclass
class HouseplanData:
"""Runtime data of the single config entry (entry.runtime_data)."""
store: HouseplanStore
config_store: HouseplanStore
virtual_light_store: HouseplanStore
# One lock for every load→modify→save cycle of both stores: prevents
# lost updates from concurrent WS calls and makes the rev check atomic.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# A separate, narrower lock for the check-quota→write-file pair of an
# upload. Without it N parallel uploads all measure the store BEFORE any
# of them writes, and all pass a quota only one of them fits under
# (HP-1490-02). Separate from write_lock so a slow directory scan does not
# stall config/layout commits.
upload_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Collect files nothing references any more. Set during setup, which also
# runs it once and schedules it daily. Exposed so it can be invoked
# directly — a test that fakes a 24 h jump proves the timer fires, not that
# the work happens, and those are different claims.
sweep: Callable[[], Awaitable[None]] | None = None
# Stable HA instance id used only through a one-way export fingerprint.
instance_id: str = ""
# 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)
HouseplanConfigEntry = ConfigEntry[HouseplanData]
def create_data(hass: HomeAssistant) -> HouseplanData:
"""Create the stores for a config entry."""
return HouseplanData(
store=HouseplanStore(hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_MINOR_VERSION),
config_store=HouseplanStore(
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
),
virtual_light_store=HouseplanStore(
hass,
STORAGE_VERSION,
STORAGE_VIRTUAL_LIGHTS_KEY,
minor_version=STORAGE_MINOR_VERSION,
),
)
def get_data(hass: HomeAssistant) -> HouseplanData | None:
"""Runtime data of the loaded entry, or None when not set up."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return entries[0].runtime_data if entries else None
def get_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""The loaded config entry, or None."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return entries[0] if entries else None
OPTIMIZE_BACKUP = "optimize_backup"
OPTIMIZE_PENDING = "optimize_pending"
LAYOUT_STORE_CORE_KEYS = frozenset({"layout", "rev"})
def layout_store_payload(
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Build one layout-store write without silently dropping metadata.
Layout used to be saved by several independent dict comprehensions. Every
new metadata key therefore had to be added to every caller or was lost on
the next drag. All writers now express only the metadata they intentionally
add/remove and this helper preserves the rest.
"""
excluded = {*LAYOUT_STORE_CORE_KEYS, *remove}
out = {} if replace_metadata else {
key: value for key, value in stored.items() if key not in excluded
}
if metadata:
out.update(metadata)
out["layout"] = layout
out["rev"] = rev
return out
async def async_save_layout_state(
runtime: HouseplanData,
stored: dict[str, Any],
layout: dict[str, Any],
rev: int,
*,
metadata: dict[str, Any] | None = None,
remove: tuple[str, ...] = (),
replace_metadata: bool = False,
) -> dict[str, Any]:
"""Persist layout and return the exact store document written."""
payload = layout_store_payload(
stored,
layout,
rev,
metadata=metadata,
remove=remove,
replace_metadata=replace_metadata,
)
await runtime.store.async_save(payload)
return payload
async def async_save_config_state(
runtime: HouseplanData,
config: dict[str, Any],
rev: int,
*,
previous_rev: int | None = None,
) -> dict[str, Any]:
"""Persist configuration and reconcile dependent operational state.
Callers already hold ``runtime.write_lock``. Reading the previous
revision here keeps less common writers (import recovery and undo) on the
same path as ordinary editor saves without duplicating lifecycle rules.
"""
if previous_rev is None:
previous = await runtime.config_store.async_load() or {}
try:
previous_rev = int(previous.get("rev", 0))
except (TypeError, ValueError):
previous_rev = 0
payload = {"config": config, "rev": rev}
await runtime.config_store.async_save(payload)
# The config is already durable at this point. Reconciliation remains a
# separate Store write; an interrupted pair is detected from config_rev on
# the next read and fails safe to the compatibility default (all on).
from .virtual_lights import async_reconcile_virtual_lights
try:
await async_reconcile_virtual_lights(
runtime.virtual_light_store,
config,
rev,
previous_config_rev=previous_rev,
)
except Exception: # noqa: BLE001 - config commit already stands
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
return payload
-24
View File
@@ -1,24 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": { "admin_only": "Only administrators may edit the layout" }
}
},
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
},
"options": {
"step": {
"init": {
"data": { "admin_only": "Only administrators may edit the layout" }
}
}
},
"issues": {
"broken_plan": {
"title": "Floor plan image is missing",
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
}
}
}
@@ -1,35 +0,0 @@
"""System health for House Plan (Settings → System → Repairs → System information)."""
from __future__ import annotations
from typing import Any
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .store import get_data
@callback
def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None:
"""Register the system health info callback."""
register.async_register_info(system_health_info)
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Return integration health info."""
data = get_data(hass)
if data is None:
return {"status": "not set up"}
cfg_raw = await data.config_store.async_load() or {}
layout_raw = await data.store.async_load() or {}
config = cfg_raw.get("config", {})
return {
"config_rev": cfg_raw.get("rev", 0),
"spaces": len(config.get("spaces", [])),
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
"room_drafts": sum(len(s.get("room_drafts", [])) for s in config.get("spaces", [])),
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
"markers": len(config.get("markers", [])),
"layout_entries": len(layout_raw.get("layout", {})),
}
-333
View File
@@ -1,333 +0,0 @@
"""Server-side vacuum trails.
The integration records the robot's path ITSELF by watching the source
entity's state changes — no card involvement. This removes every client-side
race (N open tabs would fight over writes), survives page reloads by
construction, and keeps recording while no card is open at all. Stored: the
current run and one previous run per marker (owner call 2026-07-31 — users
want to see where the cleanup has already been).
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
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
SAVE_DELAY_S = 10 # debounce store writes — flash wear over precision
FIRE_THROTTLE_S = 2.0 # event-bus updates for live cards
MOVING_STATES = {"cleaning", "returning", "on"}
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
"""Map-id normalisation contract, shared with the frontend.
Mirrors src/vacuum.ts vacMapIdFromAttrs (source attrs, `??`-chain) plus the
card's _vacMapId fallback to the vacuum entity's selected_map. The FIRST
value that is not None wins — truthiness is wrong here: a zero-based
`map_index: 0` is a valid first map and an empty string is still an id.
The old `or`-chain dropped the zero, so the server stored trails under a
key the renderer never looked up (HP-1540-02).
"""
for v in (
src_attrs.get("map_name"),
src_attrs.get("current_map"),
src_attrs.get("map_index"),
src_attrs.get("selected_map"),
vac_attrs.get("selected_map"),
):
if v is not None:
return str(v)
return "default"
class TrailBook:
"""Pure run bookkeeping: {marker: {current: run, previous: run}}.
A run is {"map_id", "started", "ended", "points": [[x, y], …]} in RAW
robot coordinates — recalibration never invalidates a stored trail.
"""
def __init__(self, data: dict[str, Any] | None = None) -> None:
self.data: dict[str, Any] = data if isinstance(data, dict) else {}
def on_point(self, marker: str, map_id: str, x: float, y: float, now: float) -> bool:
rec = self.data.setdefault(marker, {})
cur = rec.get("current")
if not cur or cur.get("ended") or cur.get("map_id") != map_id:
# a new run begins: the old one becomes "previous" (and the one
# before it is forgotten — we keep exactly two, per the owner)
if cur:
rec["previous"] = cur
cur = {"map_id": map_id, "started": now, "ended": None, "points": []}
rec["current"] = cur
pts: list[list[float]] = cur["points"]
if pts and pts[-1][0] == x and pts[-1][1] == y:
return False
pts.append([x, y])
if len(pts) > TRAIL_CAP:
# decimate by two but never lose the freshest point
half = pts[0::2]
if half[-1] != pts[-1]:
half.append(pts[-1])
cur["points"] = half
return True
def end_run(self, marker: str, now: float) -> bool:
cur = (self.data.get(marker) or {}).get("current")
if cur and not cur.get("ended"):
cur["ended"] = now
return True
return False
def delete(self, marker: str) -> bool:
"""Forget every stored run of one plan marker."""
return self.data.pop(marker, None) is not None
class TrailRecorder:
"""HA wiring: watch the tracked entities, feed the book, persist, notify."""
def __init__(self, hass: HomeAssistant, rt: Any) -> None:
self.hass = hass
self.rt = rt
self.store = Store(hass, 1, f"{DOMAIN}.trails")
self.book = TrailBook()
# HP-1540-03: one source may feed SEVERAL markers — the same robot
# placed on two floors is the documented multi-floor case, and a plain
# source → (marker, vacuum) dict silently kept only the last one
self.pairs: dict[str, list[tuple[str, str]]] = {} # source → [(marker, vacuum), …]
self._unsub_track = None
self._unsub_save = None
self._last_fire = 0.0
# One active incident per saved marker/source. `reason` is mutable so
# missing↔disabled changes do not create warning storms.
self._source_health: dict[tuple[str, str], str] = {}
# HP-1540-05: config/set fires refresh as a detached task; two of them
# interleaving across the awaited load both subscribed and the loser's
# unsub handle was overwritten — a leak until HA restart
self._refresh_lock = asyncio.Lock()
self._closed = False
async def async_setup(self) -> None:
self.book = TrailBook(await self.store.async_load() or {})
await self.async_refresh()
async def async_refresh(self) -> None:
"""(Re)subscribe after any config change — markers may come and go.
Serialised (HP-1540-05): the lock makes unsubscribe-then-resubscribe
atomic across the awaited config load, so overlapping refresh tasks can
no longer both subscribe and strand one callback forever. The _closed
check covers teardown() racing a refresh that is parked on its await.
"""
async with self._refresh_lock:
stored = await self.rt.config_store.async_load() or {}
if self._closed:
return
cfg = stored.get("config") or {}
pairs: dict[str, list[tuple[str, str]]] = {}
health_pairs: set[tuple[str, str]] = set()
for m in cfg.get("markers") or []:
if m.get("removed") is True:
continue
v = m.get("vacuum") or {}
src = v.get("source")
if not src or v.get("live") is False:
continue
marker_id = str(m.get("id"))
health_pairs.add((marker_id, str(src)))
vac = self._vacuum_entity(m)
if vac:
# HP-1540-03: append, never overwrite — every floor's
# marker records its own copy of the run
pairs.setdefault(src, []).append((marker_id, vac))
self._refresh_source_health(health_pairs)
self.pairs = pairs
self._resubscribe()
# A run already in progress (HA restarted mid-cleanup, or the user
# just finished calibrating) must start recording NOW, not at the
# next state change — otherwise the first seconds of the path are
# lost.
for src in self.pairs:
self._sample(src, time.time())
def _source_failure_reason(self, source: str) -> str | None:
"""Classify only refresh-time health evidence.
A registry row or exact live state proves existence. No registry access
is neutral: it can neither create a loss incident nor recover one.
"""
registry = er.async_get(self.hass)
state = self.hass.states.get(source)
if registry is None or not hasattr(registry, "async_get"):
return None if state is not None else "unverified"
entry = registry.async_get(source)
if entry is not None and getattr(entry, "disabled_by", None) is not None:
return "disabled"
# Registry-less YAML entities are valid: exact live state is stronger
# evidence than a missing registry row.
if entry is not None or state is not None:
return None
return "missing"
def _refresh_source_health(self, expected: set[tuple[str, str]]) -> None:
"""Refresh deduplicated source incidents during config refresh/restart.
`unavailable` and unsupported-but-existing states count as proven
recovery. There is intentionally no registry subscription in Stage 1;
the next config refresh or restart observes a later transition.
"""
for key in list(self._source_health):
if key not in expected:
del self._source_health[key]
for marker_id, source in sorted(expected):
key = (marker_id, source)
reason = self._source_failure_reason(source)
previous = self._source_health.get(key)
# Limited/unavailable registry evidence is neutral: keep an
# existing incident as-is, and never create or recover one.
if reason == "unverified":
continue
if reason is None:
if previous is not None:
_LOGGER.info(
"Vacuum source recovered: marker=%s source=%s (was %s)",
marker_id, source, previous,
)
del self._source_health[key]
continue
if previous is None:
_LOGGER.warning(
"Vacuum source %s: marker=%s source=%s",
reason, marker_id, source,
)
self._source_health[key] = reason
async def async_delete(self, marker: str) -> bool:
"""Stop and erase one marker without racing subscription refresh/save."""
async with self._refresh_lock:
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = self.book.delete(marker)
if not removed:
return False
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] != marker]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
await self.store.async_save(self.book.data)
self.hass.bus.async_fire("houseplan_trail_updated", {})
return True
def _resubscribe(self) -> None:
"""Replace the state subscription for the current pair graph."""
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
# deduplicated: two markers of one robot share source AND vacuum
ents = set(self.pairs) | {vac for ps in self.pairs.values() for _, vac in ps}
_LOGGER.info("Trail recorder: tracking %s", sorted(ents))
if ents and not self._closed:
self._unsub_track = async_track_state_change_event(
self.hass, sorted(ents), self._on_state
)
def teardown(self) -> None:
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
# not re-subscribe after this cleanup has already run
self._closed = True
if self._unsub_track:
self._unsub_track()
self._unsub_track = None
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
b = str(m.get("binding") or "")
if b.startswith("entity:vacuum."):
return b[len("entity:"):]
if b.startswith("device:"):
reg = er.async_get(self.hass)
for e in er.async_entries_for_device(reg, b[len("device:"):]):
if e.entity_id.startswith("vacuum."):
return e.entity_id
return None
def _sample(self, src: str, now: float) -> bool:
"""Record one point (or end the run) for EVERY marker fed by src.
HP-1540-03: the same source serves one marker per floor — all of them
must receive the point, not just whichever survived the dict.
"""
changed = False
for marker, vac in self.pairs.get(src) or ():
st_vac = self.hass.states.get(vac)
# "no state yet" is NOT "stopped": during HA boot the vacuum reads
# unavailable and ending the run here would split one cleanup into
# current+previous on every restart (observed live: 21 points
# became previous, the same run restarted at 5)
if not st_vac or st_vac.state in ("unavailable", "unknown"):
continue
if st_vac.state not in MOVING_STATES:
changed |= self.book.end_run(marker, now)
continue
st_src = self.hass.states.get(src)
attrs = st_src.attributes if st_src else {}
raw = attrs.get("vacuum_position") or attrs.get("robot_position")
# Server-side these attributes are often OBJECTS (Tasshack keeps a
# Point dataclass in memory — it only becomes a dict when
# serialised to the frontend). Caught live on the owner's X50: the
# recorder saw every state change and rejected every single one.
if isinstance(raw, dict):
px, py = raw.get("x"), raw.get("y")
else:
px, py = getattr(raw, "x", None), getattr(raw, "y", None)
try:
x, y = float(px), float(py) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
map_id = resolve_map_id(attrs, st_vac.attributes)
changed |= self.book.on_point(marker, map_id, x, y, now)
return changed
@callback
def _on_state(self, event: Any) -> None:
eid = event.data.get("entity_id")
now = time.time()
changed = False
for src, pair_list in self.pairs.items():
if eid == src or any(eid == vac for _, vac in pair_list):
changed |= self._sample(src, now)
if changed:
self._schedule_save()
if now - self._last_fire >= FIRE_THROTTLE_S:
self._last_fire = now
self.hass.bus.async_fire("houseplan_trail_updated", {})
def _schedule_save(self) -> None:
if self._unsub_save:
return
async def _save(_now: Any) -> None:
self._unsub_save = None
await self.store.async_save(self.book.data)
self._unsub_save = async_call_later(self.hass, SAVE_DELAY_S, _save)
@@ -1,30 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": {
"admin_only": "Only administrators may edit the layout"
}
}
},
"abort": {
"single_instance_allowed": "Already configured — only one entry is allowed."
}
},
"options": {
"step": {
"init": {
"data": {
"admin_only": "Only administrators may edit the layout"
}
}
}
},
"issues": {
"broken_plan": {
"title": "Floor plan image is missing",
"description": "The plan file `{file}` for space `{space}` was not found in `config/houseplan/plans/`. Open the space settings in the House Plan card and upload the plan again."
}
}
}
@@ -1,30 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "House Plan",
"data": {
"admin_only": "Правка раскладки только администраторами"
}
}
},
"abort": {
"single_instance_allowed": "Уже настроено — допускается одна запись."
}
},
"options": {
"step": {
"init": {
"data": {
"admin_only": "Правка раскладки только администраторами"
}
}
}
},
"issues": {
"broken_plan": {
"title": "Файл плана этажа не найден",
"description": "Файл плана `{file}` пространства `{space}` не найден в `config/houseplan/plans/`. Откройте настройки пространства в карточке House Plan и загрузите план заново."
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,125 +0,0 @@
"""Persistent operational state for manual virtual lights."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .store import HouseplanStore
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
def is_manual_virtual_light(marker: Any) -> bool:
"""Return whether a marker uses the exact persistent manual-light mode."""
return (
isinstance(marker, dict)
and isinstance(marker.get("id"), str)
and bool(marker["id"])
and marker.get("binding") == "virtual"
and marker.get("is_light") is True
and marker.get("tap_action") == "toggle"
and marker.get("removed") is not True
)
def eligible_virtual_light_ids(config: Any) -> set[str]:
"""Collect live marker ids eligible for persistent manual state."""
if not isinstance(config, dict):
return set()
markers = config.get("markers")
if not isinstance(markers, list):
return set()
return {marker["id"] for marker in markers if is_manual_virtual_light(marker)}
def _integer(value: Any, default: int = 0) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return max(0, parsed)
def _read_state(stored: Any) -> tuple[int, int, set[str]]:
if not isinstance(stored, dict):
return 0, 0, set()
raw_off = stored.get("off")
off = (
{item for item in raw_off if isinstance(item, str) and item}
if isinstance(raw_off, list)
else set()
)
return _integer(stored.get("rev")), _integer(stored.get("config_rev")), off
def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
async def async_virtual_light_snapshot(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
) -> dict[str, Any]:
"""Return a coherent snapshot, repairing stale or interrupted state.
A revision gap means an older writer may have changed eligibility without
knowing about this Store. Clearing every manual-off bit is conservative:
it restores the pre-feature/default-on behaviour and cannot resurrect an
old off state for a marker whose role changed in the meantime.
"""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_reconcile_virtual_lights(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
*,
previous_config_rev: int,
) -> dict[str, Any]:
"""Carry eligible state across one known configuration transition."""
stored = await store.async_load() or {}
rev, state_config_rev, stored_off = _read_state(stored)
eligible = eligible_virtual_light_ids(config)
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
if off != stored_off:
rev += 1
payload = _wire(rev, config_rev, off)
if payload != stored:
await store.async_save(payload)
return payload
async def async_toggle_virtual_light(
store: HouseplanStore,
config: dict[str, Any],
config_rev: int,
marker_id: str,
) -> dict[str, Any] | None:
"""Atomically invert one eligible marker and persist before returning."""
if marker_id not in eligible_virtual_light_ids(config):
return None
snapshot = await async_virtual_light_snapshot(store, config, config_rev)
off = set(snapshot["off"])
if marker_id in off:
off.remove(marker_id)
else:
off.add(marker_id)
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
await store.async_save(payload)
return {
"marker_id": marker_id,
"on": marker_id not in off,
"rev": payload["rev"],
}
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
# Synthetic demo home
A fully fictional house (plans, devices, states) used for README screenshots,
the demo GIF and headless smoke tests — so no real home data ever appears in
public materials.
- `srv/demo.html` — self-contained host page: `<ha-icon>`/`<ha-card>` stubs and a
fake `hass` (registries, states, `callWS`, `callService`, floors).
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
needs the repo's devDependencies). The card bundle is copied from `dist/`:
`cp dist/houseplan-card.js demo/srv/assets/`.
- `serve.mjs` — playwright launcher (route interception, no web server).
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
Note for sandboxed sessions: `/tmp` does not survive; this directory is the
persistent home of the harness (docs/DEVELOPMENT.md has the LD_LIBRARY_PATH recipe).
-300
View File
@@ -1,300 +0,0 @@
#!/usr/bin/env node
/** Isolated 1/10/30/60-pool performance profiles for #19 and #55. */
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
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 } 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'].includes(profile))
throw new Error(`unknown Glow profile: ${profile}`);
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));
const warmups = Math.max(0, Math.min(5, Number.isFinite(parsedWarmups) && parsedWarmups >= 0 ? parsedWarmups : 1));
const requestedVariants = valueArg('variants')?.split(',').map(Number);
if (requestedVariants?.some((count) => ![1, 10, 30, 60].includes(count)))
throw new Error(`invalid Glow variants: ${valueArg('variants')}`);
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const additiveFixture = JSON.parse(readFileSync(
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
));
additiveFixture.sourceIds = Object.keys(additiveFixture.ha.states)
.filter((entityId) => entityId.startsWith('light.'));
additiveFixture.roomCount = additiveFixture.config.spaces
.reduce((sum, space) => sum + space.rooms.length, 0);
additiveFixture.deviceCount = Object.keys(additiveFixture.ha.devices).length;
const makeOverlayFixture = () => {
const large = makeLargeHouseFixture();
const firstSpace = large.config.spaces[0].id;
const sourceDeviceIds = Object.entries(large.layout)
.filter(([, position]) => position.s === firstSpace)
.slice(0, 60)
.map(([deviceId]) => deviceId);
const sourceIds = [];
sourceDeviceIds.forEach((deviceId, index) => {
for (const [entityId, entity] of Object.entries(large.entities)) {
if (entity.device_id !== deviceId) continue;
delete large.entities[entityId];
delete large.states[entityId];
}
const entityId = `light.glow_overlay_${String(index + 1).padStart(3, '0')}`;
large.entities[entityId] = {
entity_id: entityId, device_id: deviceId, platform: 'houseplan_perf',
config_entry_id: 'perf_entry', disabled_by: null,
};
large.states[entityId] = {
entity_id: entityId, state: 'on',
attributes: {
friendly_name: `Overlay light ${index + 1}`,
brightness: 96 + (index % 5) * 32,
rgb_color: index % 2 ? [255, 154, 72] : [92, 156, 255],
},
};
sourceIds.push(entityId);
});
// The shared large-house fixture already contains a few ordinary lights.
// Keep them as devices but turn them off so the profile's pool cardinality
// is exactly the declared 1/10/30/60, not N plus an unrelated background lamp.
for (const [entityId, state] of Object.entries(large.states)) {
if (entityId.startsWith('light.') && !sourceIds.includes(entityId)) {
large.states[entityId] = { ...state, state: 'off' };
}
}
for (const space of large.config.spaces) {
space.settings = { ...(space.settings || {}), fill_mode: 'temp', glow_enabled: true };
}
return {
fixture: 'large-house-glow-overlay-v1', variants: [1, 10, 30, 60],
config: large.config, layout: large.layout,
ha: { devices: large.devices, entities: large.entities, areas: large.areas, states: large.states },
sourceIds,
roomCount: large.counts.rooms,
deviceCount: large.counts.devices,
};
};
const fixture = profile === 'large-light-blend-v1' ? additiveFixture : makeOverlayFixture();
if (requestedVariants?.length) fixture.variants = [...new Set(requestedVariants)];
const viewport = { width: 1280, height: 900 };
const { page, browser } = await launch(
viewport, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{}, resolve(targetRoot, 'demo/srv'),
);
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const sample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, profile, sample, cardContract }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('Glow benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const observeLongTasks = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask'))
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return { stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const values = entries.map((entry) => entry.duration);
return {
supported: true,
count: values.length,
maxMs: Number((values.length ? Math.max(...values) : 0).toFixed(2)),
totalMs: Number(values.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
}};
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc(); await frame(); globalThis.gc(); await frame();
return true;
};
const configFor = () => {
const config = structuredClone(fixture.config);
if (profile === 'large-light-blend-v1') {
const settings = config.spaces[0].settings;
settings.fill_mode = 'glow';
delete settings.glow_enabled;
}
return config;
};
const statesFor = (count, brightnessDelta = 0) => {
const active = new Set(fixture.sourceIds.slice(0, count));
const sources = new Set(fixture.sourceIds);
return Object.fromEntries(Object.entries(fixture.ha.states).map(([entityId, state]) => {
if (!sources.has(entityId)) return [entityId, state];
return [entityId, {
...state,
state: active.has(entityId) ? 'on' : 'off',
attributes: {
...state.attributes,
brightness: Math.max(1, Math.min(255, Number(state.attributes.brightness) + brightnessDelta)),
},
}];
}));
};
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
language: 'en', locale: { language: 'en' },
user: { id: 'glow-perf', name: 'Glow performance', is_admin: true },
devices: fixture.ha.devices, entities: fixture.ha.entities,
areas: fixture.ha.areas, states, floors: {}, connection,
callWS: async (message) => {
if (message.type === 'houseplan/config/get')
return { config: configFor(), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.ha.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.ha.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'glow_fixture', domain: 'houseplan_fixture', title: 'Glow fixture' }];
if (message.type === 'manifest/list')
return [{ domain: 'houseplan_fixture', name: 'House Plan Glow Fixture' }];
return { ok: true };
},
callService: async () => undefined,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const cacheSnapshot = (card) => ({
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 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));
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)
await new Promise((done) => setTimeout(done, 10));
}
await card.updateComplete;
await frame();
for (const count of fixture.variants) {
// Mount cost is not part of this profile. Prime each source-count
// state on the same full plan, then measure only the following HA tick.
card.hass = hassFor(statesFor(count));
await card.updateComplete;
await frame();
let renders = 0;
const originalUpdate = card.performUpdate.bind(card);
card.performUpdate = () => { renders++; return originalUpdate(); };
const longTasks = observeLongTasks();
const started = performance.now();
card.hass = hassFor(statesFor(count, 1));
await card.updateComplete;
await frame();
result[`stateUpdate${count}Ms`] = Number((performance.now() - started).toFixed(2));
result.longTasks[`stateUpdate${count}`] = await longTasks.stop();
result.renderCounts[count] = renders;
result.poolCounts[count] = card.renderRoot.querySelectorAll('.glow-pool, .glowlayer circle').length;
}
window.__card = card;
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let index = 0; index < 5; index++) {
card.hass = hassFor(statesFor(60, index % 2));
await card.updateComplete;
await frame();
}
await forceGc();
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
result.cacheEntries = cacheEntries;
result.cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
result.heapGrowthBytes = heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore;
result.preciseGc = typeof globalThis.gc === 'function';
result.renderedDevices = card._devices?.length ?? 0;
result.screenBlend = card._glowScreenBlend === true;
return result;
}, { fixture, profile, sample, cardContract: GLOW_CARD_CONTRACT });
const captureStarted = performance.now();
await page.screenshot({ type: 'png' });
row.screenshotCaptureMs = Number((performance.now() - captureStarted).toFixed(2));
if (sample >= 0) rows.push(row);
}
} finally {
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => undefined);
await browser.close();
}
const metricNames = [
...fixture.variants.map((count) => `stateUpdate${count}Ms`), 'screenshotCaptureMs',
];
const report = {
schema: 2,
profile,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version, chromium, platform: process.platform, arch: process.arch,
viewport, deviceScaleFactor: 1, cpuThrottleRate: 4, reducedMotion: true,
},
fixture: {
id: fixture.fixture, variants: fixture.variants,
rooms: fixture.roomCount, devices: fixture.deviceCount,
},
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else process.stdout.write(text);
-464
View File
@@ -1,464 +0,0 @@
#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const planSnap = profile === 'large-house-plan-snap-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.room_drafts = [0, 1].map((draft) => {
const y = 0.985 + draft * 0.025;
return {
id: `perf-draft-${floor}-${draft}`,
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
segments: [{ cm: 15 }, { cm: 20 }],
};
});
}
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
viewport,
1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{},
resolve(targetRoot, 'demo/srv'),
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const startLongTaskWindow = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
}
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return {
stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const durations = entries.map((entry) => entry.duration);
return {
supported: true,
count: durations.length,
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
},
};
};
const duration = async (action) => {
const longTasks = startLongTaskWindow();
const started = performance.now();
await action();
await frame();
return {
ms: Number((performance.now() - started).toFixed(2)),
longTasks: await longTasks.stop(),
};
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc();
await frame();
globalThis.gc();
await frame();
return true;
};
const cacheSnapshot = (card) => ({
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,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
});
window.__card?.remove?.();
localStorage.clear();
if (isometric) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
fixture.config.spaces.map((space) => [space.id, 'iso']),
)));
history.replaceState(null, '', '?hp-labs=iso');
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
let wsCalls = 0;
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassFor = (states) => ({
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas, states,
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
wsCalls++;
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
return { ok: true };
},
callService: async () => undefined,
connection,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
});
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
if (requiresIsometric) {
if (typeof card._onLabsSnapshot !== 'function')
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
// The product flag expires at 1.65.0. Performance keeps exercising
// the dormant renderer without changing the public registry contract.
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
|| !(card._isoGeometryCache instanceof Map))) {
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
}
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
await card.updateComplete;
await frame();
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
await until(() => card._booting === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const loadLongTaskResult = await loadLongTasks.stop();
const viewToggle = isometric ? await duration(async () => {
if (typeof card._setProjection === 'function') {
card._setProjection('flat');
await card.updateComplete;
card._setProjection('iso');
await card.updateComplete;
} else {
// Comparison SHAs before #89 intentionally ignore the Labs operation.
card.requestUpdate();
await card.updateComplete;
}
}) : null;
const spaceSwitch = await duration(async () => {
card._pickSpace('perf-floor-2');
await card.updateComplete;
});
const firstEntity = Object.keys(fixture.states)[0];
const nextStates = {
...fixture.states,
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
};
const stateUpdate = await duration(async () => {
card.hass = hassFor(nextStates);
await card.updateComplete;
});
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage');
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
const cacheValue = card._planSnapGeometryCache?.value ?? null;
const configBefore = JSON.stringify(card._serverCfg);
const callsBefore = wsCalls;
const view = card._viewOr(card._baseVb());
const rect = stage.getBoundingClientRect();
const fromPlan = (x, y) => ({
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
});
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
.map((line) => ({
line,
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
}))
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
const points = [
firstEndpoint
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
: [40, 40],
longLine
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
: [120, 40],
[10, 10],
];
const seenKinds = new Set();
for (let index = 0; index < 120; index++) {
const point = points[index % points.length];
stage.dispatchEvent(new PointerEvent('pointermove', {
...fromPlan(point[0], point[1]),
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
}));
await card.updateComplete;
const active = card.renderRoot.querySelector(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
);
if (active) seenKinds.add(active.getAttribute('data-kind'));
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
}
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
planSnapDiagnostics = {
supported: requiresPlanSnap,
staticLines,
staticNodes,
activeKinds: [...seenKinds].sort(),
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
=== staticNodes,
configStable: JSON.stringify(card._serverCfg) === configBefore,
wsWrites: wsCalls - callsBefore,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
card._setMode('view');
await card.updateComplete;
}) : null;
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
await card.updateComplete;
const room = card._rszRooms()[0];
const pointerId = 777;
const quietEvent = {
pointerId,
stopPropagation: () => undefined,
preventDefault: () => undefined,
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
const plan = card._rszDrag?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
plan.a[1] + plan.n[1] * card._gridPitch,
];
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
card._rszMove({
...quietEvent,
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
});
await card.updateComplete;
card._rszCancelDrag();
card._setMode('view');
await card.updateComplete;
});
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const panZoom = await duration(async () => {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
await card.updateComplete;
});
const settingsDialog = await duration(async () => {
card._openSettingsDialog();
await card.updateComplete;
});
card._settingsDialog = null;
await card.updateComplete;
const switchCycle = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
// A user cannot produce twelve tab clicks in one JavaScript task.
// Yield between interactions so Long Task entries describe one
// switch, while switchCycleMs still measures the complete cycle.
await new Promise((done) => setTimeout(done, 0));
}
});
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let round = 0; round < 4; round++) {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
await new Promise((done) => setTimeout(done, 0));
}
await forceGc();
}
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
const result = {
sample,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
panZoomMs: panZoom.ms,
settingsDialogMs: settingsDialog.ms,
switchCycleMs: switchCycle.ms,
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
panZoom: panZoom.longTasks,
settingsDialog: settingsDialog.longTasks,
switchCycle: switchCycle.longTasks,
},
cacheEntries,
cacheGrowth,
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
preciseGc: typeof globalThis.gc === 'function',
renderedDevices: card._devices?.length ?? 0,
};
card.remove();
await frame();
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap,
});
if (measuredSample >= 0) rows.push(row);
}
} finally {
await browser.close();
}
const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
const report = {
schema: 2,
profile,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version,
chromium,
platform: process.platform,
arch: process.arch,
viewport,
deviceScaleFactor: 1,
reducedMotion: true,
},
fixture: LARGE_HOUSE_COUNTS,
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else {
process.stdout.write(text);
}
-31
View File
@@ -1,31 +0,0 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
const fingerprintForTree = async (root) => {
const modulePath = resolve(root, 'scripts/source-fingerprint.mjs');
if (!existsSync(modulePath)) return sourceFingerprint(root);
const module = await import(pathToFileURL(modulePath).href);
if (typeof module.sourceFingerprint !== 'function') {
throw new Error(`${modulePath} does not export sourceFingerprint`);
}
return module.sourceFingerprint(root);
};
/** Refuse measurements/screenshots made by a committed bundle from old source. */
export async function assertFreshDemoBundle(page, root = process.cwd()) {
// A comparative performance run may load an older tree whose fingerprint
// contract is intentionally different from the candidate's. Validate that
// tree with the implementation that built it, not with today's algorithm.
const expected = await fingerprintForTree(root);
const loaded = await page.evaluate(() => globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__ ?? null);
if (loaded !== expected) {
throw new Error(
'demo/srv/assets/houseplan-card.js is stale. Run npm run build and copy '
+ 'dist/houseplan-card.js to demo/srv/assets/houseplan-card.js first. '
+ `Expected ${expected}, loaded ${loaded || 'no fingerprint'}.`,
);
}
return expected;
}
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
import { launch } from '../serve.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const OUTPUT = resolve(ROOT, 'docs/images');
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
const SCRIPT = fileURLToPath(import.meta.url);
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
export const DOC_SCREENSHOT_VERSION = 1;
export const DOC_SCREENSHOTS = Object.freeze([
{
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 390, height: 760 }, capture: 'page',
},
{
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
title: 'House Plan', language: 'en', theme: 'dark',
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
},
{
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan',
wallJunctionPreview: {
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
},
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
},
{
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'page',
},
{
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', roomMetrics: true,
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1180, height: 900 }, capture: 'room-card',
},
{
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
},
]);
const roomCardClip = (page) => page.evaluate(() => {
const card = window.__goldenCard;
const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
if (!target) throw new Error('documentation room card is missing');
const rect = target.getBoundingClientRect();
const marginX = 80;
const marginY = 70;
return {
x: Math.max(0, rect.left - marginX),
y: Math.max(0, rect.top - marginY),
width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
};
});
/**
* Documentation-only presentation state. Keep these mutations out of the
* golden harness: changing that release fixture would invalidate every visual
* baseline even though the production component and golden matrix are intact.
*/
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}`);
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
}
if (current.roomMetrics) {
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
space.settings = {
...(space.settings || {}),
label_temp: true,
label_hum: true,
label_lqi: true,
label_light: true,
};
card._cfgEpoch += 1;
card._modelCache = null;
}
if (current.fixture === 'empty') {
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
card._cfgEpoch += 1;
card._modelCache = null;
card._space = '';
card._onboardingShown = true;
card.hass = { ...card.hass, floors: {} };
card._openSpaceDialog('create');
}
if (current.dialog === 'device-info') {
const device = card._devices.find((item) => item.id === current.deviceId);
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
card._infoCard = device;
}
card.requestUpdate();
await card.updateComplete;
await frame();
if (current.devicePresentationPreview) {
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
const preview = dialog?.querySelector('hp-device-preview');
await preview?.updateComplete;
if (!body || !preview)
throw new Error('documentation device presentation preview is missing');
const bodyRect = body.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
body.scrollTop += previewRect.top - bodyRect.top - 180;
await frame();
const visibleBody = body.getBoundingClientRect();
const visiblePreview = preview.getBoundingClientRect();
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
throw new Error('documentation viewport does not show the device presentation preview');
}
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
mkdirSync(OUTPUT, { recursive: true });
copyFileSync(BUNDLE, DEMO_BUNDLE);
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
const { page, browser } = await launch();
const browserErrors = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
try {
const fingerprint = await assertFreshDemoBundle(page, ROOT);
const scenarios = {};
for (const scenario of DOC_SCREENSHOTS) {
await prepareGoldenScenario(page, scenario);
const runtime = await applyDocumentationState(page, scenario);
if (scenario.expectDialog && !runtime.dialog)
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
const clip = scenario.capture === 'room-card'
? await roomCardClip(page)
: await goldenClip(page, scenario.capture);
const image = await page.screenshot({
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
});
writeFileSync(resolve(OUTPUT, scenario.file), image);
scenarios[scenario.id] = {
file: scenario.file,
viewport: scenario.viewport,
theme: scenario.theme,
language: scenario.language,
sourceSha256: fingerprint,
imageSha256: sha256(image),
};
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
}
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
const manifest = {
version: DOC_SCREENSHOT_VERSION,
fixture: 'synthetic-only',
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();
}
-59
View File
@@ -1,59 +0,0 @@
// AC-13 for #157. Usage:
// node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/dist/houseplan-card.js
// The v1.64.0 frontend does not understand `passage`; this executable fixture
// pins its documented best-effort fallback (door symbol) and, critically,
// rejects any pageerror/unhandled exception while reading the newer literal.
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
const value = process.argv.find((arg) => arg.startsWith('--bundle='))?.slice('--bundle='.length);
if (!value) {
console.error('usage: node demo/downgrade_open_passage.mjs --bundle=/absolute/v1.64.0/houseplan-card.js');
process.exit(2);
}
const bundle = isAbsolute(value) ? value : resolve(value);
if (!existsSync(bundle)) {
console.error(`v1.64.0 bundle not found: ${bundle}`);
process.exit(2);
}
const currentDemo = fileURLToPath(new URL('./srv', import.meta.url));
const serveRoot = mkdtempSync(join(tmpdir(), 'hp-157-downgrade-'));
let browser;
try {
cpSync(currentDemo, serveRoot, { recursive: true });
cpSync(bundle, join(serveRoot, 'assets', 'houseplan-card.js'));
const launched = await launch(undefined, 1, [], {}, serveRoot);
browser = launched.browser;
const out = await launched.page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
space.openings = [{
id: 'future-passage', type: 'passage', x: 0.3, y: 0.14,
angle: 0, length: 0.09, future_material: 'stone',
}];
card._setMode('plan');
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
const opening = root().querySelector('[data-hp="opening"][data-id="future-passage"]');
const stored = space.openings[0];
return {
newerLiteralLoads: !!opening,
documentedDoorFallback: !!opening?.querySelector('.op-leaf,.op-arc'),
readDoesNotRewriteConfig: stored.type === 'passage'
&& stored.future_material === 'stone' && space.openings.length === 1,
};
});
checkAll(out);
await finish(browser, out);
browser = undefined;
} finally {
await browser?.close?.();
rmSync(serveRoot, { recursive: true, force: true });
}
-238
View File
@@ -1,238 +0,0 @@
/**
* Deterministic, fictional high-load fixture shared by performance and future
* visual-regression tooling. Nothing here depends on a real HA installation.
*/
const FLOOR_COUNT = 3;
const ROOMS_PER_FLOOR = 20;
const DEVICE_COUNT = 200;
const OPENING_COUNT = 100;
const PARTITION_COUNT = 60;
const COLUMN_COUNT = 40;
const DECOR_COUNT = 500;
const round = (value) => Number(value.toFixed(6));
const roomGrid = (floor) => {
const rooms = [];
const left = 0.04;
const top = 0.04;
const width = 0.92 / 5;
const height = 0.92 / 4;
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 5; column++) {
const index = row * 5 + column;
const x1 = round(left + column * width);
const y1 = round(top + row * height);
const x2 = round(x1 + width);
const y2 = round(y1 + height);
rooms.push({
id: `perf-room-${floor}-${index}`,
name: `Room ${floor + 1}.${index + 1}`,
area: `perf_area_${floor}_${index}`,
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
});
}
}
return rooms;
};
const wallSegments = (rooms) => {
const unique = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!unique.has(reverse)) unique.set(forward, { a, b });
});
}
return [...unique.values()];
};
const makeOpenings = (floor, walls, count) => walls.slice(0, count).map((wall, index) => {
const horizontal = Math.abs(wall.b[0] - wall.a[0]) >= Math.abs(wall.b[1] - wall.a[1]);
return {
id: `perf-opening-${floor}-${index}`,
type: index % 7 === 0 ? 'window' : index % 11 === 0 ? 'gate' : 'door',
x: round((wall.a[0] + wall.b[0]) / 2),
y: round((wall.a[1] + wall.b[1]) / 2),
angle: horizontal ? 0 : 90,
length: horizontal ? 0.045 : 0.055,
};
});
const makePartitions = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[index % rooms.length];
const [a, , c] = room.poly;
const y = round(a[1] + (c[1] - a[1]) * (0.35 + (index % 3) * 0.12));
return {
id: `perf-partition-${floor}-${index}`,
a: [round(a[0] + 0.035), y],
b: [round(c[0] - 0.035), y],
cm: 10 + (index % 3) * 5,
};
});
const makeColumns = (floor, rooms, count) => Array.from({ length: count }, (_, index) => {
const room = rooms[(index * 3) % rooms.length];
const [a, , c] = room.poly;
return {
id: `perf-column-${floor}-${index}`,
shape: index % 3 === 0 ? 'circle' : 'square',
center: [
round(a[0] + (c[0] - a[0]) * (0.28 + (index % 2) * 0.44)),
round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 1) % 2) * 0.44)),
],
cm: 25 + (index % 4) * 5,
...(index % 3 === 0 ? {} : { angle: (index % 6) * 15 }),
};
});
const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) => {
const column = index % 25;
const row = Math.floor(index / 25);
const x = round(0.02 + column * 0.039);
const y = round(0.018 + (row % 20) * 0.048);
if (index % 10 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'text', x, y, text: `F${floor + 1}-${index}`, size_cm: 14,
color: '#59636e', opacity: 0.75,
};
}
if (index % 3 === 0) {
return {
id: `perf-decor-${floor}-${index}`,
kind: 'rect', x, y, w: 0.022, h: 0.018, angle: (index % 12) * 5,
color: '#687681', opacity: 0.55, width_cm: 1.5,
fill: index % 2 === 0, fill_color: '#75838e', fill_opacity: 0.12,
};
}
return {
id: `perf-decor-${floor}-${index}`,
kind: 'line', x1: x, y1: y, x2: round(x + 0.025), y2: round(y + (index % 2 ? 0.012 : 0)),
color: '#687681', opacity: 0.6, width_cm: 1.2,
...(index % 9 === 0 ? { line_style: 'dashed' } : {}),
};
});
const entityKinds = [
['light', 'on'],
['switch', 'off'],
['sensor', '21.5'],
['binary_sensor', 'off'],
['climate', 'heat'],
['media_player', 'playing'],
['cover', 'closed'],
['fan', 'on'],
['lock', 'locked'],
['vacuum', 'docked'],
];
const makeRuntime = (spaces) => {
const devices = {};
const entities = {};
const states = {};
const areas = {};
const layout = {};
const roomRefs = spaces.flatMap((space) => space.rooms.map((room) => ({ space, room })));
for (const { room } of roomRefs) areas[room.area] = { area_id: room.area, name: room.name };
for (let index = 0; index < DEVICE_COUNT; index++) {
const { space, room } = roomRefs[index % roomRefs.length];
const [domain, baseState] = entityKinds[index % entityKinds.length];
const deviceId = `perf-device-${index}`;
const entityId = `${domain}.perf_${index}`;
devices[deviceId] = {
id: deviceId,
name: `Synthetic ${domain} ${index + 1}`,
model: `PERF-${String(index + 1).padStart(3, '0')}`,
area_id: room.area,
identifiers: [['houseplan_perf', deviceId]],
config_entries: ['perf_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
entities[entityId] = {
entity_id: entityId,
device_id: deviceId,
platform: 'houseplan_perf',
config_entry_id: 'perf_entry',
disabled_by: null,
};
const attributes = { friendly_name: devices[deviceId].name };
if (domain === 'sensor') Object.assign(attributes, {
device_class: 'temperature', unit_of_measurement: '°C', state_class: 'measurement',
});
if (domain === 'binary_sensor') attributes.device_class = index % 2 ? 'motion' : 'occupancy';
if (domain === 'climate') Object.assign(attributes, { current_temperature: 21.5, temperature: 22 });
states[entityId] = { entity_id: entityId, state: index % 4 === 0 && domain === 'light' ? 'off' : baseState, attributes };
const [a, , c] = room.poly;
layout[deviceId] = {
s: space.id,
x: round(a[0] + (c[0] - a[0]) * (0.2 + (index % 4) * 0.2)),
y: round(a[1] + (c[1] - a[1]) * (0.28 + ((index >> 2) % 3) * 0.22)),
};
}
return { devices, entities, states, areas, layout };
};
export const LARGE_HOUSE_COUNTS = Object.freeze({
floors: FLOOR_COUNT,
rooms: FLOOR_COUNT * ROOMS_PER_FLOOR,
devices: DEVICE_COUNT,
openings: OPENING_COUNT,
partitions: PARTITION_COUNT,
columns: COLUMN_COUNT,
decor: DECOR_COUNT,
});
export const makeLargeHouseFixture = () => {
let openingsLeft = OPENING_COUNT;
let partitionsLeft = PARTITION_COUNT;
let columnsLeft = COLUMN_COUNT;
let decorLeft = DECOR_COUNT;
const spaces = Array.from({ length: FLOOR_COUNT }, (_, floor) => {
const rooms = roomGrid(floor);
const segments = wallSegments(rooms);
const floorsRemaining = FLOOR_COUNT - floor;
const openingCount = Math.ceil(openingsLeft / floorsRemaining);
const partitionCount = Math.ceil(partitionsLeft / floorsRemaining);
const columnCount = Math.ceil(columnsLeft / floorsRemaining);
const decorCount = Math.ceil(decorLeft / floorsRemaining);
openingsLeft -= openingCount;
partitionsLeft -= partitionCount;
columnsLeft -= columnCount;
decorLeft -= decorCount;
return {
id: `perf-floor-${floor + 1}`,
title: `Performance floor ${floor + 1}`,
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
rooms,
walls: segments.map((wall, index) => ({
key: `perf-wall-${floor}-${index}`, cm: 15, a: wall.a, b: wall.b,
})),
openings: makeOpenings(floor, segments, openingCount),
partitions: makePartitions(floor, rooms, partitionCount),
wall_columns: makeColumns(floor, rooms, columnCount),
decor: makeDecor(floor, decorCount),
};
});
const runtime = makeRuntime(spaces);
const lightMarkers = Object.entries(runtime.entities)
.filter(([entityId]) => entityId.startsWith('light.'))
.map(([_entityId, entity]) => ({
id: entity.device_id,
binding: `device:${entity.device_id}`,
is_light: true,
}));
return {
config: { spaces, markers: lightMarkers, settings: { glow_radius_cm: 300 } },
...runtime,
counts: LARGE_HOUSE_COUNTS,
};
};
-275
View File
@@ -1,275 +0,0 @@
/** Deterministic fictional scenes for HP-QA-01 golden-image coverage. */
const round = (value) => Number(value.toFixed(6));
// Golden fixtures must use the same persisted wall-key contract as real plan
// data. Arbitrary labels make every configured wall look virtual to the
// renderer, which lets a visually ineffective baseline pass unnoticed.
const WALL_KEY_PITCH = 1 / 240;
export const fixtureWallKey = (a, b) => {
const quantize = (value) => Math.round(value / WALL_KEY_PITCH) * WALL_KEY_PITCH;
const mx = quantize((a[0] + b[0]) / 2);
const my = quantize((a[1] + b[1]) / 2);
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (length < 1e-12) { dx = 1; dy = 0; }
else { dx /= length; dy /= length; }
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
const bucket = Math.round(angle * 1800) / 1800;
return `${mx.toFixed(4)},${my.toFixed(4)}@${bucket.toFixed(4)}`;
};
const uniqueEdges = (rooms) => {
const edges = new Map();
for (const room of rooms) {
room.poly.forEach((a, index) => {
const b = room.poly[(index + 1) % room.poly.length];
const forward = `${a.join(',')}/${b.join(',')}`;
const reverse = `${b.join(',')}/${a.join(',')}`;
if (!edges.has(reverse) && !edges.has(forward)) edges.set(forward, { a, b });
});
}
return [...edges.values()];
};
const wallsFor = (prefix, rooms, thickness) => uniqueEdges(rooms).map((edge, index) => ({
key: fixtureWallKey(edge.a, edge.b),
a: edge.a,
b: edge.b,
cm: typeof thickness === 'function' ? thickness(edge, index) : thickness,
}));
const geometryRooms = [
{ id: 'geo-nw', name: 'NW', area: 'golden_geo_nw', poly: [[0.06, 0.08], [0.48, 0.08], [0.48, 0.48], [0.06, 0.48]] },
{ id: 'geo-ne', name: 'NE', area: 'golden_geo_ne', poly: [[0.48, 0.08], [0.94, 0.08], [0.94, 0.48], [0.48, 0.48]] },
{ id: 'geo-sw', name: 'SW', area: 'golden_geo_sw', poly: [[0.06, 0.48], [0.48, 0.48], [0.48, 0.92], [0.06, 0.92]] },
{ id: 'geo-se', name: 'SE', area: 'golden_geo_se', poly: [[0.48, 0.48], [0.94, 0.48], [0.94, 0.92], [0.48, 0.92]] },
{ id: 'geo-nested', name: 'Nested', area: 'golden_geo_nested',
poly: [[0.72, 0.14], [0.84, 0.26], [0.72, 0.38], [0.60, 0.26]] },
];
const lightingRooms = [
{ id: 'light-left', name: 'Light source room', area: 'golden_light_left',
poly: [[0.07, 0.10], [0.50, 0.10], [0.50, 0.88], [0.07, 0.88]] },
{ id: 'light-right', name: 'Receiving room', area: 'golden_light_right',
poly: [[0.50, 0.10], [0.93, 0.10], [0.93, 0.88], [0.50, 0.88]] },
];
const applianceRooms = [
{ id: 'appliance-room', name: 'Laundry', area: 'golden_appliance',
poly: [[0.08, 0.10], [0.92, 0.10], [0.92, 0.90], [0.08, 0.90]] },
];
const geometrySpace = {
id: 'golden-geometry',
title: 'Geometry matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', show_borders: true, show_names: true,
room_color: '#2d8fce', room_opacity: 0.16,
},
rooms: geometryRooms,
walls: wallsFor('geo', geometryRooms, (edge, index) => {
const vertical = Math.abs(edge.a[0] - edge.b[0]) < 1e-9;
if (vertical && Math.abs(edge.a[0] - 0.48) < 1e-9) return 25;
return index % 4 === 0 ? 10 : 15;
}),
open_spans: [{ a: [0.48, 0.15], b: [0.48, 0.27] }],
openings: [
{ id: 'geo-window', type: 'window', x: 0.26, y: 0.08, angle: 0, length: 0.12 },
{ id: 'geo-door', type: 'door', x: 0.48, y: 0.37, angle: 90, length: 0.12 },
{ id: 'geo-gate', type: 'gate', x: 0.72, y: 0.92, angle: 0, length: 0.2 },
{ id: 'geo-diagonal-window', type: 'window', x: 0.78, y: 0.20, angle: 45, length: 0.08 },
],
partitions: [
{ id: 'geo-partition-h', a: [0.14, 0.68], b: [0.40, 0.68], cm: 12 },
{ id: 'geo-partition-v', a: [0.75, 0.56], b: [0.75, 0.82], cm: 20 },
],
wall_columns: [
{ id: 'geo-column-square', shape: 'square', center: [0.63, 0.67], cm: 35, angle: 30 },
{ id: 'geo-column-circle', shape: 'circle', center: [0.86, 0.72], cm: 40 },
],
decor: [
{ id: 'geo-axis-h', kind: 'line', x1: 0.04, y1: 0.5, x2: 0.96, y2: 0.5,
color: '#5d6a73', opacity: 0.35, width_cm: 0.8, line_style: 'dashed' },
],
};
const lightingSpace = {
id: 'golden-lighting',
title: 'Lighting matrix',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', glow_enabled: true, show_borders: true, show_names: true,
north_deg: 0, sun_rays: true, bg_mode: 'static',
},
rooms: lightingRooms,
walls: wallsFor('light', lightingRooms, (edge) => (
Math.abs(edge.a[0] - 0.5) < 1e-9 && Math.abs(edge.b[0] - 0.5) < 1e-9 ? 25 : 15
)),
openings: [
{ id: 'light-window', type: 'window', x: 0.27, y: 0.10, angle: 0, length: 0.14 },
{ id: 'light-door', type: 'door', x: 0.50, y: 0.54, angle: 90, length: 0.15 },
{ id: 'light-gate', type: 'gate', x: 0.74, y: 0.88, angle: 0, length: 0.22 },
],
partitions: [
{ id: 'light-partition', a: [0.70, 0.22], b: [0.70, 0.70], cm: 18 },
],
wall_columns: [
{ id: 'light-column', shape: 'circle', center: [0.38, 0.64], cm: 45 },
],
decor: [],
};
const applianceSpace = {
id: 'golden-appliance',
title: 'Appliance lifecycle',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: {
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
sun_rays: false, bg_mode: 'static',
},
rooms: applianceRooms,
walls: wallsFor('appliance', applianceRooms, 15),
openings: [],
partitions: [],
wall_columns: [],
decor: [],
};
const runtime = (includeAppliance = false) => {
const devices = {};
const entities = {};
const states = {
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 24 },
},
};
// Keep sun.sun state-only on purpose. Core/runtime entities and YAML
// entities without unique_id may have a live state without a registry row.
// The production projection must preserve them.
const layout = {};
const areas = Object.fromEntries(
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
);
const add = (id, domain, area, x, y, state, attributes = {}) => {
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
devices[id] = {
id, name: `Golden ${id}`, model: `GOLDEN-${id.toUpperCase()}`, area_id: area,
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'],
entry_type: null, via_device_id: null, disabled_by: null,
};
entities[entityId] = {
entity_id: entityId, device_id: id, platform: 'houseplan_golden',
config_entry_id: 'golden_entry', disabled_by: null,
};
states[entityId] = { entity_id: entityId, state, attributes: { friendly_name: devices[id].name, ...attributes } };
layout[id] = { s: 'golden-lighting', x: round(x), y: round(y) };
};
add('golden-light-one', 'light', 'golden_light_left', 0.20, 0.34, 'on', { rgb_color: [255, 196, 112] });
add('golden-light-two', 'light', 'golden_light_left', 0.35, 0.72, 'on', { color_temp_kelvin: 2700 });
add('golden-light-three', 'light', 'golden_light_right', 0.82, 0.30, 'off');
add('golden-presence', 'binary_sensor', 'golden_light_right', 0.82, 0.62, 'on', { device_class: 'occupancy' });
add('golden-climate', 'climate', 'golden_light_right', 0.60, 0.28, 'heat', {
current_temperature: 22.4, temperature: 23, hvac_action: 'heating',
});
add('golden-left-temperature', 'sensor', 'golden_light_left', 0.19, 0.54, '17', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-right-temperature', 'sensor', 'golden_light_right', 0.81, 0.48, '29', {
device_class: 'temperature', unit_of_measurement: '°C',
});
add('golden-left-linkquality', 'sensor', 'golden_light_left', 0.34, 0.54, '35', {
unit_of_measurement: 'lqi',
});
add('golden-right-linkquality', 'sensor', 'golden_light_right', 0.66, 0.70, '190', {
unit_of_measurement: 'lqi',
});
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
id: washerId,
name: 'Golden washing machine',
model: 'GOLDEN-WASHER-COMPOSITE',
area_id: 'golden_appliance',
identifiers: [['houseplan_golden', washerId]],
config_entries: ['golden_entry'],
entry_type: null,
via_device_id: null,
disabled_by: null,
};
const addWasherEntity = (entityId, state, attributes = {}, registry = {}) => {
entities[entityId] = {
entity_id: entityId,
device_id: washerId,
platform: 'houseplan_golden',
config_entry_id: 'golden_entry',
disabled_by: null,
...registry,
};
states[entityId] = {
entity_id: entityId,
state,
attributes: { friendly_name: registry.original_name || entityId, ...attributes },
};
};
addWasherEntity('switch.golden_washer_power', 'on', {}, { original_name: 'Power' });
addWasherEntity('switch.golden_washer_child_lock', 'off', {}, { original_name: 'Child lock' });
addWasherEntity('sensor.golden_washer_status', 'done', {}, {
original_name: 'Status', translation_key: 'status',
});
addWasherEntity('sensor.golden_washer_stage', 'Rinse', {}, { original_name: 'Stage' });
addWasherEntity('sensor.golden_washer_program', 'mixed_wash', {}, { original_name: 'Program' });
layout[washerId] = { s: 'golden-appliance', x: 0.5, y: 0.5 };
}
return { devices, entities, states, layout, areas };
};
export const VISUAL_MATRIX_COUNTS = Object.freeze({
spaces: 2,
rooms: geometryRooms.length + lightingRooms.length,
openings: geometrySpace.openings.length + lightingSpace.openings.length,
partitions: geometrySpace.partitions.length + lightingSpace.partitions.length,
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
config: {
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
settings: {
glow_radius_cm: 360,
north_deg: 0,
sun_rays: true,
bg_mode: 'static',
fill_colors: {
glow_base: { c: '#1b2530', a: 0.78 },
glow_light: { c: '#ffd27b', a: 0.70 },
wall_fill: { c: '#d7d9dc', a: 1 },
},
},
},
...runtime(applianceLifecycle),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
} : VISUAL_MATRIX_COUNTS,
});
-22
View File
@@ -1,22 +0,0 @@
// Generate demo/srv/assets/icons.js: an { "mdi:name": "<svg path>" } map for every
// mdi: icon referenced in src/ and demo/ (the demo host stubs <ha-icon> with it).
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import * as mdi from '@mdi/js';
const names = new Set();
const scan = (dir) => {
for (const f of readdirSync(dir, { withFileTypes: true })) {
if (f.isDirectory()) { scan(`${dir}/${f.name}`); continue; }
if (!/\.(ts|json|html|mjs)$/.test(f.name) || f.name === 'icons.js') continue;
const txt = readFileSync(`${dir}/${f.name}`, 'utf8');
for (const m of txt.matchAll(/mdi:([a-z0-9-]+)/g)) names.add(m[1]);
}
};
scan('src'); scan('demo');
const map = {};
for (const n of [...names].sort()) {
const camel = 'mdi' + n.replace(/(^|-)(\w)/g, (_, __, c) => c.toUpperCase());
if (mdi[camel]) map['mdi:' + n] = mdi[camel];
}
writeFileSync('demo/srv/assets/icons.js', 'window.__ICONS=' + JSON.stringify(map) + ';\n');
console.log('icons:', Object.keys(map).length, 'of', names.size, 'referenced');
-61
View File
@@ -1,61 +0,0 @@
# HP-QA-01 golden images
This layer catches visual regressions that DOM smokes cannot: wall seams and
end caps, thick opening tunnels, Glow/sun clipping, hover contours, editor
chrome, the open contextual tray at wide/medium/narrow widths in English and
Russian (selection, tool options, group and palette), long dialog
titles/footers, mobile clipping, themes and zoom/remount. The desktop and
mobile device-dialog scenarios use a real light and make the complete
source-role, Glow colour, brightness and radius controls visible; capturing
only the top of that section fails the scenario before comparison.
The Glow matrix also keeps one deliberately opaque custom-fill scene with a
single source and two doorways: it makes hard spill wedges and fully unlit
radial spokes visible instead of hiding them under a translucent room fill.
## Safety contract
- A build fingerprint embedded by Rollup must match `src/`, Rollup/TypeScript
configuration and locked package inputs; stale committed
demo bundles fail before the first screenshot.
- Chromium, viewport, locale, timezone, colour profile, font rendering,
animations and caret are controlled by the runner.
- `capture` writes only to ignored `artifacts/golden/`; it never changes a
baseline and never claims a missing baseline passed. Any scenario runtime
error makes capture fail, including the initial no-baseline CI run.
- `verify` requires every image plus a matching matrix manifest and fails on
missing/different/error scenarios, browser mismatch or a baseline whose hash
no longer matches the reviewed manifest.
- `accept` requires `--reviewed`, a complete candidate report and current
source fingerprint. It validates the whole set before copying anything and
is the only command allowed to update baselines.
## Workflow
Build and copy the exact current source first:
```bash
npm run build
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
npm run golden:capture
```
Review `artifacts/golden/actual/` and, when existing references are present,
`artifacts/golden/diff/`. If every image is intentional:
```bash
npm run golden:accept -- --reviewed
npm run golden:verify
```
Never accept images merely to make CI green. A matrix/framing change increments
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
Future updates must still use the `golden-images` artifact produced by the Linux
CI job as the review set: desktop font rasterisation can differ from the CI
environment even with the same pinned Chromium. Pass its unpacked root via
`--from=...` when accepting it locally.
Scenarios may also declare a semantic pixel region (for example, a receiving
room that must contain warm light). `golden:capture` and `golden:verify` reject
the capture before baseline comparison when that visual precondition is empty;
a reviewed but meaningless PNG therefore cannot become the contract.
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from './policy.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reviewed = process.argv.includes('--reviewed');
const fromArg = process.argv.find((arg) => arg.startsWith('--from='));
const from = resolve(fromArg ? fromArg.slice('--from='.length) : resolve(ROOT, 'artifacts/golden'));
if (!reviewed) throw new Error('refusing to replace baselines without explicit --reviewed');
const reportPath = resolve(from, 'golden-report.json');
if (!existsSync(reportPath)) throw new Error(`candidate report not found: ${reportPath}`);
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION)
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
if (report.buildFingerprint !== sourceFingerprint(ROOT))
throw new Error('candidate screenshots were not captured from the current frontend source');
if (typeof report.chromium !== 'string' || !report.chromium)
throw new Error('candidate report does not identify its Chromium build');
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const byId = new Map(report.results.map((result) => [result.id, result]));
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
mkdirSync(baselineRoot, { recursive: true });
const hashes = {};
const candidates = [];
for (const scenario of GOLDEN_SCENARIOS) {
const result = byId.get(scenario.id);
const candidate = resolve(from, 'actual', `${scenario.id}.png`);
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status))
throw new Error(`review candidate has an invalid run status: ${scenario.id} (${result?.status || 'missing'})`);
if (!result?.actualSha256 || !existsSync(candidate))
throw new Error(`review candidate missing: ${scenario.id}`);
const bytes = readFileSync(candidate);
const digest = createHash('sha256').update(bytes).digest('hex');
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${scenario.id}`);
candidates.push({ scenario, candidate });
hashes[scenario.id] = digest;
}
// Validate the complete set first: a broken report must never leave a half-
// updated baseline directory behind.
for (const { scenario, candidate } of candidates)
copyFileSync(candidate, resolve(baselineRoot, `${scenario.id}.png`));
writeFileSync(resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST), `${JSON.stringify({
schema: 1,
matrixVersion: GOLDEN_MATRIX_VERSION,
acceptedAt: new Date().toISOString(),
sourceFingerprint: report.buildFingerprint,
chromium: report.chromium,
scenarios: hashes,
}, null, 2)}\n`, 'utf8');
console.log(`Accepted ${GOLDEN_SCENARIOS.length} reviewed golden baselines.`);
-1
View File
@@ -1 +0,0 @@
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

@@ -1,72 +0,0 @@
{
"schema": 1,
"matrixVersion": 24,
"acceptedAt": "2026-08-17T10:51:28.998Z",
"sourceFingerprint": "b73e5020215967d2d13e2a08ce303916e2bdbcbd45c94c40c6e56336a8097656",
"chromium": "151.0.7922.34",
"scenarios": {
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
"lighting-glow-sun-dark": "a98eee332f25a43c8d9d126c118c8cfea4ee73752b1060227548efedaf0efcdd",
"device-value-badge-positions-dark": "1ad43f2bd866733aa75c34de38fb97d469661799b540a8ae22150067d788cec8",
"lighting-sun-window-state-only-dark": "3bd581a23a2e0ebba58530db5182adea5cba6ec10bc032bee024415c19108a17",
"lighting-fill-light-axis-split-dark": "4f867528aeb9124229f81659876b03ff297a3a7a92bc57ffb32d5c24913c4938",
"lighting-fill-temp-axis-split-dark": "e0535b70701c9fe6753f74c943b9f288a0fb1a23a9cfc8590d4945e0a1724eb5",
"lighting-fill-lqi-axis-split-dark": "485ab183144913569ddc11154553ab4ac7db522ab188de74d652b717dc786d9c",
"lighting-temp-glow-dark": "ecaed039fb6aab4e1fdc9f1856c89e5f563219b8ff813877027737cecbeca41a",
"lighting-temp-glow-light": "5bc8a35aaa94c427d465d198f1cd5eecfdc704f5abeded9e407f32ce93aa7d32",
"lighting-custom-glow-dark": "899e334dfc0d3490ca291b7239bf7b88ca9d1ef3be199ddf347314bab1513f27",
"lighting-opaque-glow-two-doorways-dark": "413f5a8e39193ba941f72955a391ccac954c09f24322b7bf67494c7691275980",
"lighting-custom-glow-light": "266bba4ae1744a884b2cd224b37fdc36447d57405a1c5298ca30027e2957cc8a",
"lighting-temp-glow-no-sources-dark": "5100c81543fc30a7934a6db6f9e67e2c4fa185df0a974be5911879e43c0d3fc9",
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
"large-house-zoom-040-dark": "5f11c4b78318a64c2a7cf803716661eea506609d4f0a6bb3d64a709f8c49db1d",
"large-house-zoom-250-dark": "c906426f888ff4e306c5c334c6329b387fc5ca368e229f55acc33c202351a1ac",
"large-house-warm-remount-dark": "6baf4baed1c735c64dfe1e69d9864ca287ffc0e8452d00e801f0873e98b187ee",
"device-dialog-desktop-en": "d6fcc83aa1335df1041e2b1aa445b0019d1e3567f98ef8f47a889894051f2b62",
"device-dialog-mobile-ru": "8cb928853ddacb61882804c3d00ead31da31bc4559ee6a8e293ef6b55cd5a463",
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
"backup-plan-only-export-desktop-en": "2833ee45acac2936e76546e9ff5d0031932c02fbf66fe904c1be94dc3e33dc05",
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

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