Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1312eb14e7 |
@@ -41,7 +41,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Скриншоты документации снимаются здесь и только здесь (#246).
|
||||
#
|
||||
# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре:
|
||||
# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка
|
||||
# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять
|
||||
# целиком. Одно окружение убирает этот шум насовсем.
|
||||
#
|
||||
# Джоба ничего не коммитит: она публикует артефакт, который человек принимает
|
||||
# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`.
|
||||
# Та же конструкция, что у golden-эталонов, и по той же причине: картинки
|
||||
# попадают в репозиторий через явное решение, а не через бота.
|
||||
name: Docs screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ветка или SHA, с которого снимать
|
||||
required: false
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#175, #206):
|
||||
# системные библиотеки Chromium уже в образе раннера.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the bundle the screenshots must come from
|
||||
run: npm run build
|
||||
- name: Capture
|
||||
run: node demo/docs/capture.mjs
|
||||
# Вердикт до всякой приёмки. Само число изменившихся файлов ничего не
|
||||
# говорит: набор, снятый другим браузером, меняет их все, и это нормально
|
||||
# ровно один раз — при переходе на канонический прогон. Сравнивать надо
|
||||
# браузер: тот же Chromium и десять изменившихся картинок означают, что
|
||||
# изменился продукт (или что-то не так), другой Chromium — ожидаемую
|
||||
# разницу рендеринга.
|
||||
- name: Вердикт
|
||||
run: |
|
||||
git status --porcelain docs/images
|
||||
changed=$(git diff --name-only docs/images | grep -c png || true)
|
||||
before=$(git show HEAD:docs/images/screenshots.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})")
|
||||
after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)")
|
||||
echo "--- изменившихся PNG: $changed"
|
||||
echo "--- Chromium: было «${before:-не записан}», стало «$after»"
|
||||
if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then
|
||||
echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт."
|
||||
echo "Смотрите на кадры: если изменение ожидаемое, принимайте."
|
||||
elif [ "$before" != "$after" ]; then
|
||||
echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу."
|
||||
echo "Это ожидаемо один раз — при переходе на канонический прогон."
|
||||
echo "Если Chromium сменился неожиданно, сверьте закреплённую версию в package-lock."
|
||||
else
|
||||
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
|
||||
fi
|
||||
- name: Upload candidate
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docs-screenshots
|
||||
path: |
|
||||
docs/images/*.png
|
||||
docs/images/screenshots.json
|
||||
if-no-files-found: error
|
||||
@@ -13,11 +13,6 @@ name: Mutation gate
|
||||
|
||||
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'
|
||||
@@ -32,32 +27,25 @@ concurrency:
|
||||
jobs:
|
||||
mutants:
|
||||
runs-on: ubuntu-latest
|
||||
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
|
||||
# гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: candidate
|
||||
fetch-depth: 2
|
||||
@@ -116,12 +116,12 @@ jobs:
|
||||
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out base SHA
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.base.outputs.sha }}
|
||||
path: baseline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -132,24 +132,16 @@ jobs:
|
||||
- name: Install candidate and baseline dependencies
|
||||
run: npm ci --prefix candidate && npm ci --prefix baseline
|
||||
|
||||
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
working-directory: candidate
|
||||
run: npx playwright install chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build both exact source trees
|
||||
run: |
|
||||
npm --prefix candidate run build
|
||||
(cd candidate && node scripts/bundle-sync.mjs)
|
||||
cp candidate/dist/houseplan-card.js candidate/demo/srv/assets/houseplan-card.js
|
||||
npm --prefix baseline run build
|
||||
(cd baseline && node scripts/bundle-sync.mjs)
|
||||
cp baseline/dist/houseplan-card.js baseline/demo/srv/assets/houseplan-card.js
|
||||
|
||||
- name: Capture base and candidate profiles
|
||||
working-directory: candidate
|
||||
@@ -158,8 +150,6 @@ jobs:
|
||||
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
|
||||
@@ -174,13 +164,12 @@ jobs:
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: full-performance
|
||||
path: artifacts/performance
|
||||
|
||||
@@ -39,7 +39,6 @@ jobs:
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
steps:
|
||||
- id: decide
|
||||
@@ -64,41 +63,19 @@ jobs:
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
|
||||
# Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все
|
||||
# подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89
|
||||
# первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе
|
||||
# код-ревью упиралось бы в review-4 после одной правки.
|
||||
#
|
||||
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
|
||||
# только для имени документа и метки: два захода с одинаковым номером
|
||||
# означают, что второй документ перезапишет первый и артефакт ревью
|
||||
# исчезнет.
|
||||
#
|
||||
# `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это
|
||||
# «отправка на ревью → вердикт с блокирующими находками → возврат
|
||||
# автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты.
|
||||
# Зелёный ничего на правки не вернул и цикла не образует.
|
||||
#
|
||||
# Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер
|
||||
# наказывал за то, что предписывал сам: при неудавшемся слиянии он
|
||||
# велит вернуть S7-code-review после ребейза, и этот заход добивал
|
||||
# бюджет. На #225 (лёгкий трек, лимит 2) последовательность
|
||||
# жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и
|
||||
# зелёным CI: работа встала, хотя после вердикта не было ни одной
|
||||
# правки продуктового кода.
|
||||
#
|
||||
# Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал
|
||||
# цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по
|
||||
# имени документа в теле комментария; документа нет — вердикт не
|
||||
# посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы
|
||||
# работу досрочно: из двух ошибок выбрана обратимая.
|
||||
attempt=1; spent=0; spent_list=""
|
||||
# Этап опознаётся по имени документа в теле комментария. Если документа
|
||||
# нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт
|
||||
# остановил бы работу досрочно; из двух ошибок выбрана обратимая.
|
||||
done_cycles=0
|
||||
if [ -n "$stage" ]; then
|
||||
comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments)
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]"
|
||||
# Блокирующим считается вердикт, у которого в строке вердикта стоит
|
||||
# «жёлтый» или «красный». Регистр и окружение слова не важны.
|
||||
blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))"
|
||||
attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 ))
|
||||
spent=$(printf '%s' "$comments" | jq -r "$blocking | length")
|
||||
spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")")
|
||||
done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \
|
||||
--json comments \
|
||||
-q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length")
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
@@ -132,33 +109,19 @@ jobs:
|
||||
refuse "стоит blocked — конвейер не запускается" \
|
||||
"на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято."
|
||||
elif [ "$EXHAUSTED" = "true" ]; then
|
||||
# Метку снимает владелец, а не конвейер: автоматика, отменяющая
|
||||
# остановку работы, дороже ручного снятия. Но пересчёт печатается —
|
||||
# метка могла остаться от прежнего правила, когда бюджет тратил и
|
||||
# зелёный вердикт (#227).
|
||||
stale=""
|
||||
if [ "$spent" -lt "$limit" ]; then
|
||||
stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец."
|
||||
fi
|
||||
refuse "стоит review-4 — решение за владельцем" \
|
||||
"на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale"
|
||||
elif [ "$spent" -ge "$limit" ]; then
|
||||
echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан"
|
||||
"на 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 \
|
||||
"Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4).
|
||||
|
||||
Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:
|
||||
$spent_list"
|
||||
"Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)."
|
||||
stage=""
|
||||
else
|
||||
echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit"
|
||||
echo "этап $stage, цикл $((done_cycles + 1)) из $limit"
|
||||
fi
|
||||
echo "stage=$stage" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
@@ -168,37 +131,17 @@ jobs:
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
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
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -211,15 +154,8 @@ jobs:
|
||||
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
|
||||
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
|
||||
| head -1 | sed 's|.*refs/heads/||')
|
||||
if [ -n "$branch" ]; then
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
@@ -229,125 +165,27 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
|
||||
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
|
||||
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
|
||||
# ребейз упирается в конфликт и это видно; смысловое расхождение git
|
||||
# склеивает молча, и в dev уезжает комбинация, которую никто не читал.
|
||||
# Именно так пришёл регресс #234.
|
||||
#
|
||||
# Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут
|
||||
# ревью и потраченных лимитов подписки, хотя виден за пять секунд до них.
|
||||
#
|
||||
# Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать
|
||||
# чужую ветку без нужды — лишний риск.
|
||||
- name: Привести ветку к dev
|
||||
id: rebase
|
||||
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
GIT_AUTHOR_NAME: claude[bot]
|
||||
GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||||
GIT_COMMITTER_NAME: claude[bot]
|
||||
GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
if git merge-base --is-ancestor origin/dev HEAD; then
|
||||
echo "ветка содержит весь dev — ребейз не нужен"
|
||||
exit 0
|
||||
fi
|
||||
behind=$(git rev-list --count "HEAD..origin/dev")
|
||||
before=$(git rev-parse "origin/$BRANCH")
|
||||
echo "dev впереди на $behind коммит(ов) — привожу ветку"
|
||||
if ! git rebase origin/dev; then
|
||||
git rebase --abort || true
|
||||
echo "conflict=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается"
|
||||
exit 0
|
||||
fi
|
||||
# --force-with-lease с явным ожидаемым значением обязателен: между
|
||||
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
|
||||
# его молча. Расхождение lease — падение прогона, а не предупреждение:
|
||||
# ревью пошло бы по коду, которого на ветке уже нет.
|
||||
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
|
||||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:refs/heads/$BRANCH"; then
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
fi
|
||||
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
|
||||
# и без этого он ребейзил бы заново уже приведённое.
|
||||
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
|
||||
short_before=$(git rev-parse --short "$before")
|
||||
short_after=$(git rev-parse --short HEAD)
|
||||
echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT"
|
||||
echo "ветка $BRANCH приведена к dev: $short_before -> $short_after"
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||||
# тоже смена метки, и автор не ждёт впустую.
|
||||
- name: Конфликт с dev — вернуть автору без ревью
|
||||
if: steps.rebase.outputs.conflict == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
run: |
|
||||
cat > /tmp/stale.md <<EOF
|
||||
**Ревью не запускалось:** ветка \`$BRANCH\` не ребейзится на \`dev\` без конфликта. Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Проверка стоит до ревью намеренно: конфликт всё равно вернул бы задачу, но уже после сорока минут работы ревьюера и потраченных лимитов.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
|
||||
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
|
||||
2. запушить ветку;
|
||||
3. вернуть метку \`S7-code-review\`.
|
||||
|
||||
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
# уничтожать его собственный артефакт (#220).
|
||||
REVIEW_DOC: ${{ runner.temp }}/review-document.md
|
||||
with:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
@@ -360,44 +198,6 @@ jobs:
|
||||
Этап: ${{ needs.guard.outputs.stage }}
|
||||
spec — ревью ТЗ (PROCESS.md §2.4)
|
||||
code — код-ревью (PROCESS.md §2.7)
|
||||
Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }}
|
||||
Бюджет §4 тратят только жёлтые и красные вердикты: зелёный
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
всех раундов, и повторный цикл заново выводил продуктовую рамку и
|
||||
перепроверял AC, которых правка не касалась: r2 по #150 стоил
|
||||
полного прогона ради одной строки в тестовой фикстуре.
|
||||
|
||||
Порядок для r2 и дальше:
|
||||
1. найди вердикт предыдущего раунда в комментариях issue и SHA,
|
||||
на котором он получен. SHA в вердикте не назван — это находка;
|
||||
2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла
|
||||
ТЗ или тела issue для spec. Дельта — предмет этого раунда;
|
||||
3. по каждой находке предыдущего раунда покажи, чем именно она
|
||||
закрыта: строка кода или текста, а не заявление автора;
|
||||
4. заново проверяй только те AC, чьё доказательство дельта
|
||||
задевает. Остальные наследуй;
|
||||
5. в документе обязателен раздел «Унаследовано из r<N-1>»: что
|
||||
принято без повторной проверки, со ссылкой на документ того
|
||||
раунда и SHA, на котором вывод получен. Без этого перечня
|
||||
сокращение — молчаливое доверие, а такой тихий успех уже
|
||||
дважды стоил дня (#171, #207).
|
||||
|
||||
Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший
|
||||
вперёд dev (после ребейза это другой код, §7.2), смена контракта
|
||||
поведения, задета новая подсистема, либо объём дельты сопоставим с
|
||||
исходной задачей. Сомневаешься — разбирай полностью и скажи почему.
|
||||
|
||||
Сокращается объём РАЗБОРА, а не строгость: правка по замечанию
|
||||
способна сломать AC, который предыдущий раунд признал выполненным —
|
||||
так появилась регрессия #102. Поэтому граница не «только находки», а
|
||||
«находки плюс всё, до чего дотягивается дельта».
|
||||
|
||||
Прочитай в этом порядке, прежде чем судить:
|
||||
1. docs/SCOPE.md — зачем продукт существует и для кого. Он
|
||||
@@ -441,44 +241,14 @@ jobs:
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
Всегда, они дешёвые, и в повторном раунде тоже: код изменился,
|
||||
а стоят они минуты:
|
||||
Всегда, они дешёвые:
|
||||
`npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх
|
||||
копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает
|
||||
`src/**`: отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим —
|
||||
выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev`
|
||||
с красным job `docs` до следующей задачи (#237).
|
||||
|
||||
Если diff трогает геометрию или ссылки на неё — рёбра комнат,
|
||||
записи толщины, `layout`, `marker.space`, `open_spans` — обязательны
|
||||
инварианты модели (#254): `npm test` уже гоняет их на всех моделях
|
||||
проекта, а на конкретной конфигурации они проверяются командой
|
||||
`npm run invariants -- --config <экспорт или ответ config/get>`.
|
||||
Три вопроса, на которые они отвечают, и все три уже стоили
|
||||
продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли
|
||||
каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу
|
||||
решёточного ребра (#258, #259). Последний сравнивает строки без
|
||||
допусков: сдвиг ключа на один шаг решётки равен допуску первых двух,
|
||||
поэтому они на нём промахиваются. Если задача меняет геометрию, а
|
||||
инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь.
|
||||
копий бандла.
|
||||
|
||||
По необходимости, и «необходимость» определяется diff'ом и AC:
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те,
|
||||
что печатает `node scripts/smoke-select.mjs --base <base> --head <head>`.
|
||||
Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое
|
||||
в этот текст число трижды расходилось с деревом, поэтому его
|
||||
здесь больше нет. Прогон всех уместен только когда задача
|
||||
действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал
|
||||
`smoke_wall_junctions`, который по названию про стыки стен, а не
|
||||
про толщину отрезка. Инструмент печатает три вида ответа, и они
|
||||
разные: «прямое совпадение» — смок называет изменённый символ,
|
||||
«зарегистрированная связь» — смок проверяет следствие контракта,
|
||||
не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не
|
||||
разрешение ничего не прогонять. Вывод инструмента прикладывается
|
||||
к комментарию ревью вместе с решением по каждой строке: прогнал
|
||||
либо не прогнал и почему. Слабые связи (одно распространённое
|
||||
имя) — повод посмотреть, а не обязанность прогонять;
|
||||
- браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс
|
||||
относящиеся к тронутым поверхностям. Их 127; прогон всех уместен
|
||||
только когда задача действительно задевает всё;
|
||||
- `npm run golden:verify` — если diff может изменить видимый
|
||||
результат: рендер, геометрия, стили, слои;
|
||||
- `python -m pytest tests_backend -q` — если тронут
|
||||
@@ -486,14 +256,6 @@ jobs:
|
||||
- performance-профили — если названы в AC либо тронуты
|
||||
чувствительные к перфу пути.
|
||||
|
||||
**Одно число — один источник.** Если дифф добавляет или меняет
|
||||
величину, видимую пользователю, назови в отчёте прямо: какое число
|
||||
видно дважды (превью против записи, подпись против площади,
|
||||
подсветка инструмента против сохранённого значения) и один ли у него
|
||||
источник. Три дефекта подряд имели именно эту причину — #234, #233 и
|
||||
способ, которым #234 обнаружили. Механическая часть закреплена
|
||||
тестом `test/single-source-numbers.test.mjs`, смысловая — твоя.
|
||||
|
||||
Дисциплина «тест должен уметь падать» не отменяется, но применяется к
|
||||
тем тестам, которые ты прогонял.
|
||||
|
||||
@@ -505,46 +267,27 @@ jobs:
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (решение владельца 2026-08-19, #202: заведение и
|
||||
обслуживание issue дороже правки на месте); Low либо правится,
|
||||
либо снимается с записью. Жёлтый вердикт допустим и при полностью
|
||||
выполненных AC, если изменение не решает заявленный сценарий или
|
||||
ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не
|
||||
отменяет AC и не даёт права менять скоуп.
|
||||
Серьёзность: High блокирует; Medium обязан стать отдельным issue;
|
||||
Low либо правится, либо снимается с записью. Жёлтый вердикт
|
||||
допустим при полностью выполненных AC, если изменение не решает
|
||||
заявленный сценарий или ухудшает смежный. Продуктовое рассуждение
|
||||
расширяет вопросы, но не отменяет AC и не даёт права менять скоуп.
|
||||
|
||||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||||
issue со ссылкой на #${{ github.event.issue.number }} и метками:
|
||||
тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не
|
||||
считается и прямо запрещено §12.
|
||||
Каждую Medium-находку заведи отдельным issue со ссылкой на
|
||||
#${{ github.event.issue.number }} и метками: тип, приоритет,
|
||||
S1-new. «Оставили в тексте ревью» закрытием не считается и прямо
|
||||
запрещено §12.
|
||||
|
||||
Напиши полный документ ревью в файл, путь которого лежит в
|
||||
переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория).
|
||||
|
||||
Почему не в docs/reviews: документ там был некоммитнутым файлом того
|
||||
же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На
|
||||
#220 три раунда подряд документ исчезал — восстановление дерева
|
||||
(`git checkout -- .`, `git clean -fd`) сносит собственный артефакт
|
||||
ревью, потому что он untracked. В репозиторий его положит шаг
|
||||
публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно.
|
||||
|
||||
В самом репозитории не создавай файлов вообще: любые изменения в
|
||||
рабочей копии будут отброшены. Имя документа в docs/reviews шаг
|
||||
публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для
|
||||
code, с номером issue и заходом.
|
||||
|
||||
Содержание документа: скоуп, как проверялось, находки с
|
||||
воспроизведением, что проверено и корректно, чего не проверял. Для
|
||||
r2 и дальше добавь два раздела: «Закрытие раунда r<N-1>» — таблица
|
||||
«находка | чем закрыта | где это видно», и «Унаследовано из r<N-1>» —
|
||||
что принято без повторной проверки, с документом и SHA.
|
||||
Напиши полный документ ревью в файл
|
||||
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.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым)
|
||||
`Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → #…`
|
||||
|
||||
Затем верни JSON по схеме. Это последнее действие и оно обязательно:
|
||||
без него метка не переставится и конвейер встанет.
|
||||
@@ -556,14 +299,10 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
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 }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
@@ -574,49 +313,12 @@ jobs:
|
||||
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
|
||||
# docs/reviews исключён из уборки: ревьюер мог написать документ по
|
||||
# старому пути, и клин не должен его съесть до `git add` — ровно так
|
||||
# оба пути остаются работоспособными.
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
# Документ приезжает извне репозитория (#220). Три раунда подряд он
|
||||
# терялся, пока лежал некоммитнутым файлом в том же дереве, которое
|
||||
# ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс
|
||||
# `git clean -fd` сносят собственный артефакт ревью, потому что он
|
||||
# untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не
|
||||
# страшна.
|
||||
if [ -f "$SOURCE" ]; then
|
||||
mkdir -p docs/reviews
|
||||
cp "$SOURCE" "$doc"
|
||||
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
|
||||
else
|
||||
# Совместимость: ревьюер мог написать по старому пути, если промпт
|
||||
# ещё не обновился в этой ветке.
|
||||
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
|
||||
fi
|
||||
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::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)"
|
||||
exit 1
|
||||
echo "::warning::документ ревью не создан"
|
||||
exit 0
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
@@ -636,26 +338,16 @@ jobs:
|
||||
-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
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
|
||||
exit 0
|
||||
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"
|
||||
echo "документ опубликован в $target"
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -734,7 +426,6 @@ jobs:
|
||||
echo "слито в dev: $(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Pin the current dev candidate
|
||||
id: candidate
|
||||
@@ -67,11 +67,11 @@ jobs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
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 .)
|
||||
@@ -172,7 +173,7 @@ jobs:
|
||||
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
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
||||
with:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
|
||||
|
||||
@@ -16,11 +16,11 @@ jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
@@ -47,27 +47,27 @@ jobs:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
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
|
||||
node scripts/bundle-sync.mjs
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
hacs-discovery:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
|
||||
@@ -6,12 +6,6 @@ on:
|
||||
# to the same SHA and must not duplicate the browser validation jobs.
|
||||
branches:
|
||||
- '**'
|
||||
# Документы ревью конвейер пишет пачками — 340 коммитов за месяц, и каждый
|
||||
# гонял лёгкую половину Validate впустую (≈15 часов раннера в месяц).
|
||||
# Релизного кандидата это не затрагивает: тег всегда стоит на коммите,
|
||||
# который меняет версию и бандл, а не только `docs/reviews/**`.
|
||||
paths-ignore:
|
||||
- 'docs/reviews/**'
|
||||
pull_request:
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
@@ -21,43 +15,12 @@ concurrency:
|
||||
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
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
process-workflow-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: process.yml идентичен в main и dev
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||||
<(git show origin/dev:.github/workflows/process.yml); then
|
||||
echo "main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
env:
|
||||
@@ -65,9 +28,8 @@ jobs:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
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). Хуки ловят нарушение на
|
||||
@@ -78,9 +40,9 @@ jobs:
|
||||
process-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Process gate
|
||||
env:
|
||||
@@ -88,181 +50,34 @@ jobs:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
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"
|
||||
|
||||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||||
# же входами уже завершилась успешно.
|
||||
#
|
||||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||||
# здесь эквивалентность входов доказана хешем.
|
||||
#
|
||||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||||
backend: ${{ steps.probe.outputs.backend }}
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
done
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
- name: Маркер smoke
|
||||
id: m_smoke
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер golden
|
||||
id: m_golden
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||||
lookup-only: true
|
||||
- name: Маркер performance_smoke
|
||||
id: m_perf
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||||
lookup-only: true
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||||
lookup-only: true
|
||||
- name: Что переиспользуем
|
||||
id: probe
|
||||
env:
|
||||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||||
run: |
|
||||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||||
# успех, который уже дважды стоил нам дня (#171, #207).
|
||||
waive() {
|
||||
if [ "$2" = "true" ]; then
|
||||
echo "$1=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
|
||||
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "$1=false" >> "$GITHUB_OUTPUT"
|
||||
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
|
||||
waive smoke "$SMOKE"
|
||||
waive golden "$GOLDEN"
|
||||
waive performance_smoke "$PERF"
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -273,60 +88,31 @@ jobs:
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
# Копия стенда больше не коммитится (#255): сверяются две обязательные.
|
||||
- name: Card bundle snapshots in sync
|
||||
run: cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
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, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Install Chromium for Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build a fresh bundle for the smokes
|
||||
run: npm run bundle:sync
|
||||
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: '3'
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Smoke suite
|
||||
run: |
|
||||
fail=0
|
||||
index=0
|
||||
ran=0
|
||||
mkdir -p /tmp/smoke-logs
|
||||
# Деление по порядковому номеру файла: список отсортирован, поэтому
|
||||
# разбиение детерминировано и не зависит от времени прогона.
|
||||
for f in demo/smoke_*.mjs; do
|
||||
index=$((index + 1))
|
||||
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
|
||||
ran=$((ran + 1))
|
||||
name=$(basename "$f" .mjs)
|
||||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||||
echo "ok $name"
|
||||
@@ -336,68 +122,30 @@ jobs:
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
echo "--- шард ${SHARD}/${SHARDS}: прогнано ${ran} из ${index}"
|
||||
# Пустой шард — признак, что деление сломалось, а не что работы нет.
|
||||
if [ "$ran" -eq 0 ]; then echo "шард пуст: проверьте деление"; exit 1; fi
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: smoke-logs-${{ matrix.shard }}
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
|
||||
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
|
||||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||||
smoke_done:
|
||||
needs: [smoke, reuse]
|
||||
if: needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||||
|
||||
golden:
|
||||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.golden != 'true'
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact source under review
|
||||
run: npm run bundle:sync
|
||||
run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
- name: Capture or verify golden matrix
|
||||
id: golden
|
||||
run: |
|
||||
@@ -410,60 +158,28 @@ jobs:
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "golden прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
|
||||
performance_smoke:
|
||||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [frontend, reuse]
|
||||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше.
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Build the exact candidate source
|
||||
run: npm run bundle:sync
|
||||
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
|
||||
@@ -472,55 +188,21 @@ jobs:
|
||||
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
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||||
|
||||
backend:
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
# 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
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.13" }
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
run: python -m pytest tests_backend/ -q
|
||||
# Маркер пишется последним шагом: он существует только если всё выше
|
||||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "backend прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|
||||
|
||||
@@ -7,8 +7,3 @@ __pycache__/
|
||||
.venv-backend/
|
||||
artifacts/
|
||||
.agents/
|
||||
|
||||
# Копия бандла для стенда: её собирает `npm run bundle:sync`, а в репозитории
|
||||
# она только росла — 364 версии по 1.16 МБ за семь недель (#255). Обязательных
|
||||
# копий две: `dist/` (артефакт сборки) и `custom_components/` (её ставит HACS).
|
||||
demo/srv/assets/houseplan-card.js
|
||||
|
||||
@@ -38,7 +38,8 @@ 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.
|
||||
issue. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
|
||||
human-facing view synchronised from the labels, not the source of truth.
|
||||
|
||||
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
|
||||
@@ -87,7 +88,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
|
||||
| **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/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/houseplan-card.js` is no longer committed (#255): build it with `npm run bundle:sync` |
|
||||
| **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`,
|
||||
@@ -154,28 +155,6 @@ 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
|
||||
@@ -255,15 +234,10 @@ The exchange happens in **issue comments** — there is no local message bus. Ve
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
|
||||
with no High findings the verdict is yellow, the author fixes it and the fix
|
||||
passes another review cycle — no separate issue (owner's decision 2026-08-19,
|
||||
#202: filing and servicing an issue costs far more than fixing in place). Only
|
||||
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
|
||||
never patched from this branch. Low is fixed or waived with a note
|
||||
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.
|
||||
@@ -322,22 +296,12 @@ byte-for-byte:
|
||||
|
||||
```
|
||||
cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
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.
|
||||
During the implementation cycle only the fast gates run. `smoke`, `golden` and
|
||||
`performance_smoke` spin up Chromium and belong to the pre-beta run — which is then
|
||||
mandatory and complete.
|
||||
|
||||
**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.
|
||||
@@ -358,22 +322,14 @@ complete Linux CI artifact; never accept a partial scenario or images merely to
|
||||
CI green. See `demo/golden/README.md`.
|
||||
|
||||
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
|
||||
TypeScript and package-lock build inputs. Every browser check must verify it
|
||||
before trusting a result — benchmarks, golden runs and documentation captures
|
||||
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
|
||||
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
|
||||
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
|
||||
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
|
||||
assertions go red and part stay green, which reads as a logic defect.
|
||||
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. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
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.**
|
||||
|
||||
|
||||
@@ -18,12 +18,13 @@ requests still belong in [issues](https://github.com/Matysh/houseplan-card/issue
|
||||
|
||||
## 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.
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
|
||||
[GitHub Project v2](https://github.com/users/Matysh/projects/1) are the
|
||||
only active project backlog. Issues own scope and acceptance criteria; Project
|
||||
v2 owns prioritization and workflow status. Before starting planned work, link
|
||||
it to an existing issue or create one, add it to the Project, and keep both
|
||||
surfaces 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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> метках, Project v2 остаётся человеческим представлением. При расхождении
|
||||
> документации с GitHub побеждает GitHub. При расхождении этого документа с
|
||||
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
|
||||
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
|
||||
@@ -35,7 +35,7 @@
|
||||
| **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/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
|
||||
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/srv/assets/houseplan-card.js`, `demo/golden/baselines/**` | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
|
||||
|
||||
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
|
||||
бандл» перестают быть лазейками.
|
||||
@@ -86,12 +86,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
### 2.2 Аналитика и оценка
|
||||
|
||||
Задача разбирается — и разобранная **сама идёт дальше**. Умолчание изменено
|
||||
решением владельца 2026-08-14: раньше аналитика ждала подтверждения по каждому
|
||||
пункту, и большинство ожиданий ничего не меняло — issue в основном описаны
|
||||
однозначно.
|
||||
Задача разбирается, продуктовое «да» ещё не дано.
|
||||
|
||||
- **Кто:** агент-аналитик. Владелец не утверждает переход — он правит асинхронно.
|
||||
- **Кто:** агент-аналитик готовит, владелец решает.
|
||||
- **Чек-лист**, результат — комментарием в issue:
|
||||
1. дубликаты проверены (ссылки на похожие issue);
|
||||
2. в скоупе по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`;
|
||||
@@ -101,21 +98,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
|
||||
владельцем на любой стадии.
|
||||
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
|
||||
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
|
||||
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
8. лёгкий трек — да/нет по критериям §5.
|
||||
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
|
||||
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
|
||||
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
|
||||
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
@@ -131,14 +117,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
|
||||
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
|
||||
Low либо правится, либо снимается решением ревьюера с записью.
|
||||
- **High-находки блокируют.** Medium/Low — либо правятся, либо становятся
|
||||
отдельными issue со ссылкой; «оставили в тексте ревью» не считается закрытием.
|
||||
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
4 циклов (§4).
|
||||
|
||||
### 2.5 Готово к разработке (DoR)
|
||||
|
||||
@@ -191,11 +173,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
- **High блокируют.** Medium **обязаны** превратиться в issue.
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
4 циклов (§4).
|
||||
|
||||
### 2.8 Закрытие после выпуска беты
|
||||
|
||||
@@ -216,42 +196,6 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Отклонено:** закрытие с записанной причиной (вне скоупа, дубликат, цена не
|
||||
оправдана). Тихое закрытие без причины запрещено.
|
||||
|
||||
### 2.10 Повторный раунд ревью — объём по дельте
|
||||
|
||||
Решение владельца 2026-08-19 (issue #214). Относится и к ревью ТЗ, и к
|
||||
код-ревью, начиная со второго цикла.
|
||||
|
||||
**Предмет повторного раунда — дельта, а не задача целиком.** Раньше объём
|
||||
разбора не был оговорён, промпт ревьюера для всех раундов был одинаковым, и
|
||||
повторный цикл заново выводил продуктовую рамку и перепроверял AC, которых
|
||||
правка не касалась: r2 по #150 стоил полного прогона конвейера ради одной
|
||||
строки в тестовой фикстуре.
|
||||
|
||||
Порядок:
|
||||
|
||||
1. найти вердикт предыдущего раунда и **SHA, на котором он получен**; SHA в
|
||||
вердикте не назван — это находка;
|
||||
2. объявить дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла ТЗ либо тела
|
||||
issue для этапа ТЗ;
|
||||
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
|
||||
строкой кода или текста, а не заявлением автора;
|
||||
4. заново проверять только те AC, чьё доказательство дельта задевает;
|
||||
5. **раздел «Унаследовано из r<N−1>»** обязателен: что принято без повторной
|
||||
проверки, со ссылкой на документ того раунда и SHA. Без перечня сокращение
|
||||
превращается в молчаливое доверие.
|
||||
|
||||
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
|
||||
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
|
||||
|
||||
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
|
||||
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
|
||||
новая подсистема, либо объём дельты сопоставим с исходной задачей.
|
||||
|
||||
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
|
||||
сломать AC, который предыдущий раунд признал выполненным — так появилась
|
||||
регрессия #102. Граница не «только находки», а «находки плюс всё, до чего
|
||||
дотягивается дельта».
|
||||
|
||||
---
|
||||
|
||||
## 3. Правила
|
||||
@@ -273,10 +217,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
ревью-гейт.
|
||||
7. **Ревью возвращает не более 4 раз.** Пятый заход — решение владельца: разделить,
|
||||
отклонить или арбитраж (§4).
|
||||
8. **High блокирует. Medium в скоупе чинится в текущем issue** (без High —
|
||||
жёлтый вердикт и повторный цикл); Medium вне скоупа становится отдельным
|
||||
issue (#202). Low либо правится, либо снимается решением ревьюера с записью
|
||||
в документе.
|
||||
8. **High блокирует. Medium становится issue.** Low либо правится, либо снимается
|
||||
решением ревьюера с записью в документе.
|
||||
9. **Скоуп не расширяется.** Всё найденное вне ТЗ — новый issue, а не попутная
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
@@ -307,24 +249,11 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
## 4. Лимит циклов ревью: 4
|
||||
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**.
|
||||
Оба ревью-гейта возвращают задачу на правки не более **4 раз**. Счётчик виден в
|
||||
имени документа: `-r1` … `-r4`; на четвёртом заходе ставится метка `review-4`.
|
||||
|
||||
- **Что считается циклом:** отправка на ревью → вердикт с блокирующими находками
|
||||
→ возврат. Уточняющий вопрос без вердикта циклом не считается.
|
||||
- **Зелёный вердикт цикла не образует** и бюджет не тратит (решение владельца
|
||||
2026-08-20, issue #227): он ничего не вернул на правки. Практический случай —
|
||||
зелёное ревью, слияние которого не удалось: конвейер сам предписывает ребейз и
|
||||
возврат метки, и этот заход не должен наказываться. Раньше счётчик считал все
|
||||
вердикты подряд, и на #225 последовательность жёлтый → зелёный → ребейз дала
|
||||
`review-4` на задаче с зелёным ревью и зелёным CI.
|
||||
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
|
||||
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
|
||||
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
|
||||
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
|
||||
циклов (шесть документов = четыре цикла плюс два ребейза).
|
||||
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
|
||||
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
|
||||
лимита, конвейер сообщает пересчёт, но метку не трогает.
|
||||
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
|
||||
решение одно из трёх:
|
||||
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
|
||||
@@ -501,12 +430,8 @@ issue #NN
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
|
||||
блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… ·
|
||||
Документ: docs/reviews/…`
|
||||
(«→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору.
|
||||
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
|
||||
его не тратят, поэтому заход и K расходятся, #227)
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · цикл r<N>/<лимит> ·
|
||||
High: N · Medium: N → #… · Документ: docs/reviews/…`
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
@@ -537,58 +462,23 @@ issue #NN
|
||||
npx tsc --noEmit
|
||||
npm test
|
||||
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
|
||||
# копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255)
|
||||
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
|
||||
&& cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
node demo/smoke_<целевые>.mjs
|
||||
npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
|
||||
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
|
||||
```
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
|
||||
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (сколько их —
|
||||
считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от
|
||||
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
|
||||
диффу, печатает
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла. По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (их 127 — прогон всех
|
||||
уместен только когда задача задевает всё), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
|
||||
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
|
||||
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
|
||||
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
|
||||
человек.
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
|
||||
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
|
||||
Условие честности такого сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему. Непрогнанный гейт становится видимым решением, а не молчаливым
|
||||
пропуском.
|
||||
|
||||
**Одно число — один источник.** Любая величина, которую пользователь видит
|
||||
дважды — превью против записи, подпись против площади, подсветка инструмента
|
||||
против сохранённого значения, — обязана считаться в одном месте. Три дефекта
|
||||
подряд имели ровно эту причину: #234 (резинка показывала 12 см, запись хранила
|
||||
24), #233 (подпись мерила по осевым линиям, площадь рядом — по полу) и способ,
|
||||
которым #234 нашли (подсветка «Толщины» врала согласованно с записью). Ревьюер
|
||||
отвечает на вопрос прямо: какое число в этом диффе видно дважды и один ли у него
|
||||
источник. Механическая часть правила закреплена тестом
|
||||
`test/single-source-numbers.test.mjs` — строку с единицей измерения собирает
|
||||
только канонический форматтер; смысловая часть остаётся за ревью.
|
||||
|
||||
**Гейт беты** (условие закрытия issue): CI Validate зелёный на точном SHA тега.
|
||||
|
||||
Часть гейтов запускается только здесь, то есть **после** пройденного код-ревью.
|
||||
@@ -602,12 +492,9 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
|
||||
## 9. Метки — канонический статус
|
||||
|
||||
Статус читается из меток: их видно в списке issue, их читает любой токен с
|
||||
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
|
||||
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
|
||||
представление статуса рядом с метками требовало отдельного скоупа токена,
|
||||
синхронизации и внимания, а давало вид доски. Два источника одного факта
|
||||
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
|
||||
Статус читается из меток: их видно в списке issue и их читает любой токен с
|
||||
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
|
||||
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
|
||||
|
||||
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
|
||||
документе были только на бумаге; репозиторий с самого начала жил на английских.
|
||||
@@ -684,13 +571,6 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
считается от `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`, поэтому при его отсутствии хук печатает
|
||||
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
||||
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
||||
@@ -782,8 +662,8 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
```
|
||||
|
||||
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
|
||||
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на каждую
|
||||
Medium-находку, кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
|
||||
вердикту, а не модель.**
|
||||
|
||||
@@ -813,28 +693,6 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
|
||||
ошибки.
|
||||
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
|
||||
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
|
||||
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
|
||||
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
|
||||
которую ревьюер не читал. Именно так пришёл регресс #234. Шаг перед ревью делает
|
||||
одно из трёх:
|
||||
|
||||
- ветка уже содержит весь `dev` — ничего;
|
||||
- отстала и ребейзится чисто — ребейз, `push --force-with-lease`, ревью по
|
||||
приведённому состоянию. Факт ребейза передаётся в промпт, чтобы сработало
|
||||
правило §7.2 о полном разборе вместо дельты;
|
||||
- конфликт — возврат в `S6-in-progress` **до** запуска ревью. Цикл при этом не
|
||||
расходуется: код никто не читал, вердикта нет.
|
||||
|
||||
Проверка стоит до ревью не только ради совпадения SHA. Конфликт всё равно вернул бы
|
||||
задачу, но обнаруживался он после сорока пяти минут работы ревьюера и потраченных
|
||||
лимитов подписки, хотя виден за пять секунд до них.
|
||||
|
||||
`--force-with-lease` здесь обязателен с явным ожидаемым значением: между чтением
|
||||
ветки и пушем автор мог запушить коммит, и слепой `--force` потерял бы его молча.
|
||||
Расхождение lease — падение прогона, а не предупреждение.
|
||||
|
||||
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
|
||||
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
|
||||
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
|
||||
@@ -929,8 +787,7 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
- принятие golden-эталонов ради зелёного CI или по частичному артефакту;
|
||||
- закрытие issue до выпуска беты с зелёным CI;
|
||||
- переоткрытие закрытого issue вместо нового бага;
|
||||
- Medium-находки, оставленные как TODO в документе ревью: в скоупе — чинятся
|
||||
в текущем issue, вне скоупа — становятся отдельным (#202);
|
||||
- Medium-находки, оставленные как TODO в документе ревью;
|
||||
- **параллельные бэклоги** в файлах (`BACKLOG-*.md`, «планы» в docs);
|
||||
- ревью-документы вне репозитория;
|
||||
- попутные правки «раз уж я здесь»;
|
||||
|
||||
@@ -1,157 +1,379 @@
|
||||
# 🏠 House Plan — a live home map for Home Assistant
|
||||
# 🏠 House Plan — interactive floor plan card for Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](LICENSE)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
|
||||
**Turn Home Assistant into a live, interactive map of your home.** Upload or draw
|
||||
a floor plan, outline the rooms with your mouse — and every smart device appears
|
||||
in its real place: live states, tap-to-toggle lights, temperature and humidity per
|
||||
room, Zigbee signal maps, glowing light pools and a fullscreen kiosk mode for wall
|
||||
tablets. No YAML, no Inkscape, no external editors — the whole floorplan lives
|
||||
right on your Lovelace dashboard.
|
||||
|
||||
<!-- docs-section: overview -->
|
||||
> **Use a desktop computer to edit plans.** View and kiosk are fully supported
|
||||
> on phones and tablets. The editors are designed primarily for a desktop
|
||||
> browser with a mouse and keyboard; individual editing operations on touch
|
||||
> devices may be awkward, limited, or unavailable.
|
||||
|
||||
## 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.
|
||||
> ### 🚀 Try it live — no install needed
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — a real Home Assistant
|
||||
> with a ready-made plan. Log in as **`demo`** / **`demo`** and click anything:
|
||||
> toggle lights, open the editors, break things. The stand resets itself to a
|
||||
> pristine state every hour.
|
||||
|
||||

|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
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.
|
||||
**Feature highlights**
|
||||
|
||||
> **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).
|
||||
- ♾️ **An infinite canvas** — there is no "plan size" and no edge to run
|
||||
past: draw and place devices anywhere, pan at any zoom, zoom out to see
|
||||
everything, and let one tap fit the whole plan back on screen.
|
||||
- 🖱 **GUI-first floorplan editor** — rooms, doors & windows, island rooms,
|
||||
virtual walls and a visual decor layer, all drawn with clicks; room resize
|
||||
by dragging walls, with live lengths and areas as you drag; smart
|
||||
alignment guides and a live ruler in real meters/feet.
|
||||
- 🖼 **A backdrop you can move and scale** — drag the floor-plan picture into
|
||||
place and pull a corner to size it, with its real size in metres shown as
|
||||
you drag, so the drawing and the photo of your plan finally line up.
|
||||
- 💡 **Lights toggle on click** out of the box; wall-switch markers can control
|
||||
whole groups of lights (works for dumb switches and stateless remotes too).
|
||||
- 🌒 **“Light sources” fill** — a dark house where every lit lamp lights exactly
|
||||
the floor it can see: through doorways and open boundaries, stopped by walls,
|
||||
columns and partitions, which cast real shadows.
|
||||
- ☀️ **The sun on the plan** — set the compass and the backdrop lives with
|
||||
the day (white noon → golden hour → deep night), while windows on exterior
|
||||
walls cast real wedges of sunlight into the rooms; optional cloud cover
|
||||
from a weather entity.
|
||||
- 🪟 **Curtains and blinds open on a tap** — one action opens, closes or
|
||||
stops a cover, and the icon itself morphs between open and closed while a
|
||||
soft ring pulses as it travels.
|
||||
- 🌡 **Room cards** with temperature, humidity, Zigbee LQI and light count;
|
||||
comfort-range temperature fills, per-room signal heatmap.
|
||||
- 🚪 **Doors, windows and locks** with contact sensors — unlocking is always an
|
||||
explicit button, never an accidental tap.
|
||||
- 📺 **Kiosk mode** for wall tablets and TVs: fullscreen, swipe between floors,
|
||||
auto-carousel, per-screen icon sizes.
|
||||
- 🤖 **Live robot vacuums** — the dock marker stays put while a round puck
|
||||
drives the plan in real time, pouring its path out from under itself;
|
||||
current and previous cleanup runs are recorded server-side. Calibration is
|
||||
one click (rooms matched by name) or a drag-and-stretch overlay. A diagnostic
|
||||
source picker also covers registry-less map cameras without silently
|
||||
rebinding broken sources. Works with Xiaomi Cloud Map Extractor, Tasshack
|
||||
dreame-vacuum and Valetudo.
|
||||
- 🔔 New devices appear automatically with a red “new” dot; the layout is stored
|
||||
**server-side** — one shared plan for every user and screen, synced live.
|
||||
|
||||
<!-- docs-section: features -->
|
||||
---
|
||||
|
||||
## What House Plan provides
|
||||
## What it is and why
|
||||
|
||||
- **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.
|
||||
House Plan shows your smart home the way it actually looks — on a floor plan. Instead of long lists of entities, you see rooms and devices in their real places: where the leak is, what the temperature is in the kids' room, whether the light is on in the hallway, whether the gate is open.
|
||||
|
||||

|
||||
This is convenient when:
|
||||
|
||||
<!-- docs-section: first-run -->
|
||||
- you have many devices and lists are awkward to use;
|
||||
- you need to grasp the state of the house "at a glance";
|
||||
- you want to give access to family members — anyone can figure out a picture;
|
||||
- you want a beautiful overview screen for a wall-mounted tablet.
|
||||
|
||||
## Your first working room
|
||||
The integration consists of two parts that are installed together:
|
||||
|
||||
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.
|
||||
- **the Lovelace card** `houseplan-card` — the interactive plan itself;
|
||||
- **the server-side component** — stores the room markup and icon positions in Home Assistant, so the plan is identical in all browsers and on all devices.
|
||||
|
||||

|
||||
---
|
||||
|
||||

|
||||
## How it differs from alternatives
|
||||
|
||||

|
||||
A house plan in Home Assistant is usually built with `picture-elements`,
|
||||
`ha-floorplan`, or newer GUI cards that draw walls and furniture in the
|
||||
dashboard. Those either lock you into YAML/SVG, or store the plan in the
|
||||
Lovelace card config. House Plan is a **shared live map** backed by a Home
|
||||
Assistant integration:
|
||||
|
||||

|
||||
| | House Plan | picture-elements / ha-floorplan | GUI draw cards (e.g. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Setup** | Entirely through the UI, with the mouse | Manual YAML / Inkscape SVG | In-card drawing of walls & furniture |
|
||||
| **Adding devices** | Automatic, by HA **area** | You type every entity by hand | Place entities by hand on the drawing |
|
||||
| **Icon coordinates** | Drag with the mouse | Count pixels into YAML | Drag on the canvas |
|
||||
| **Room markup** | Built-in outline editor bound to areas | External SVG editor | Draw walls yourself (furniture CAD) |
|
||||
| **Storage** | On the HA server (`.storage`, shared, multi-client) | In the dashboard YAML | In the card / dashboard YAML |
|
||||
| **Overlays** | Glow, climate, LQI, sun, vacuums, kiosk | Whatever you script in SVG/CSS | Varies by card |
|
||||
| **Zoom** | Smooth vector zoom | Usually a fixed image | SVG / virtual canvas |
|
||||
|
||||

|
||||
**One sentence:** House Plan is the shared, area-aware live map of your home —
|
||||
not a general-purpose CAD package. Its Background editor covers practical
|
||||
decor, labels and furniture; if you need unrestricted architectural drafting,
|
||||
a draw-centric tool may fit better. With a plan and HA areas, House Plan keeps
|
||||
every tablet on the same live layout.
|
||||
|
||||
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.
|
||||
Key advantages in short:
|
||||
|
||||
<!-- docs-section: installation -->
|
||||
- **No code at all.** Everything — spaces, rooms, devices — is configured with clicks.
|
||||
- **Automatic device placement.** Outline a room and bind it to a Home Assistant area — the devices of that area appear on the plan by themselves.
|
||||
- **Manual additions of your own.** Any device, group or even a "virtual" point can be placed on the plan manually, with a name, icon, model, link and an attached PDF manual.
|
||||
- **Live states.** Temperature, Zigbee signal strength, on/off, open/closed — everything updates in real time.
|
||||
Icon colors follow one principle — **yellow means the device is doing its main job right now**:
|
||||
a light is shining, a socket is powering, a fan is spinning, a vacuum is
|
||||
cleaning, a radiator valve is actually heating (not merely enabled). For climate integrations,
|
||||
a reported work action is authoritative; when an integration exposes only its enabled HVAC mode,
|
||||
that mode is the best available fallback. Orange = open / unlocked.
|
||||
A pulsing red ring = an emergency (leak, smoke, gas). An RGB bulb's colour lives in its glow
|
||||
spot (glow fill), where the spot itself is the on/off indicator and the badge stays standard.
|
||||
A translucent icon = unavailable. Dark = idle.
|
||||
- **A coherent visual Background editor.** Draw lines/shapes, place labels and
|
||||
furniture, edit physical styles and transform every object with the same
|
||||
selection model. The plan image has its own move/resize/rotate tool, numeric
|
||||
properties and shared Undo/Redo.
|
||||
- **Crisp zoom.** Zooming in does not "blur" the picture: the plan, labels and icons remain vector-sharp at any scale.
|
||||
|
||||
---
|
||||
|
||||
## Wall tablet / TV (kiosk mode)
|
||||
|
||||
Add the card to a dedicated dashboard with a **panel view** and set `kiosk: true`
|
||||
(or tick "Wall device (kiosk) mode" in the card editor):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # seconds between auto space switches, 0 = off (nice for TVs)
|
||||
```
|
||||
|
||||
No header, no editors — just the live plan. Swipe to change floors (at 1:1),
|
||||
pinch to zoom, double-tap to reset. Long-press an empty spot for 3 seconds to
|
||||
tune icon and text sizes for THIS screen (saved per device). To hide Home
|
||||
Assistant's own header use the companion app's kiosk settings or the
|
||||
[kiosk-mode](https://github.com/NemesisRE/kiosk-mode) plugin.
|
||||
|
||||
## Installation
|
||||
|
||||
### HACS
|
||||
One click if you already run HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
[](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:
|
||||
### Via HACS (recommended)
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
1. Open **HACS → menu (⋮) → Custom repositories**.
|
||||
2. Paste the URL of this repository, set the category to **Integration**, and click **Add**.
|
||||
3. Find **House Plan** in the list, install it and **restart Home Assistant**.
|
||||
4. Go to **Settings → Devices & Services → Add integration** and select **House Plan**.
|
||||
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
The card is registered automatically — no need to add a Lovelace resource manually.
|
||||
|
||||
### Manual installation
|
||||
> **Card doesn't load (`Custom element doesn't exist: houseplan-card`) or you manage Lovelace
|
||||
> resources in YAML?** Add the resource manually pointing at the URL the integration *serves*:
|
||||
>
|
||||
> ```yaml
|
||||
> resources:
|
||||
> - url: /houseplan_files/houseplan-card.js
|
||||
> type: module
|
||||
> ```
|
||||
>
|
||||
> Do **not** use `/custom_components/houseplan/frontend/houseplan-card.js` — that is the file
|
||||
> on disk, which Home Assistant does not serve over HTTP (you'll get a `text/plain` MIME error
|
||||
> and the element never registers). The correct, integration-served URL is
|
||||
> `/houseplan_files/houseplan-card.js`. Both cards (`houseplan-card` and
|
||||
> `houseplan-space-card`) ship in that one file — no separate resource is needed.
|
||||
|
||||
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
|
||||
Assistant, and add the House Plan integration.
|
||||
### Manually
|
||||
|
||||
### Add the card
|
||||
1. Copy the `custom_components/houseplan` folder into the `config/custom_components` directory of your Home Assistant.
|
||||
2. Restart Home Assistant.
|
||||
3. Add the integration: **Settings → Devices & Services → Add integration → House Plan**.
|
||||
|
||||
Create a dashboard view (Panel works best) and add the card in the UI or as:
|
||||
### Adding a plan screen
|
||||
|
||||
Create a new dashboard tab (a "Panel" view works best) and add the card:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: House plan
|
||||
```
|
||||
|
||||
Different screens may start on different spaces:
|
||||
Nothing else needs to be specified — everything else is configured right on the screen.
|
||||
|
||||
```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.
|
||||
## How to use
|
||||
|
||||
## Detailed documentation
|
||||
### Step 1. Add a space (floor)
|
||||
|
||||
- [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)
|
||||
On first open the plan is still empty — House Plan immediately offers to create the first space.
|
||||
If your Home Assistant already has **floors** configured, a wizard offers to create a space
|
||||
for each floor (names prefilled, a plan image is asked for one by one; any floor can be skipped).
|
||||
|
||||
<!-- docs-section: support -->
|
||||

|
||||
|
||||
## Support and feedback
|
||||
In the dialog, set a **name** (for example, "1st floor") and pick the background: **upload** a floor-plan image (SVG, PNG, JPG, WebP), **choose one already uploaded** to the server earlier, or select **"no background, I'll draw the rooms"** for a hand-drawn space. The canvas is infinite; an image keeps its own proportions by default and can be moved, resized or rotated at any time in the Background editor.
|
||||
|
||||
- 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).
|
||||
> 💡 You can draw the background in any floor planner (for example, REMPLANNER) or photograph a paper plan. SVG works best — it stays crisp when zoomed in.
|
||||
|
||||
License: [MIT](LICENSE).
|
||||
Later you can add as many spaces as you like (floors, yard, garage) with the **+** button next to the tabs.
|
||||
|
||||
### Step 2. Outline the rooms
|
||||
|
||||
After the first space is added, the card switches to the **Plan** tab by itself. The card has three mode tabs in the header — **View** (default: display and device control only, nothing can be moved or edited), **Plan** (rooms, openings, labels, space settings) and **Devices** (placing and configuring markers); the edit tabs are shown to administrators. In Plan, click grid points, connecting them with lines, and close the room outline by clicking the first point.
|
||||
|
||||
As soon as the outline is closed, the room-save dialog appears. Here you need to **bind the room to a Home Assistant area** — this is exactly what enables the automation. For utility rooms with no devices (hall, sauna) there is a **"No area"** button.
|
||||
|
||||

|
||||
|
||||
While drawing, a ruler follows the cursor showing the current segment's real length (metres, or feet + inches on an imperial Home Assistant). The scale is set per space — the **"Scale (grid cell size)"** field in the space dialog says how many centimetres one grid cell represents (default 5 cm).
|
||||
|
||||
Rooms may not overlap: a click strictly inside an existing room, or an outline that would swallow one, is refused. Two more tools help you reshape the plan later:
|
||||
|
||||
- **Merge** — click a room, then a neighbour that shares a wall; they fuse into one. A dialog picks which name and area survive.
|
||||
- **Split** — click a room, then two points on its walls; the chord cuts it in two. The bigger part stays the room it was (name, area, devices); the smaller one asks for a new name and area.
|
||||
|
||||
|
||||
### Doors, windows, gates and locks
|
||||
|
||||
In markup mode the **"Opening"** tool places doors, windows and gates: click next to a wall and the
|
||||
opening snaps onto it. Pick the type, the **length in real centimetres** (defaults: door 90 cm,
|
||||
window 120 cm, gate 300 cm), an open/close sensor and — for doors and gates — a **lock entity**.
|
||||
|
||||
With a sensor bound, the plan comes alive: the door leaf swings on its hinge and the swing arc
|
||||
draws itself in as the real door opens; a window opens its two casements. While open, the moving
|
||||
parts take an accent colour. A gate keeps a 3–4 m opening compact on the plan: two half-width
|
||||
leaves open only 10° outwards, without a full-width swing arc, while contact, lock and light
|
||||
passage work exactly like a door. A door or gate with a lock shows a padlock badge next to it — green when
|
||||
locked, orange when unlocked. For safety the lock can **not** be toggled from the plan; a click
|
||||
on the opening shows a status card with both states instead.
|
||||
|
||||
Openings are easy to adjust later: hovering one highlights it, you can **drag it along the
|
||||
walls** (it slides around corners too), and a **double click opens its properties**.
|
||||
|
||||
### Step 3. Devices appear by themselves
|
||||
|
||||
As soon as you save a room bound to an area, **the devices of that area are automatically laid out inside the outline**. These are the same devices shown on the **Settings → Devices → (filtered by the room)** page — only the meaningful ones, without service records, bridges and duplicates.
|
||||
|
||||
By default only meaningful devices make it onto the plan: non-physical ones (service records, bridges, scenes, individual lamps folded into a light group) arrive with the **"Hide device from plan"** checkbox already ticked. The checkbox is yours from then on — every device dialog has it, virtual devices included. To see and un-hide them, open the device editor and press **"Hidden and disabled"**: user-hidden devices appear as translucent blue ghosts, a click opens the dialog. Hidden devices still count toward the room's Zigbee signal, but cast no light. A device disabled in Home Assistant appears there as a labelled grey service ghost and is excluded from all plan data/actions until it is enabled in HA again.
|
||||
|
||||
From here on you can just use the plan: clicking an icon opens the device card with the model, link and a button to jump into Home Assistant.
|
||||
|
||||

|
||||
|
||||
### Step 4. Zoom
|
||||
|
||||
The mouse wheel or the **- / ⊹ / +** buttons zoom the plan in and out; on a touch screen the two-finger pinch works. Zoomed out you see the whole plan, zoomed in you see the details, and everything stays crisp. The zoom level is remembered separately for each space.
|
||||
|
||||

|
||||
|
||||
### Step 5. Put the icons in their places
|
||||
|
||||
Switch to the **Devices** tab to arrange icons: drag them with the mouse, click one to open its editor. In **View** mode nothing can be moved — panning the map never displaces a sensor (a top user request). Positions are saved on the server and are identical in all browsers and devices. The **↺** button restores the automatic layout.
|
||||
|
||||

|
||||
|
||||
### Tap actions: control devices from the plan
|
||||
|
||||
By default a tap on an icon opens its info card. A device can instead use the
|
||||
universal **Toggle state** action. Its editor shows the exact entity or configured
|
||||
group, the current state and what the next tap will do; when nothing can be toggled,
|
||||
it says so and the tap is a quiet no-op rather than an unexpected info-card fallback.
|
||||
Lights keep their convenient toggle default. Covers and valves use open/close/stop
|
||||
semantics automatically, while locks, alarm panels and secure garage/door/gate covers
|
||||
remain blocked. An exact entity binding never falls through to a sibling switch, and
|
||||
temporarily unavailable group members are skipped without erasing the configuration.
|
||||
A **long press** still opens the info card and right-click still opens HA more-info.
|
||||
|
||||
### Icon rules
|
||||
|
||||
Which MDI icon a device gets is decided by **icon rules** — editable right in the card
|
||||
(the ⬡ button in the header): an ordered list of “name pattern → icon” regexes with a
|
||||
live test field, bilingual defaults (EN/RU) and a one-click reset. When no rule
|
||||
matches, the entity *device class* decides (thermometer for temperature sensors, etc.).
|
||||
|
||||
### Step 6. Adding your own devices manually
|
||||
|
||||
You can also place a **single entity** (not just a whole device): start typing in the binding search and individual entities appear next to devices — handy when one device exposes several values (e.g. temperature and humidity) and you want each as its own icon.
|
||||
|
||||
|
||||
Not everything has to be left to the automation. With the **+** button in the header you can place any device, group or a **virtual point** on the plan (for example, an "Inlet valve" that does not exist as a device). Set a name, icon, model, link, description and, if you wish, attach a **PDF manual**.
|
||||
|
||||
To represent a dumb physical lamp controlled by a smart relay, place a virtual
|
||||
point where the lamp really is and set **Light source → Always**. Manual colour,
|
||||
brightness and radius stay available even though the point has no HA entity.
|
||||
Then open the relay and add that plan source under **Controls other light
|
||||
sources**. The relay continues to show the aggregate working state, while Glow,
|
||||
room fill and statistics belong to the lamp's position. An unlinked passive
|
||||
Always source is deliberately constant-on. With several own `light.*`/`switch.*`
|
||||
entities, Always also offers a leading-entity selector; a missing saved choice
|
||||
is warned about and retained while a deterministic fallback is used.
|
||||
|
||||
To make that virtual lamp manually switchable without creating a Home
|
||||
Assistant helper, also choose **Tap action → Toggle state** on the lamp itself.
|
||||
This exact combination — virtual binding, **Light source → Always**, and
|
||||
**Toggle state** — stores a shared on/off state in the House Plan integration.
|
||||
It survives page reloads and Home Assistant restarts and updates Glow, room
|
||||
fill/statistics, full cards and `houseplan-space-card` together. Any signed-in
|
||||
dashboard viewer may toggle it. While this manual mode is active, saved
|
||||
**Controls other light sources** remain intact but are not called; changing the
|
||||
role, binding or tap action restores their normal behaviour. This operational
|
||||
state is deliberately not part of plan exports or Home Assistant entities.
|
||||
|
||||
The same dialog controls how the device looks on the plan. **Display** switches between the
|
||||
icon badge, an animated **presence ripple** (pulsing rings while the entity is active, a faint
|
||||
dot when idle — great for motion sensors) or both, with a per-device ring colour and size. The
|
||||
**icon size** (×0.5–3) and **rotation** are also per-device, so a wall valve can be small and
|
||||
turned the way it is mounted.
|
||||
|
||||

|
||||
|
||||
### Styling the plan with card-mod (advanced, unsupported)
|
||||
|
||||
The card ships finished and has no CSS field of its own — but if you already run [card-mod](https://github.com/thomasloven/lovelace-card-mod), every object on the plan now carries a stable hook you can aim at: `data-hp="device"` (plus `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. We promise not to rename them; we do not ship card-mod, do not support it, and are not responsible for what your CSS does to the card. The full table, the examples and the limits are in **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Uninstalling
|
||||
|
||||
1. Remove the card (or the tab with the plan) from the dashboard.
|
||||
2. **Settings → Devices & Services → House Plan → Delete** the integration entry.
|
||||
3. Remove the integration from **HACS** (or delete the `custom_components/houseplan` folder if installed manually) and restart Home Assistant.
|
||||
4. Optionally delete the saved plan data: the `config/houseplan/` files (backgrounds and attachments) and the `houseplan.config` / `houseplan.layout` entries in the `config/.storage` directory.
|
||||
|
||||
---
|
||||
|
||||
## Getting help & sharing your plan
|
||||
|
||||
- 💬 **[Telegram chat — @ha_houseplan](https://t.me/ha_houseplan)** — questions,
|
||||
setup help, feature ideas, and screenshots of your plans. The fastest way to
|
||||
reach the author and other users.
|
||||
- 🐞 [GitHub issues](https://github.com/Matysh/houseplan-card/issues) — bug
|
||||
reports and feature requests (please attach your House Plan version).
|
||||
- 💡 [GitHub discussions](https://github.com/Matysh/houseplan-card/discussions) —
|
||||
longer-form ideas.
|
||||
- 📜 [Changelog](docs/CHANGELOG.md) — what changed in every version
|
||||
([на русском](docs/CHANGELOG.ru.md)).
|
||||
|
||||
When reporting a problem, the version number helps a lot: it is shown in the
|
||||
browser console on load (`HOUSEPLAN-CARD vX.Y.Z`) and in **Settings → Devices &
|
||||
Services → House Plan**.
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**Do I need to write anything in YAML?** No. The only line is adding the card to the dashboard; everything else is done with the mouse.
|
||||
|
||||
**My devices did not appear on the plan.** A device appears only if its Home Assistant area is bound to a drawn room. Check that the device has a room assigned (Settings → Devices) and that the room is outlined and bound to that area. Open the device editor and press **"Hidden and disabled"**: a blue ghost is user-hidden and can be shown; a grey disabled ghost must first be enabled in Home Assistant.
|
||||
|
||||
**Can I hide an unwanted device or rename it?** Yes — click the device on the plan and press "Edit" in its card: there you can change the name, icon, model or hide the icon.
|
||||
|
||||
**Is the data stored in the cloud?** No. Everything is stored locally in your Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub>Screenshots were taken on a real Home Assistant configuration.</sub></p>
|
||||
|
||||
@@ -1,161 +1,351 @@
|
||||
# 🏠 House Plan — живой план дома для Home Assistant
|
||||
# 🏠 House Plan — интерактивный поэтажный план дома для Home Assistant
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/Matysh/houseplan-card/releases)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://github.com/Matysh/houseplan-card/stargazers)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
|
||||
|
||||
<!-- docs-section: overview -->
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
|
||||
## Дом целиком — одним взглядом
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
|
||||
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
|
||||
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
|
||||
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
|
||||
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
|
||||
датчик протечки.
|
||||

|
||||
|
||||

|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||
|
||||
Настройка выполняется в графическом интерфейсе: без YAML-разметки, Inkscape и
|
||||
внешнего редактора плана. Данные плана и расположение устройств хранятся на
|
||||
сервере Home Assistant и синхронизируются между экранами.
|
||||
🇬🇧 [Documentation in English](README.md) · 💬 [Чат в Telegram: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||
|
||||
> **Редактируйте на компьютере.** Режим просмотра и киоск полноценно работают
|
||||
> на телефонах и планшетах. Редакторы рассчитаны прежде всего на мышь и
|
||||
> клавиатуру; на touch отдельные операции могут быть неудобны или недоступны.
|
||||
> Подробный контракт: [поддержка touch](docs/TOUCH-SUPPORT.md).
|
||||
**Главное**
|
||||
|
||||
<!-- docs-section: features -->
|
||||
- ♾️ **Бесконечный холст** — нет «размера плана» и нет края, за который
|
||||
нельзя выйти: рисуйте и ставьте устройства где угодно, тащите план на
|
||||
любом зуме, отдаляйтесь, чтобы увидеть всё, и одной кнопкой вписывайте
|
||||
план обратно в экран.
|
||||
- 🖱 **Редакторы прямо в карточке** — комнаты, двери, окна и ворота, комнаты-острова,
|
||||
виртуальные стены и декор-слой рисуются кликами; размеры комнат меняются
|
||||
перетаскиванием стен с живыми длинами и площадями; помощник выравнивания и
|
||||
линейка в реальных метрах.
|
||||
- 💡 **Свет переключается кликом** из коробки; значок выключателя может
|
||||
управлять группой ламп (в т.ч. «тупые» выключатели и кнопки-пульты).
|
||||
- 🌒 **Заливка «Свет по источникам»** — тёмный дом, где каждая горящая лампа
|
||||
освещает ровно тот пол, который видит: через проёмы и открытые границы,
|
||||
а стены, колонны и перегородки его не пропускают и дают настоящие тени.
|
||||
- ☀️ **Солнце на плане** — задайте компас, и фон живёт вместе с днём
|
||||
(белый полдень → золотой час → глубокая ночь), а окна внешних стен пускают
|
||||
в комнаты настоящие клинья солнечного света; облачность — опционально, от
|
||||
weather-сущности.
|
||||
- 🪟 **Шторы открываются тапом** — одно действие открывает, закрывает или
|
||||
останавливает штору, а сам значок морфится между открытым и закрытым
|
||||
видом и мягко пульсирует кольцом, пока штора едет.
|
||||
- 🌡 **Карточки комнат**: температура, влажность, Zigbee-сигнал, свет «1 из 3»;
|
||||
температурная заливка по комфортным границам.
|
||||
- 🚪 **Двери, окна и замки** с датчиками — отпирание только явной кнопкой,
|
||||
никогда случайным тапом.
|
||||
- 📺 **Киоск-режим** для настенных планшетов и ТВ: полноэкранно, свайп между
|
||||
этажами, автокарусель, свои размеры на каждом экране.
|
||||
- 🤖 **Роботы-пылесосы вживую** — маркер-база стоит на месте, а круглая
|
||||
шайба ездит по плану в реальном времени, «выливая» путь из-под себя;
|
||||
текущая и прошлая уборки хранятся на сервере. Калибровка — в один клик
|
||||
(по именам комнат) или перетаскиванием призрака карты. Диагностика и явный
|
||||
выбор источника поддерживают registry-less камеры карт и не подменяют молча
|
||||
сломавшуюся привязку. Работают Xiaomi Cloud Map Extractor, dreame-vacuum
|
||||
(Tasshack) и Valetudo.
|
||||
- 🔔 Новые устройства сами появляются на плане с красной точкой; раскладка
|
||||
хранится **на сервере HA** — один план для всех экранов, живая синхронизация.
|
||||
|
||||
## Что умеет House Plan
|
||||
---
|
||||
|
||||
- **Живые состояния и безопасные действия.** Свет и другие безопасные устройства
|
||||
переключаются с плана; замок нельзя открыть случайным нажатием.
|
||||
- **Три встроенных редактора.** «План» создаёт комнаты, стены и проёмы;
|
||||
«Устройства» размещает и настраивает маркеры; «Подложка» добавляет линии,
|
||||
подписи и мебель.
|
||||
- **Комнаты, связанные с зонами HA.** Новые устройства появляются автоматически,
|
||||
а карточки комнат показывают температуру, влажность, свет и средний LQI.
|
||||
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
|
||||
солнечные лучи из окон.
|
||||
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
|
||||
робот показывает позицию, базу и пройденный путь.
|
||||
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
|
||||
локальный масштаб и отдельный стартовый этаж для каждой карточки.
|
||||
- **Киоск для настенного экрана.** Только план, полноэкранная навигация и размеры
|
||||
значков, сохранённые отдельно для этого устройства.
|
||||
|
||||

|
||||
## Что это и зачем
|
||||
|
||||
<!-- docs-section: first-run -->
|
||||
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
|
||||
|
||||
## Первая рабочая комната
|
||||
Это удобно, когда:
|
||||
|
||||
1. Установите интеграцию и добавьте карточку на дашборд.
|
||||
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
|
||||
вариант без изображения, чтобы нарисовать план вручную.
|
||||
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
|
||||
контур нажатием на первую точку.
|
||||
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
|
||||
устройств выберите «Без зоны».
|
||||
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
|
||||
перетащите маркеры в нужные места.
|
||||
6. При необходимости оформите подложку линиями, текстом и мебелью.
|
||||
7. Вернитесь в «Просмотр» — теперь план показывает живые состояния и принимает
|
||||
безопасные действия.
|
||||
- устройств много, и списками пользоваться неудобно;
|
||||
- нужно быстро понять состояние дома «одним взглядом»;
|
||||
- хочется отдать доступ близким — по картинке разберётся любой;
|
||||
- вы хотите красивый обзорный экран для настенного планшета.
|
||||
|
||||

|
||||
Интеграция состоит из двух частей, которые ставятся вместе:
|
||||
|
||||

|
||||
- **карточка Lovelace** `houseplan-card` — сам интерактивный план;
|
||||
- **серверный компонент** — хранит разметку комнат и позиции иконок в Home Assistant, поэтому план одинаков во всех браузерах и на всех устройствах.
|
||||
|
||||

|
||||
---
|
||||
|
||||

|
||||
## Чем отличается от аналогов
|
||||
|
||||

|
||||
Обычно план дома в Home Assistant делают через `picture-elements`, `ha-floorplan`
|
||||
или новые GUI-карточки, где стены и мебель рисуют прямо на дашборде. Там либо
|
||||
YAML/SVG, либо конфиг живёт в YAML карточки. House Plan — это **общий живой
|
||||
план** на серверной интеграции Home Assistant:
|
||||
|
||||
Пошаговые сценарии, все инструменты и особые случаи описаны в
|
||||
[полном руководстве](docs/USER-GUIDE.ru.md). Возможности подложки отдельно
|
||||
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
|
||||
[руководстве по пылесосам](docs/VACUUM.md).
|
||||
| | House Plan | picture-elements / ha-floorplan | GUI-рисовалки (напр. easy-floorplan) |
|
||||
|---|---|---|---|
|
||||
| **Настройка** | Полностью через интерфейс, мышкой | Ручной YAML / Inkscape SVG | Рисование стен и мебели в карточке |
|
||||
| **Добавление устройств** | Автоматически по **зоне** HA | Каждую сущность вписываете руками | Ставите сущности руками на чертёж |
|
||||
| **Координаты иконок** | Перетаскиваете мышью | Считаете пиксели в YAML | Drag на холсте |
|
||||
| **Разметка комнат** | Встроенный редактор контуров, привязка к зонам | Сторонний SVG-редактор | Сами рисуете стены (мебельный CAD) |
|
||||
| **Хранение** | На сервере HA (`.storage`, общее, multi-client) | В YAML дашборда | В карточке / YAML дашборда |
|
||||
| **Оверлеи** | Glow, климат, LQI, солнце, пылесосы, киоск | Что пропишете в SVG/CSS | Зависит от карточки |
|
||||
| **Масштаб** | Плавный векторный зум | Обычно фиксированная картинка | SVG / виртуальный холст |
|
||||
|
||||
<!-- docs-section: installation -->
|
||||
**Одной фразой:** House Plan — это общая, area-aware живая карта дома, а не
|
||||
универсальная CAD-система. Редактор подложки покрывает практический декор,
|
||||
надписи и мебель; для свободного архитектурного черчения лучше отдельный
|
||||
draw-инструмент. При наличии плана и зон HA House Plan держит один живой layout
|
||||
на всех планшетах.
|
||||
|
||||
Ключевые преимущества коротко:
|
||||
|
||||
- **Никакого кода.** Всё — пространства, комнаты, устройства — настраивается кликами.
|
||||
- **Автоматическое добавление устройств.** Обвели комнату и привязали её к зоне Home Assistant — устройства этой зоны сами появляются на плане.
|
||||
- **Ручное добавление своих.** Любое устройство, группу или даже «виртуальную» точку можно поставить на план вручную, задать имя, иконку, модель, ссылку и приложить PDF-инструкцию.
|
||||
- **Живые состояния.** Температура, уровень сигнала Zigbee, вкл/выкл, открыто/закрыто — всё обновляется в реальном времени.
|
||||
Цвета значков подчиняются одному принципу — **жёлтый значит «устройство прямо сейчас выполняет свою основную работу»**:
|
||||
лампа светит, розетка подаёт, вентилятор крутится, пылесос убирает, термоголовка
|
||||
реально греет (а не просто включена). Для climate-сущностей переданное действие приоритетно;
|
||||
если интеграция сообщает только включённый HVAC-режим, он служит лучшим доступным приближением.
|
||||
Оранжевый = открыто / не заперто. Пульсирующее красное
|
||||
кольцо = авария (протечка, дым, газ). Цвет RGB-лампы живёт в её пятне света (режим glow),
|
||||
где само пятно — индикатор включения, а подложка значка остаётся стандартной.
|
||||
Полупрозрачный значок = недоступно. Тёмный = покой.
|
||||
- **Единый визуальный редактор подложки.** Линии, фигуры, надписи и мебель используют общее выделение, физические стили и Undo/Redo. Картинка плана не прибита к холсту: отдельный инструмент двигает, масштабирует и поворачивает её, а числовой диалог задаёт точный размер и угол.
|
||||
- **Чёткий зум.** Приближение не «мылит» картинку: план, подписи и иконки остаются векторно-чёткими на любом масштабе.
|
||||
|
||||
---
|
||||
|
||||
## Настенный планшет / ТВ (киоск-режим)
|
||||
|
||||
Отдельный дашборд с view типа «панель», у карточки — `kiosk: true` (или
|
||||
галочка «Режим настенного устройства» в редакторе карточки):
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
kiosk: true
|
||||
cycle: 0 # автосмена пространств каждые N секунд, 0 = выкл (удобно для ТВ)
|
||||
```
|
||||
|
||||
Без шапки и редакторов — только живой план. Свайп листает этажи (при 1:1),
|
||||
пинч — зум, двойной тап — сброс. Долгое нажатие (3 с) по пустому месту —
|
||||
настройка размеров значков и текста для ЭТОГО экрана (хранится на
|
||||
устройстве). Шапку самого Home Assistant скрывают настройки companion-app
|
||||
или плагин [kiosk-mode](https://github.com/NemesisRE/kiosk-mode).
|
||||
|
||||
## Установка
|
||||
|
||||
### Через HACS
|
||||
В один клик, если у вас уже есть HACS:
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=Matysh&repository=houseplan-card&category=integration)
|
||||
[](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, который публикует интеграция:
|
||||
### Через HACS (рекомендуется)
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
|
||||
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
|
||||
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
|
||||
|
||||
Не используйте путь к файлу внутри `custom_components`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
Карточка подключается автоматически — добавлять ресурс Lovelace вручную не нужно.
|
||||
|
||||
> **Карточка не грузится (`Custom element doesn't exist: houseplan-card`) или вы ведёте ресурсы
|
||||
> Lovelace в YAML?** Добавьте ресурс вручную, указав URL, который *раздаёт сама интеграция*:
|
||||
>
|
||||
> ```yaml
|
||||
> resources:
|
||||
> - url: /houseplan_files/houseplan-card.js
|
||||
> type: module
|
||||
> ```
|
||||
>
|
||||
> **Не** используйте `/custom_components/houseplan/frontend/houseplan-card.js` — это путь к файлу
|
||||
> на диске, который Home Assistant не отдаёт по HTTP (получите ошибку MIME `text/plain`, и элемент
|
||||
> не зарегистрируется). Правильный URL, который раздаёт интеграция, — `/houseplan_files/houseplan-card.js`.
|
||||
> Обе карточки (`houseplan-card` и `houseplan-space-card`) лежат в этом одном файле — отдельный ресурс
|
||||
> не нужен.
|
||||
|
||||
### Вручную
|
||||
|
||||
Скопируйте `custom_components/houseplan` в `config/custom_components`,
|
||||
перезапустите Home Assistant и добавьте интеграцию House Plan.
|
||||
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
|
||||
### Добавление карточки
|
||||
### Добавление экрана с планом
|
||||
|
||||
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
|
||||
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: План дома
|
||||
```
|
||||
|
||||
Для нескольких экранов можно задать разные стартовые пространства:
|
||||
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
default_floor: ground
|
||||
```
|
||||
---
|
||||
|
||||
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
|
||||
масштаб и выбранное пространство локальны для экрана. Одновременное
|
||||
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
|
||||
лучше не менять параллельно в двух браузерах.
|
||||
## Как пользоваться
|
||||
|
||||
## Где искать подробности
|
||||
### Шаг 1. Добавьте пространство (этаж)
|
||||
|
||||
- [Полное руководство пользователя](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)
|
||||
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
|
||||
|
||||
<!-- docs-section: support -->
|
||||
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
|
||||
пространство для каждого: названия подставятся сами, план попросит по очереди,
|
||||
любой этаж можно пропустить.
|
||||
|
||||
## Помощь и обратная связь
|
||||

|
||||
|
||||
- Вопросы и примеры планов: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
|
||||
- Баги и предложения: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
- Перед отчётом обновите House Plan, перезапустите HA и выполните жёсткое
|
||||
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
|
||||
воспроизведения; приватные entity ID можно заменить вымышленными.
|
||||
В диалоге задайте **название** (например, «1 этаж») и выберите подложку: **загрузите** картинку плана (SVG, PNG, JPG, WebP), **возьмите уже загруженную** на сервер ранее или отметьте **«без подложки, нарисую комнаты сам»**. Холст бесконечный; картинка по умолчанию сохраняет пропорции, а подвинуть, изменить размер или повернуть её можно в любой момент в редакторе подложки.
|
||||
|
||||
Скриншоты в документации получены воспроизводимой командой
|
||||
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
|
||||
fingerprint исходников и хеш каждого изображения находятся в
|
||||
[индексе снимков](docs/images/screenshots.json).
|
||||

|
||||
|
||||
Лицензия: [MIT](LICENSE).
|
||||
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего SVG — он остаётся чётким при увеличении.
|
||||
|
||||
Позже можно добавить сколько угодно пространств (этажи, двор, гараж) кнопкой **+** рядом со вкладками.
|
||||
|
||||
### Шаг 2. Обведите комнаты
|
||||
|
||||
После добавления первого пространства карточка сама переходит в режим разметки. Кликайте по точкам сетки, соединяя их линиями, и замкните контур комнаты кликом по первой точке.
|
||||
|
||||
Как только контур замкнётся, появится окно сохранения комнаты. Здесь нужно **привязать комнату к зоне Home Assistant** — именно это включает автоматику. Для служебных помещений без устройств (холл, сауна) есть кнопка **«Без зоны»**.
|
||||
|
||||

|
||||
|
||||
Во время рисования у курсора показывается линейка с реальной длиной текущего отрезка (метры или футы+дюймы на имперской системе HA). Масштаб задаётся для каждого пространства — поле **«Масштаб (размер ячейки сетки)»** в диалоге пространства: сколько сантиметров в одной ячейке (по умолчанию 5 см).
|
||||
|
||||
Комнаты не могут пересекаться: клик строго внутри существующей комнаты или контур, охватывающий её, отклоняются. Ещё два инструмента помогают перекроить план позже:
|
||||
|
||||
- **Объединить** — кликните комнату, затем соседнюю с общей стеной; они сольются в одну. Диалог выбирает, чьё имя и зона останутся.
|
||||
- **Разделить** — кликните комнату, затем две точки на её стенах; хорда разрежет её надвое. Бо́льшая часть остаётся прежней комнатой (имя, зона, устройства), меньшая просит новое имя и зону.
|
||||
|
||||
|
||||
### Двери, окна, ворота и замки
|
||||
|
||||
В режиме разметки инструмент **«Проём»** ставит двери, окна и ворота: кликните рядом со стеной — проём
|
||||
примагнитится к ней. Выберите тип, **длину в реальных сантиметрах** (по умолчанию дверь 90 см,
|
||||
окно 120 см, ворота 300 см), датчик открытия и — для двери или ворот — **замок**.
|
||||
|
||||
С привязанным датчиком план оживает: створка двери поворачивается на петле, и дуга распахивания
|
||||
дорисовывается по мере открытия настоящей двери; окно раскрывает две створки. Пока открыто,
|
||||
подвижные части подсвечены акцентным цветом. Ворота не занимают полплана даже при ширине 3–4 м: две половинные створки показаны открытыми наружу всего на 10°, без большой дуги. Датчик, замок и пропуск света работают как у двери. У двери или ворот с замком рядом отображается замочек —
|
||||
зелёный, когда заперто, оранжевый, когда нет. Ради безопасности замок с плана **нельзя**
|
||||
переключить — клик по проёму показывает карточку с обоими статусами.
|
||||
|
||||
Проёмы легко поправить позже: при наведении проём подсвечивается, его можно **перетащить вдоль
|
||||
стен** (в том числе за угол), а **двойной клик открывает свойства**.
|
||||
|
||||
### Шаг 3. Устройства появляются сами
|
||||
|
||||
Как только вы сохранили комнату с привязкой к зоне, **устройства этой зоны автоматически расставляются внутри контура**. Берутся те же устройства, что показаны на странице **Настройки → Устройства → (фильтр по нужной комнате)** — только осмысленные, без служебных записей, мостов и дубликатов.
|
||||
|
||||
По умолчанию на план попадают только осмысленные устройства: нефизические (служебные записи, мосты, сцены, лампы, свёрнутые в световую группу) могут быть скрыты автоматически. Управление находится в левом нижнем углу диалога устройства: **«Скрыть»** убирает маркер после сохранения, а у уже скрытого маркера там же появляется **«Показать»**. Чтобы найти их, откройте редактор устройств и нажмите **«Скрытые и деактивированные»**: пользовательски скрытые устройства отображаются синими призраками. Деактивированное в HA устройство показывается серым служебным призраком и полностью исключается из данных и действий плана до повторной активации.
|
||||
|
||||
Дальше можно просто пользоваться планом: клик по иконке открывает карточку устройства с моделью, ссылкой и кнопкой перехода в Home Assistant.
|
||||
|
||||

|
||||
|
||||
### Шаг 4. Масштаб
|
||||
|
||||
Колесо мыши или кнопки **- / ⊹ / +** приближают и отдаляют план; на сенсорном экране работает «щипок» двумя пальцами. При отдалении виден весь план целиком, при приближении — детали, и всё остаётся чётким. Масштаб запоминается отдельно для каждого пространства.
|
||||
|
||||

|
||||
|
||||
### Шаг 5. Расставьте значки по местам
|
||||
|
||||
Расставлять значки нужно на вкладке **«Устройства»**: там они перетаскиваются мышью, а клик открывает редактор. В режиме **«Просмотр»** ничего сдвинуть нельзя — панорамирование карты больше не сдвигает датчики (главная просьба пользователей). Позиции сохраняются на сервере и одинаковы во всех браузерах и устройствах. Кнопка **↺** возвращает автоматическую раскладку.
|
||||
|
||||

|
||||
|
||||
### Управление с плана (tap actions)
|
||||
|
||||
По умолчанию тап по значку открывает инфо-карточку. В настройках карточки можно
|
||||
переключить **«Тап по устройству»** на *Переключить* — тогда тап включает/выключает
|
||||
свет, розетки, вентиляторы и увлажнители прямо с плана (режим настенного планшета).
|
||||
Для безопасности общий toggle не действует на замки, сигнализации, шторы/ворота и
|
||||
клапаны; для конкретного устройства toggle можно включить осознанно в его диалоге
|
||||
(кроме замков и сигнализаций — они с плана не переключаются никогда). **Долгое
|
||||
нажатие** всегда открывает инфо-карточку.
|
||||
|
||||
### Правила иконок
|
||||
|
||||
Какая MDI-иконка достанется устройству, решают **правила иконок** — редактируются
|
||||
прямо в карточке (кнопка ⬡ в шапке): упорядоченный список «шаблон имени → иконка»
|
||||
с живым тест-полем, двуязычные умолчания (EN/RU) и сброс одной кнопкой. Если ни одно
|
||||
правило не подошло — решает *device class* сущности (термометр для датчиков
|
||||
температуры и т.п.).
|
||||
|
||||
### Шаг 6. Добавление своих устройств вручную
|
||||
|
||||
Можно поставить и **отдельную сущность** (не только устройство целиком): начните печатать в поиске привязки — рядом с устройствами появятся отдельные сущности. Удобно, когда одно устройство отдаёт несколько значений (например, температуру и влажность), а вы хотите каждое своей иконкой.
|
||||
|
||||
|
||||
Не всё нужно оставлять на автоматику. Кнопкой **+** в шапке можно поставить на план любое устройство, группу или **виртуальную точку** (например, «Вентиль на вводе», которого нет как устройства). Задайте имя, иконку, модель, ссылку, описание и при желании приложите **PDF-инструкцию**.
|
||||
|
||||
В этом же диалоге настраивается вид устройства на плане. **Отображение** переключает значок,
|
||||
анимированную **пульсацию присутствия** (расходящиеся кольца, пока сущность активна, и тусклая
|
||||
точка в покое — идеально для датчиков движения) или то и другое сразу, с цветом и размером колец
|
||||
на устройство. **Размер значка** (×0,5–3) и **поворот** — тоже индивидуальные: вентиль на стене
|
||||
может быть маленьким и повёрнутым так, как он установлен.
|
||||
|
||||

|
||||
|
||||
### Свои стили через card-mod (для продвинутых, без поддержки)
|
||||
|
||||
Карточка приезжает готовой, и поля для CSS у неё нет — но если у вас уже стоит [card-mod](https://github.com/thomasloven/lovelace-card-mod), у каждого объекта плана теперь есть стабильный «крючок», за который можно зацепиться: `data-hp="device"` (плюс `data-entity`, `data-area`), `data-hp="room"`, `data-hp="opening"`, `data-hp="decor"`, `data-hp="room-label"`, `data-hp="space-tab"`. Мы обещаем их не переименовывать; сам card-mod мы не поставляем, не поддерживаем и за то, что ваш CSS сделает с карточкой, не отвечаем. Полная таблица, примеры и ограничения — в **[docs/STYLING-HOOKS.md](docs/STYLING-HOOKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Удаление
|
||||
|
||||
1. Уберите карточку (или вкладку с планом) из дашборда.
|
||||
2. **Настройки → Устройства и службы → House Plan → Удалить** запись интеграции.
|
||||
3. Удалите интеграцию из **HACS** (или папку `custom_components/houseplan` при ручной установке) и перезапустите Home Assistant.
|
||||
4. При желании удалите сохранённые данные плана: файлы `config/houseplan/` (подложки и вложения) и записи `houseplan.config` / `houseplan.layout` в каталоге `config/.storage`.
|
||||
|
||||
---
|
||||
|
||||
## Помощь и обмен опытом
|
||||
|
||||
- 💬 **[Чат в Telegram — @ha_houseplan](https://t.me/ha_houseplan)** — вопросы,
|
||||
помощь с настройкой, идеи и скриншоты ваших планов. Самый быстрый способ
|
||||
связаться с автором и другими пользователями.
|
||||
- 🐞 [Issues на GitHub](https://github.com/Matysh/houseplan-card/issues) — баги
|
||||
и запросы фич (пожалуйста, указывайте версию House Plan).
|
||||
- 💡 [Discussions](https://github.com/Matysh/houseplan-card/discussions) — для
|
||||
развёрнутых обсуждений.
|
||||
- 📜 [История изменений](docs/CHANGELOG.ru.md) — что менялось в каждой версии.
|
||||
|
||||
Версия видна в консоли браузера при загрузке (`HOUSEPLAN-CARD vX.Y.Z`) и в
|
||||
**Настройки → Устройства и службы → House Plan** — с ней разбираться сильно
|
||||
быстрее.
|
||||
|
||||
---
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
|
||||
**Нужно ли что-то писать в YAML?** Нет. Единственная строчка — это добавление карточки на дашборд; всё остальное делается мышкой.
|
||||
|
||||
**Мои устройства не появились на плане.** Устройство появляется, только если его зона в Home Assistant привязана к нарисованной комнате. Проверьте, что у устройства задана комната (Настройки → Устройства), а комната обведена и привязана к этой зоне. Откройте **«Скрытые и деактивированные»**: синий призрак можно показать в его диалоге, серый сначала нужно активировать в Home Assistant.
|
||||
|
||||
**Можно ли скрыть лишнее устройство или переименовать его?** Да — кликните по устройству на плане и в его карточке нажмите «Редактировать»: там можно сменить имя, иконку, модель или скрыть значок.
|
||||
|
||||
**Данные хранятся в облаке?** Нет. Всё хранится локально в вашем Home Assistant.
|
||||
|
||||
---
|
||||
|
||||
<p align="center"><sub>Скриншоты сделаны на реальной конфигурации Home Assistant.</sub></p>
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
STORAGE_MINOR_VERSION = 1
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
@@ -46,12 +46,12 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.67.0-rc.3"
|
||||
VERSION = "1.63.0"
|
||||
|
||||
# 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 = 7
|
||||
PLAN_MODEL_VERSION = 6
|
||||
EXPORT_VERSION = 1
|
||||
MAX_EXPORT_BYTES = 8 * 1024 * 1024
|
||||
IMPORT_PREVIEW_TTL_S = 10 * 60
|
||||
@@ -65,5 +65,5 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {"bg_mode": "daynight"},
|
||||
"settings": {},
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Lossless, allow-listed canonicalisation of persisted geometry.
|
||||
|
||||
The frontend mirrors this module in src/coordinate-canonicalization.ts.
|
||||
Keep the precision, lattice formula and field allow-list in lockstep; a shared
|
||||
fixture is exercised by both runtimes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
|
||||
COORDINATE_DECIMALS = 9
|
||||
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
|
||||
LATTICE_GRID_N = 240
|
||||
LATTICE_NOISE_STEPS = 1e-4
|
||||
|
||||
|
||||
def canonicalize_number(value: Any) -> Any:
|
||||
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
sign = -1.0 if math.copysign(1.0, number) < 0 else 1.0
|
||||
result = sign * (
|
||||
math.floor(abs(number) * COORDINATE_FACTOR + 0.5)
|
||||
/ COORDINATE_FACTOR
|
||||
)
|
||||
if result == 0:
|
||||
return 0.0
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_lattice_coordinate(value: Any) -> Any:
|
||||
"""Collapse near-node noise while preserving authored off-grid values."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return value
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return value
|
||||
scaled = number * LATTICE_GRID_N
|
||||
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
|
||||
nearest = math.floor(scaled + 0.5)
|
||||
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
|
||||
result = nearest / LATTICE_GRID_N
|
||||
return 0.0 if result == 0 else result
|
||||
return canonicalize_number(number)
|
||||
|
||||
|
||||
def _record(value: Any) -> dict[str, Any] | None:
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _records(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_number(record[name])
|
||||
|
||||
|
||||
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
|
||||
for name in names:
|
||||
if name in record:
|
||||
record[name] = canonicalize_lattice_coordinate(record[name])
|
||||
|
||||
|
||||
def _lattice_point(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for index in range(min(2, len(value))):
|
||||
value[index] = canonicalize_lattice_coordinate(value[index])
|
||||
|
||||
|
||||
def _lattice_points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
_lattice_point(point)
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
record = _record(result)
|
||||
if record is not None:
|
||||
_lattice_fields(record, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_layout_geometry(layout: Any) -> Any:
|
||||
"""Canonicalise lattice x/y in every layout record."""
|
||||
result = copy.deepcopy(layout)
|
||||
record = _record(result)
|
||||
if record is None:
|
||||
return result
|
||||
for position in record.values():
|
||||
item = _record(position)
|
||||
if item is not None:
|
||||
_lattice_fields(item, ("x", "y"))
|
||||
return result
|
||||
|
||||
|
||||
def canonicalize_config_geometry(config: Any) -> Any:
|
||||
"""Canonicalise only the named persisted geometry fields."""
|
||||
result = copy.deepcopy(config)
|
||||
root = _record(result)
|
||||
if root is None:
|
||||
return result
|
||||
|
||||
for space in _records(root.get("spaces")):
|
||||
_scalar_fields(
|
||||
space,
|
||||
(
|
||||
"plan_x",
|
||||
"plan_y",
|
||||
"plan_scale",
|
||||
"plan_scale_x",
|
||||
"plan_scale_y",
|
||||
"plan_angle",
|
||||
),
|
||||
)
|
||||
|
||||
for room in _records(space.get("rooms")):
|
||||
_lattice_fields(room, ("x", "y", "w", "h"))
|
||||
_lattice_points(room.get("poly"))
|
||||
|
||||
for wall in _records(space.get("walls")):
|
||||
_lattice_point(wall.get("a"))
|
||||
_lattice_point(wall.get("b"))
|
||||
|
||||
for opening in _records(space.get("openings")):
|
||||
_lattice_fields(opening, ("x", "y"))
|
||||
_scalar_fields(opening, ("angle", "length"))
|
||||
host = _record(opening.get("host"))
|
||||
if host is not None:
|
||||
_scalar_fields(host, ("t",))
|
||||
|
||||
for decor in _records(space.get("decor")):
|
||||
kind = decor.get("kind")
|
||||
if kind == "line":
|
||||
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
|
||||
elif kind in ("rect", "ellipse", "furniture"):
|
||||
_lattice_fields(decor, ("x", "y", "w", "h"))
|
||||
_scalar_fields(decor, ("angle",))
|
||||
elif kind == "text":
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
_lattice_points(draft.get("points"))
|
||||
|
||||
for partition in _records(space.get("partitions")):
|
||||
_lattice_point(partition.get("a"))
|
||||
_lattice_point(partition.get("b"))
|
||||
|
||||
for column in _records(space.get("wall_columns")):
|
||||
_lattice_point(column.get("center"))
|
||||
if column.get("shape") == "square":
|
||||
_scalar_fields(column, ("angle",))
|
||||
|
||||
for span in _records(space.get("open_spans")):
|
||||
_lattice_point(span.get("a"))
|
||||
_lattice_point(span.get("b"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_scalar_fields(marker, ("angle",))
|
||||
|
||||
return result
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.67.0-rc.3"
|
||||
"version": "1.63.0"
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Read-only projections of the stored plan (#256).
|
||||
|
||||
A full configuration is 70 KB on a real installation: three spaces and 139
|
||||
markers. Every diagnostic question — "which space does this marker point at",
|
||||
"how many markers are hidden", "what is on the first floor" — used to require
|
||||
downloading all of it, because `houseplan/config/get` had no way to ask for
|
||||
less.
|
||||
|
||||
The functions here are pure and deliberately unaware of Home Assistant: the
|
||||
websocket handlers stay thin, and the interesting part is covered by tests that
|
||||
run without the HA harness.
|
||||
|
||||
Two rules shape everything below.
|
||||
|
||||
* Absent parameter means *no projection*. The response must stay byte-for-byte
|
||||
what it was before this module existed; no existing client may notice it.
|
||||
* A projection never invents or repairs data. An unknown field name simply adds
|
||||
nothing, and an unknown space yields an empty list rather than an error — the
|
||||
caller distinguishes "no such thing" from "broken" by content, not by an
|
||||
error code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def _names(value: Any) -> list[str] | None:
|
||||
"""Normalise a field list; anything unusable means "no projection"."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
names = [str(item) for item in value if isinstance(item, str) and item]
|
||||
return names or None
|
||||
|
||||
|
||||
def project_markers(markers: Any, marker_fields: Iterable[str] | None) -> Any:
|
||||
"""Keep only the requested marker fields, plus `id`.
|
||||
|
||||
`id` is added unconditionally: a marker without it cannot be matched to
|
||||
anything, so a projection that drops it produces an answer nobody can use.
|
||||
"""
|
||||
names = _names(marker_fields)
|
||||
if names is None or not isinstance(markers, list):
|
||||
return markers
|
||||
keep = {"id", *names}
|
||||
out = []
|
||||
for marker in markers:
|
||||
if not isinstance(marker, dict):
|
||||
out.append(marker)
|
||||
continue
|
||||
out.append({key: value for key, value in marker.items() if key in keep})
|
||||
return out
|
||||
|
||||
|
||||
def project_config(
|
||||
config: Any,
|
||||
*,
|
||||
space_id: str | None = None,
|
||||
fields: Iterable[str] | None = None,
|
||||
marker_fields: Iterable[str] | None = None,
|
||||
) -> Any:
|
||||
"""Return a narrowed copy of the configuration.
|
||||
|
||||
The original object is never mutated: the caller hands us the store's
|
||||
document, and a projection that edited it in place would corrupt the very
|
||||
thing it was asked to read.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
field_names = _names(fields)
|
||||
if space_id is None and field_names is None and _names(marker_fields) is None:
|
||||
return config
|
||||
|
||||
projected: dict[str, Any] = dict(config)
|
||||
if space_id is not None:
|
||||
spaces = projected.get("spaces")
|
||||
projected["spaces"] = [
|
||||
space for space in spaces
|
||||
if isinstance(space, dict) and str(space.get("id", "")) == str(space_id)
|
||||
] if isinstance(spaces, list) else spaces
|
||||
if marker_fields is not None:
|
||||
projected["markers"] = project_markers(projected.get("markers"), marker_fields)
|
||||
if field_names is not None:
|
||||
projected = {key: value for key, value in projected.items() if key in set(field_names)}
|
||||
return projected
|
||||
|
||||
|
||||
def project_layout(layout: Any, *, space_id: str | None = None) -> Any:
|
||||
"""Keep only the positions of one space."""
|
||||
if space_id is None or not isinstance(layout, dict):
|
||||
return layout
|
||||
wanted = str(space_id)
|
||||
return {
|
||||
key: position for key, position in layout.items()
|
||||
if isinstance(position, dict) and str(position.get("s", "")) == wanted
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
@@ -20,39 +19,9 @@ from .const import (
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
|
||||
|
||||
_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):
|
||||
@@ -70,9 +39,10 @@ class HouseplanStore(Store):
|
||||
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
|
||||
data = old_data
|
||||
# if old_major_version == 1 and old_minor_version < 2:
|
||||
# ...migrate...
|
||||
return data
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -161,7 +131,7 @@ def layout_store_payload(
|
||||
}
|
||||
if metadata:
|
||||
out.update(metadata)
|
||||
out["layout"] = canonicalize_layout_geometry(layout)
|
||||
out["layout"] = layout
|
||||
out["rev"] = rev
|
||||
return out
|
||||
|
||||
@@ -209,8 +179,7 @@ async def async_save_config_state(
|
||||
except (TypeError, ValueError):
|
||||
previous_rev = 0
|
||||
|
||||
canonical_config = canonicalize_config_geometry(config)
|
||||
payload = {"config": canonical_config, "rev": rev}
|
||||
payload = {"config": config, "rev": rev}
|
||||
await runtime.config_store.async_save(payload)
|
||||
|
||||
# The config is already durable at this point. Reconciliation remains a
|
||||
@@ -221,7 +190,7 @@ async def async_save_config_state(
|
||||
try:
|
||||
await async_reconcile_virtual_lights(
|
||||
runtime.virtual_light_store,
|
||||
canonical_config,
|
||||
config,
|
||||
rev,
|
||||
previous_config_rev=previous_rev,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ want to see where the cleanup has already been).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -25,33 +24,11 @@ import logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRAIL_CAP = 2000 # raw points per run before decimation
|
||||
TRAIL_RESUME_GRACE_S = 30 * 60 # same-map stop/pause belongs to one cleanup
|
||||
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 can_resume_trail_run(run: Any, map_id: str, now: float) -> bool:
|
||||
"""Whether an ended current run may be reopened for this point.
|
||||
|
||||
Store timestamps are untrusted persisted data. Only finite JSON-number
|
||||
timestamps and a non-negative inclusive grace interval are accepted;
|
||||
malformed values and wall-clock rollback fail closed into a new run.
|
||||
"""
|
||||
if not isinstance(run, dict) or run.get("map_id") != map_id:
|
||||
return False
|
||||
ended = run.get("ended")
|
||||
if (
|
||||
isinstance(ended, bool)
|
||||
or not isinstance(ended, (int, float))
|
||||
or isinstance(now, bool)
|
||||
or not isinstance(now, (int, float))
|
||||
):
|
||||
return False
|
||||
elapsed = now - ended
|
||||
return math.isfinite(elapsed) and 0 <= elapsed <= TRAIL_RESUME_GRACE_S
|
||||
|
||||
|
||||
def resolve_map_id(src_attrs: Any, vac_attrs: Any) -> str:
|
||||
"""Map-id normalisation contract, shared with the frontend.
|
||||
|
||||
@@ -87,10 +64,7 @@ class TrailBook:
|
||||
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")
|
||||
resumed = bool(cur and can_resume_trail_run(cur, map_id, now))
|
||||
if resumed:
|
||||
cur["ended"] = None
|
||||
if not cur or cur.get("ended") is not None or cur.get("map_id") != map_id:
|
||||
if not cur or cur.get("ended") 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:
|
||||
@@ -99,9 +73,7 @@ class TrailBook:
|
||||
rec["current"] = cur
|
||||
pts: list[list[float]] = cur["points"]
|
||||
if pts and pts[-1][0] == x and pts[-1][1] == y:
|
||||
# Clearing ended is observable state even if the source repeats
|
||||
# the dock point: it must still reach Store and live cards.
|
||||
return resumed
|
||||
return False
|
||||
pts.append([x, y])
|
||||
if len(pts) > TRAIL_CAP:
|
||||
# decimate by two but never lose the freshest point
|
||||
@@ -113,7 +85,7 @@ class TrailBook:
|
||||
|
||||
def end_run(self, marker: str, now: float) -> bool:
|
||||
cur = (self.data.get(marker) or {}).get("current")
|
||||
if cur and cur.get("ended") is None:
|
||||
if cur and not cur.get("ended"):
|
||||
cur["ended"] = now
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -5,17 +5,10 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed).
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import math
|
||||
import re
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
PLAN_EXTENSIONS = {"svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
|
||||
MAX_PLAN_BYTES = 8 * 1024 * 1024
|
||||
@@ -39,595 +32,6 @@ class MarkerControlError(ValueError):
|
||||
self.code = code
|
||||
|
||||
|
||||
class OpeningPassageError(ValueError):
|
||||
"""Semantic open-passage error with a stable public code and payload."""
|
||||
|
||||
code = "invalid_passage_fields"
|
||||
|
||||
def __init__(self, space_id: str, opening_id: str, fields: list[str]) -> None:
|
||||
self.space_id = space_id
|
||||
self.opening_id = opening_id
|
||||
self.fields = tuple(sorted(fields))
|
||||
super().__init__(
|
||||
f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}"
|
||||
)
|
||||
|
||||
|
||||
class PartitionOpeningHostError(ValueError):
|
||||
"""A write tried to strip explicit host identity from a surviving opening."""
|
||||
|
||||
code = "invalid_partition_opening_host"
|
||||
|
||||
|
||||
class PartitionOpeningJambMarginError(ValueError):
|
||||
"""A direct geometry write leaves no physical jamb at a wall endpoint."""
|
||||
|
||||
code = "invalid_partition_opening_jamb_margin"
|
||||
|
||||
def __init__(
|
||||
self, space_id: str, opening_id: str, margin: float, margin_cm: float
|
||||
) -> None:
|
||||
self.space_id = space_id
|
||||
self.opening_id = opening_id
|
||||
self.margin = margin
|
||||
self.margin_cm = margin_cm
|
||||
super().__init__(
|
||||
f"space={space_id}; opening={opening_id}; "
|
||||
f"margin={margin:.12g}; margin_cm={margin_cm:.12g}"
|
||||
)
|
||||
|
||||
|
||||
# One normalized canvas width contains this many physical grid cells. Keep in
|
||||
# sync with GRID_STEP_N/NORM_W in the frontend; it is a geometry scale, not a
|
||||
# user setting.
|
||||
NORMALIZED_CANVAS_CELLS = 240.0
|
||||
_DEFAULT_ROOM_WALL_CM = 15.0
|
||||
_OPTIMIZE_REHOST_EPSILON = 1e-8
|
||||
|
||||
|
||||
def _room_polygon(room: dict) -> list[list[float]]:
|
||||
poly = room.get("poly")
|
||||
if isinstance(poly, list) and len(poly) >= 3:
|
||||
return poly
|
||||
if all(key in room for key in ("x", "y", "w", "h")):
|
||||
x, y = float(room["x"]), float(room["y"])
|
||||
w, h = float(room["w"]), float(room["h"])
|
||||
return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
|
||||
return []
|
||||
|
||||
|
||||
def _segment_metrics(a: list, b: list) -> tuple[float, float, float]:
|
||||
dx, dy = float(b[0]) - float(a[0]), float(b[1]) - float(a[1])
|
||||
return dx, dy, math.hypot(dx, dy)
|
||||
|
||||
|
||||
def _line_distance(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return abs((float(point[0]) - float(a[0])) * dy
|
||||
- (float(point[1]) - float(a[1])) * dx) / length
|
||||
|
||||
|
||||
def _projection(point: list, a: list, b: list) -> float:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return math.inf
|
||||
return ((float(point[0]) - float(a[0])) * dx
|
||||
+ (float(point[1]) - float(a[1])) * dy) / length
|
||||
|
||||
|
||||
def _segment_covers(a: list, b: list, target_a: list, target_b: list) -> bool:
|
||||
"""Return whether one exact collinear segment covers the target."""
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
if (_line_distance(target_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(target_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
for point in (target_a, target_b):
|
||||
along = _projection(point, a, b)
|
||||
if along < -_OPTIMIZE_REHOST_EPSILON or along > length + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _segments_overlap_on_axis(a: list, b: list, other_a: list, other_b: list) -> bool:
|
||||
if (_line_distance(other_a, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(other_b, a, b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
_, _, length = _segment_metrics(a, b)
|
||||
lo, hi = sorted((_projection(other_a, a, b), _projection(other_b, a, b)))
|
||||
return min(length, hi) - max(0.0, lo) > _OPTIMIZE_REHOST_EPSILON
|
||||
|
||||
|
||||
def _angle_delta_mod_180(first: float, second: float) -> float:
|
||||
return abs((first - second + 90.0) % 180.0 - 90.0)
|
||||
|
||||
|
||||
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
|
||||
"""Whether a collinear union covers one complete positive target span."""
|
||||
dx, dy, length = _segment_metrics(target_a, target_b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
ranges = []
|
||||
for a, b in segments:
|
||||
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
continue
|
||||
lo, hi = sorted((_projection(a, target_a, target_b),
|
||||
_projection(b, target_a, target_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
|
||||
ranges.append((lo, hi))
|
||||
reached = 0.0
|
||||
for lo, hi in sorted(ranges):
|
||||
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
reached = max(reached, hi)
|
||||
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
|
||||
|
||||
|
||||
def _known_optimize_partition(partition: dict) -> bool:
|
||||
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
|
||||
|
||||
|
||||
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
|
||||
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
|
||||
if not (isinstance(a, list) and len(a) == 2
|
||||
and isinstance(b, list) and len(b) == 2):
|
||||
return None
|
||||
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return None
|
||||
_, _, length = _segment_metrics(axis_a, axis_b)
|
||||
lo, hi = sorted((_projection(a, axis_a, axis_b),
|
||||
_projection(b, axis_a, axis_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
|
||||
|
||||
|
||||
def _axis_point(a: list, b: list, along: float) -> list[float]:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
return [float(a[0]) + dx / length * along,
|
||||
float(a[1]) + dy / length * along]
|
||||
|
||||
|
||||
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
|
||||
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
|
||||
partition_id = str(old_partition.get("id", ""))
|
||||
current = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if current == old_partition:
|
||||
return False
|
||||
if current is None:
|
||||
return True
|
||||
if not (_known_optimize_partition(old_partition)
|
||||
and _known_optimize_partition(current)):
|
||||
return False
|
||||
try:
|
||||
if float(current.get("cm")) != float(old_partition.get("cm")):
|
||||
return False
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
|
||||
_, _, old_length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return current_range is not None and (
|
||||
current_range[0] > _OPTIMIZE_REHOST_EPSILON
|
||||
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
|
||||
"""Independently prove every removed atom of one old partition axis."""
|
||||
if not _known_optimize_partition(old_partition):
|
||||
return False
|
||||
try:
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
old_cm = float(old_partition["cm"])
|
||||
_, _, length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
|
||||
return False
|
||||
|
||||
rooms: list[tuple[str, list[tuple[list, list]]]] = []
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
if len(poly) < 3:
|
||||
return False
|
||||
rooms.append((str(room.get("id", "")), [
|
||||
(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))
|
||||
]))
|
||||
|
||||
candidate_partitions = space.get("partitions") or []
|
||||
retained: list[tuple[float, float]] = []
|
||||
breakpoints = [0.0, length]
|
||||
|
||||
def note_segment(a: list, b: list) -> tuple[float, float] | None:
|
||||
projected = _axis_range(a, b, old_a, old_b)
|
||||
if projected:
|
||||
breakpoints.extend(projected)
|
||||
return projected
|
||||
|
||||
for partition in candidate_partitions:
|
||||
try:
|
||||
projected = note_segment(partition["a"], partition["b"])
|
||||
candidate_cm = float(partition["cm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
|
||||
retained.append(projected)
|
||||
for _, edges in rooms:
|
||||
for edge_a, edge_b in edges:
|
||||
note_segment(edge_a, edge_b)
|
||||
for collection in (space.get("walls") or [], space.get("open_spans") or []):
|
||||
for segment in collection:
|
||||
try:
|
||||
note_segment(segment["a"], segment["b"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
points = sorted(set(round(point, 12) for point in breakpoints
|
||||
if -_OPTIMIZE_REHOST_EPSILON <= point
|
||||
<= length + _OPTIMIZE_REHOST_EPSILON))
|
||||
for lo, hi in zip(points, points[1:]):
|
||||
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
|
||||
continue
|
||||
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
|
||||
and end >= hi - _OPTIMIZE_REHOST_EPSILON
|
||||
for start, end in retained):
|
||||
continue
|
||||
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
|
||||
owners = {room_id for room_id, edges in rooms
|
||||
if _segments_cover_target(edges, target_a, target_b)}
|
||||
collinear = {room_id for room_id, edges in rooms
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, edge_a, edge_b
|
||||
) for edge_a, edge_b in edges)}
|
||||
if len(owners) not in (1, 2) or collinear != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, span["a"], span["b"]
|
||||
) for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, partition["a"], partition["b"]
|
||||
) for partition in candidate_partitions):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
try:
|
||||
wall_a, wall_b = wall["a"], wall["b"]
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((
|
||||
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
|
||||
))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM)
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
|
||||
host = opening.get("host")
|
||||
try:
|
||||
if not isinstance(host, dict) or host.get("kind") != "partition":
|
||||
return None
|
||||
t = float(host["t"])
|
||||
a, b = partition["a"], partition["b"]
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
|
||||
return None
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
if angle >= 90:
|
||||
angle -= 180
|
||||
elif angle < -90:
|
||||
angle += 180
|
||||
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
|
||||
|
||||
|
||||
def _safe_optimize_residual_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Prove a hosted opening rebound to a newly-created residual id."""
|
||||
old_host, new_host = old_opening.get("host"), opening.get("host")
|
||||
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
|
||||
and old_host.get("kind") == new_host.get("kind") == "partition"):
|
||||
return False
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
|
||||
new_partition = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
|
||||
if old_partition is None or new_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
if ({key: value for key, value in old_opening.items() if key not in ignored}
|
||||
!= {key: value for key, value in opening.items() if key not in ignored}):
|
||||
return False
|
||||
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
|
||||
new_geometry = _hosted_opening_geometry(opening, new_partition)
|
||||
if old_geometry is None or new_geometry is None:
|
||||
return False
|
||||
old_x, old_y, old_angle, old_length = old_geometry
|
||||
new_x, new_y, new_angle, new_length = new_geometry
|
||||
try:
|
||||
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
|
||||
explicit_angle = float(opening["angle"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
|
||||
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Independently prove the #276/#296 partition-to-room-wall transition."""
|
||||
old_host = old_opening.get("host")
|
||||
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
|
||||
return False
|
||||
partition_id = str(old_host.get("id", ""))
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if old_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
a, b = old_partition.get("a"), old_partition.get("b")
|
||||
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
|
||||
return False
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
|
||||
new_stable = {key: value for key, value in opening.items() if key not in ignored}
|
||||
if old_stable != new_stable:
|
||||
return False
|
||||
try:
|
||||
t = float(old_host["t"])
|
||||
x, y = float(opening["x"]), float(opening["y"])
|
||||
opening_angle = float(opening["angle"])
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if not (0 <= t <= 1 and opening_length > 0):
|
||||
return False
|
||||
expected_x, expected_y = float(a[0]) + dx * t, float(a[1]) + dy * t
|
||||
expected_angle = math.degrees(math.atan2(dy, dx))
|
||||
if expected_angle >= 90:
|
||||
expected_angle -= 180
|
||||
elif expected_angle < -90:
|
||||
expected_angle += 180
|
||||
if (abs(x - expected_x) > _OPTIMIZE_REHOST_EPSILON
|
||||
or abs(y - expected_y) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(opening_angle, expected_angle) > 1e-7):
|
||||
return False
|
||||
along = t * length
|
||||
if (along - opening_length / 2 < -_OPTIMIZE_REHOST_EPSILON
|
||||
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
|
||||
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
|
||||
target_a = [expected_x - half_dx, expected_y - half_dy]
|
||||
target_b = [expected_x + half_dx, expected_y + half_dy]
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if _segments_cover_target(edges, target_a, target_b):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
|
||||
for item in space.get("partitions") or []):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
wall_a, wall_b = wall.get("a"), wall.get("b")
|
||||
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
|
||||
continue
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
|
||||
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
|
||||
return False
|
||||
|
||||
for other in space.get("openings") or []:
|
||||
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
|
||||
continue
|
||||
try:
|
||||
center = [float(other["x"]), float(other["y"])]
|
||||
other_angle = float(other["angle"])
|
||||
other_length = float(other["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if (_line_distance(center, a, b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _angle_delta_mod_180(other_angle, expected_angle) > 1e-7):
|
||||
continue
|
||||
other_along = _projection(center, a, b)
|
||||
if (min(along + opening_length / 2, other_along + other_length / 2)
|
||||
- max(along - opening_length / 2, other_along - other_length / 2)
|
||||
> _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None, *, allow_optimize_rehost: bool = False
|
||||
) -> None:
|
||||
"""Validate hosted-opening write deltas without rejecting legacy reads.
|
||||
|
||||
Deleting the opening together with its partition remains valid. Surviving
|
||||
records keep their host, while new/direct geometry changes reserve half the
|
||||
actual wall depth at both endpoints. Rigid host translation and unrelated
|
||||
edits round-trip existing near-end records unchanged.
|
||||
"""
|
||||
old_spaces = {
|
||||
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
|
||||
}
|
||||
for space in config.get("spaces") or []:
|
||||
space_id = str(space.get("id", ""))
|
||||
old_space = old_spaces.get(space_id)
|
||||
old_openings = {
|
||||
str(opening.get("id")): opening
|
||||
for opening in (old_space or {}).get("openings") or []
|
||||
}
|
||||
partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in space.get("partitions") or []
|
||||
}
|
||||
old_partitions = {
|
||||
str(partition.get("id")): partition
|
||||
for partition in (old_space or {}).get("partitions") or []
|
||||
}
|
||||
for opening in space.get("openings") or []:
|
||||
opening_id = str(opening.get("id", ""))
|
||||
old = old_openings.get(opening_id)
|
||||
if old and old.get("host") is not None and opening.get("host") is None:
|
||||
if not (allow_optimize_rehost and old_space
|
||||
and _safe_optimize_partition_rehost(
|
||||
space, old_space, opening, old
|
||||
)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; host removed"
|
||||
)
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(str(host.get("id", "")))
|
||||
if partition is None:
|
||||
# SPACE_SCHEMA owns missing-host diagnostics.
|
||||
continue
|
||||
old_host = (old or {}).get("host")
|
||||
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
|
||||
if (allow_optimize_rehost and old and old_space
|
||||
and isinstance(old_host, dict)
|
||||
and old_host.get("kind") == "partition"
|
||||
and isinstance(host, dict) and host.get("kind") == "partition"
|
||||
and old_host.get("id") != host.get("id")
|
||||
and not _safe_optimize_residual_rehost(
|
||||
space, old_space, opening, old
|
||||
)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; residual host changed"
|
||||
)
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
old_span = None
|
||||
if old_partition is not None:
|
||||
old_ax, old_ay = old_partition["a"]
|
||||
old_bx, old_by = old_partition["b"]
|
||||
old_span = ((old_bx - old_ax) ** 2 + (old_by - old_ay) ** 2) ** 0.5
|
||||
strict = (
|
||||
old is None
|
||||
or old_host is None
|
||||
or old_host.get("id") != host.get("id")
|
||||
or old_host.get("t") != host.get("t")
|
||||
or old.get("length") != opening.get("length")
|
||||
or old_partition is None
|
||||
or old_partition.get("cm") != partition.get("cm")
|
||||
or abs(old_span - span) > 1e-9
|
||||
)
|
||||
if not strict:
|
||||
continue
|
||||
cell_cm = float(space.get("cell_cm", 5))
|
||||
margin_cm = float(partition["cm"]) / 2
|
||||
margin = margin_cm / cell_cm / NORMALIZED_CANVAS_CELLS
|
||||
along = float(host["t"]) * span
|
||||
half = float(opening["length"]) / 2
|
||||
if (along - half < margin - 1e-9
|
||||
or along + half > span - margin + 1e-9):
|
||||
raise PartitionOpeningJambMarginError(
|
||||
space_id, opening_id, margin, margin_cm
|
||||
)
|
||||
if allow_optimize_rehost and old_space:
|
||||
for old_partition in old_space.get("partitions") or []:
|
||||
if (_optimize_partition_needs_proof(old_partition, space)
|
||||
and not _safe_optimize_partition_delta(space, old_partition)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; partition={old_partition.get('id', '')}; "
|
||||
"optimize delta unproved"
|
||||
)
|
||||
|
||||
|
||||
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
|
||||
|
||||
|
||||
def validate_opening_passages(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Reject new/changed inapplicable fields while preserving dormant bad data.
|
||||
|
||||
A passage read from an older/future writer may already contain door-only
|
||||
keys. Unrelated writes must remain possible, but imports and any write that
|
||||
introduces or changes such a key are fail-closed.
|
||||
"""
|
||||
old_spaces = {
|
||||
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
|
||||
}
|
||||
for space in config.get("spaces") or []:
|
||||
space_id = str(space.get("id", ""))
|
||||
old_space = None if validate_all else old_spaces.get(space_id)
|
||||
old_openings = {
|
||||
str(opening.get("id")): opening
|
||||
for opening in (old_space or {}).get("openings") or []
|
||||
}
|
||||
for opening in space.get("openings") or []:
|
||||
if opening.get("type") != "passage":
|
||||
continue
|
||||
opening_id = str(opening.get("id", ""))
|
||||
present = sorted(PASSAGE_FORBIDDEN_FIELDS & set(opening))
|
||||
if not present:
|
||||
continue
|
||||
old_opening = None if validate_all else old_openings.get(opening_id)
|
||||
if not old_opening or old_opening.get("type") != "passage":
|
||||
raise OpeningPassageError(space_id, opening_id, present)
|
||||
changed = [
|
||||
field for field in present
|
||||
if field not in old_opening or opening[field] != old_opening[field]
|
||||
]
|
||||
if changed:
|
||||
raise OpeningPassageError(space_id, opening_id, changed)
|
||||
|
||||
|
||||
VALUE_BADGE_ATTRIBUTES = {
|
||||
"current_temperature", "temperature", "current_humidity", "humidity",
|
||||
"current_position", "percentage", "brightness", "volume_level",
|
||||
@@ -731,7 +135,7 @@ def validate_marker_value_badges(
|
||||
def validate_marker_light_entities(
|
||||
config: dict, previous: dict | None = None, *, validate_all: bool = False
|
||||
) -> None:
|
||||
"""Validate new/changed light/switch choices without rejecting dormant data.
|
||||
"""Validate new/changed leading-light choices without rejecting dormant data.
|
||||
|
||||
The top-level schema must stay lossless: an old or future literal that the
|
||||
current frontend cannot edit may round-trip unchanged. Imports validate the
|
||||
@@ -748,26 +152,16 @@ def validate_marker_light_entities(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
for field, code, message in (
|
||||
(
|
||||
"light_entity",
|
||||
"invalid_light_entity",
|
||||
"Leading light entity must be light.* or switch.*",
|
||||
),
|
||||
(
|
||||
"toggle_entity",
|
||||
"invalid_toggle_entity",
|
||||
"Toggle entity must be light.* or switch.*",
|
||||
),
|
||||
):
|
||||
value = marker.get(field)
|
||||
old_value = None if validate_all else (old_marker or {}).get(field)
|
||||
if not validate_all and value == old_value:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
|
||||
raise MarkerControlError(code, message)
|
||||
value = marker.get("light_entity")
|
||||
old_value = None if validate_all else (old_marker or {}).get("light_entity")
|
||||
if not validate_all and value == old_value:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str) or not _LIGHT_ENTITY_RE.fullmatch(value):
|
||||
raise MarkerControlError(
|
||||
"invalid_light_entity", "Leading light entity must be light.* or switch.*"
|
||||
)
|
||||
|
||||
|
||||
def validate_marker_controls(
|
||||
@@ -958,18 +352,11 @@ _URL = vol.All(str, vol.Length(max=MAX_URL))
|
||||
CANVAS_LIMIT = 5000.0
|
||||
_COORD = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
|
||||
POS_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
),
|
||||
canonicalize_position,
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(
|
||||
vol.Schema({str: POS_SCHEMA}),
|
||||
vol.Length(max=MAX_LAYOUT),
|
||||
canonicalize_layout_geometry,
|
||||
POS_SCHEMA = vol.Schema(
|
||||
{vol.Required("x"): _COORD, vol.Required("y"): _COORD},
|
||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||
)
|
||||
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||
|
||||
# Room/opening geometry: same story, same range (docs/CANVAS.md). A vertex at
|
||||
# 2.5 is a plan that grew past the old square, not corruption; 1e100 is
|
||||
@@ -1316,15 +703,6 @@ WALL_COLUMN_SCHEMA = vol.All(
|
||||
_strict_wall_column,
|
||||
)
|
||||
|
||||
PARTITION_OPENING_HOST_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("kind"): vol.Equal("partition"),
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("t"): vol.All(_finite, vol.Range(min=0, max=1)),
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
@@ -1342,29 +720,6 @@ def _space_geometry_invariants(value: dict) -> dict:
|
||||
)
|
||||
if draft_segments > MAX_DRAFT_SEGMENTS:
|
||||
raise vol.Invalid("too many saved room-draft segments")
|
||||
partitions = {
|
||||
item.get("id"): item for item in value.get("partitions", []) if item.get("id")
|
||||
}
|
||||
hosted_intervals: dict[str, list[tuple[float, float]]] = {}
|
||||
for opening in value.get("openings", []):
|
||||
host = opening.get("host")
|
||||
if host is None:
|
||||
continue
|
||||
partition = partitions.get(host["id"])
|
||||
if partition is None:
|
||||
raise vol.Invalid("partition opening host must exist in the same space")
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
length = float(opening["length"])
|
||||
along = float(host["t"]) * span
|
||||
if length > span or along - length / 2 < -1e-9 or along + length / 2 > span + 1e-9:
|
||||
raise vol.Invalid("partition opening must fit inside its host")
|
||||
lo, hi = along - length / 2, along + length / 2
|
||||
occupied = hosted_intervals.setdefault(host["id"], [])
|
||||
if any(max(lo, old_lo) < min(hi, old_hi) - 1e-9 for old_lo, old_hi in occupied):
|
||||
raise vol.Invalid("partition openings must not overlap")
|
||||
occupied.append((lo, hi))
|
||||
return value
|
||||
|
||||
|
||||
@@ -1418,7 +773,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window", "gate", "passage"),
|
||||
vol.Required("type"): vol.Any("door", "window", "gate"),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
@@ -1430,7 +785,6 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Optional("invert"): bool,
|
||||
vol.Optional("flip_h"): bool,
|
||||
vol.Optional("flip_v"): bool,
|
||||
vol.Optional("host"): PARTITION_OPENING_HOST_SCHEMA,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
@@ -1531,9 +885,6 @@ MARKER_SCHEMA = vol.Schema(
|
||||
# Semantic delta validation below the schema preserves unknown/future
|
||||
# literals until that exact field is edited (lossless config doctrine).
|
||||
vol.Optional("light_entity"): object,
|
||||
# Exact own entity selected for Toggle. Delta validation preserves an
|
||||
# untouched future literal while bounding every new/changed value.
|
||||
vol.Optional("toggle_entity"): object,
|
||||
vol.Optional("value_badge"): vol.Any(
|
||||
None,
|
||||
vol.Schema(
|
||||
@@ -1567,41 +918,38 @@ MARKER_SCHEMA = vol.Schema(
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
),
|
||||
canonicalize_config_geometry,
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||
vol.Optional("settings", default=dict): vol.Schema(
|
||||
{
|
||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||
# background around the plan, all spaces (a space may override)
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
# sun on the plan (docs/SUN.md): global defaults
|
||||
vol.Optional("north_deg"): _north_deg,
|
||||
vol.Optional("bg_mode"): _BG_MODE,
|
||||
vol.Optional("sun_rays"): bool,
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
# legacy field so an existing stored config can still load; the
|
||||
# frontend ignores it and removes it on the next settings save.
|
||||
vol.Optional("weather_entity"): vol.Any(None, _TEXT),
|
||||
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
|
||||
vol.Optional("fill_colors"): vol.Schema(
|
||||
{
|
||||
str: vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
vol.Required("a"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading
|
||||
)
|
||||
|
||||
@@ -25,10 +25,6 @@ from .const import (
|
||||
MAX_SIGN_PATHS,
|
||||
PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .coordinate_canonicalization import (
|
||||
canonicalize_config_geometry,
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .import_export import (
|
||||
ImportFailure,
|
||||
@@ -59,12 +55,9 @@ from .virtual_lights import (
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .projection import project_config, project_layout
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
@@ -143,7 +136,6 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
websocket_api.async_register_command(hass, ws_space_delete)
|
||||
websocket_api.async_register_command(hass, ws_plan_set)
|
||||
websocket_api.async_register_command(hass, ws_plans_list)
|
||||
websocket_api.async_register_command(hass, ws_plans_delete)
|
||||
@@ -271,7 +263,6 @@ async def _commit_import_pair(
|
||||
vol.Required("type"): "houseplan/export/create",
|
||||
vol.Required("kind"): vol.In(["full", "space"]),
|
||||
vol.Optional("space_id"): str,
|
||||
vol.Optional("plan_only", default=False): bool,
|
||||
vol.Optional("card_version", default=""): str,
|
||||
}
|
||||
)
|
||||
@@ -296,7 +287,6 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
plan_only=msg.get("plan_only", False),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
@@ -432,12 +422,8 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if kind == "full":
|
||||
backup = {
|
||||
"kind": "import",
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
@@ -454,20 +440,16 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
final_metadata[_OPTIMIZE_BACKUP] = backup
|
||||
pending = {
|
||||
"kind": "import",
|
||||
"config": canonicalize_config_geometry(target_config),
|
||||
"layout": canonicalize_layout_geometry(target_layout),
|
||||
"config": target_config,
|
||||
"layout": target_layout,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"final_metadata": final_metadata,
|
||||
}
|
||||
rollback = {
|
||||
"kind": "import_rollback",
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout") or {}
|
||||
),
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout") or {},
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
@@ -510,9 +492,6 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"layout_rev": new_layout_rev,
|
||||
"counts": details.get("counts", {}),
|
||||
"space_id": details.get("space_id"),
|
||||
"repaired_target_refs": details.get("repaired_target_refs", 0),
|
||||
"preserved_unresolved_refs": details.get("preserved_unresolved_refs", 0),
|
||||
"reference_report": details.get("reference_report", {}),
|
||||
"can_undo": kind == "full",
|
||||
})
|
||||
|
||||
@@ -532,15 +511,10 @@ def _live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, An
|
||||
return live_layout(config, layout)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/layout/get",
|
||||
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
}
|
||||
)
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/layout/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the saved layout, optionally narrowed to one space (#256)."""
|
||||
"""Return the saved layout."""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
@@ -548,9 +522,7 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
connection.send_result(
|
||||
msg["id"], {
|
||||
"layout": project_layout(
|
||||
data.get("layout", {}), space_id=msg.get("space_id"),
|
||||
),
|
||||
"layout": data.get("layout", {}),
|
||||
"rev": int(data.get("rev", 0)),
|
||||
"can_optimize_undo": _optimizer_backup_is_current(config_data, data),
|
||||
"undo_kind": _undo_kind(config_data, data),
|
||||
@@ -590,9 +562,6 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
return
|
||||
layout = _live_layout(config_data.get("config") or {}, msg["layout"])
|
||||
if layout == data.get("layout", {}):
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": current_rev})
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await async_save_layout_state(
|
||||
rt, data, layout, new_rev,
|
||||
@@ -654,10 +623,7 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
data = await rt.store.async_load() or {}
|
||||
layout = data.get("layout", {})
|
||||
if layout.get(msg["device_id"]) == msg["pos"]:
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
|
||||
return
|
||||
layout = {**layout, msg["device_id"]: msg["pos"]}
|
||||
layout[msg["device_id"]] = msg["pos"]
|
||||
# keep the revision: a point-wise write used to drop it, which made the
|
||||
# optimistic locking on layout/set meaningless — every drag reset the
|
||||
# counter to 0 (HP-1454-08)
|
||||
@@ -765,10 +731,7 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
|
||||
rt, data, new_layout, new_rev,
|
||||
metadata={
|
||||
**_optimizer_backup_after_layout_maintenance(data, new_rev),
|
||||
"repair_backup": {
|
||||
"space": space_id,
|
||||
"positions": canonicalize_layout_geometry(touched),
|
||||
},
|
||||
"repair_backup": {"space": space_id, "positions": touched},
|
||||
},
|
||||
remove=("repair_backup",),
|
||||
)
|
||||
@@ -1091,20 +1054,7 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
# ---------------- space configuration ----------------
|
||||
|
||||
|
||||
_PROJECTION_FIELDS = vol.All([vol.All(str, vol.Length(min=1, max=100))], vol.Length(max=50))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/config/get",
|
||||
# Проекция ответа (#256). Все параметры необязательны, и без них ответ
|
||||
# прежний — это главный инвариант: ни один существующий клиент не
|
||||
# должен заметить появление этой возможности.
|
||||
vol.Optional("space_id"): vol.All(str, vol.Length(min=1, max=200)),
|
||||
vol.Optional("fields"): _PROJECTION_FIELDS,
|
||||
vol.Optional("marker_fields"): _PROJECTION_FIELDS,
|
||||
}
|
||||
)
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/config/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Return the configuration, its revision, and whether this user may write.
|
||||
@@ -1112,11 +1062,6 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
`can_write` is the single source of truth for the card's editor chrome
|
||||
(audit P0-4): the UI must mirror `may_write`, not a hard-coded is_admin
|
||||
check that drifted from the integration option.
|
||||
|
||||
Optional `space_id`/`fields`/`marker_fields` narrow ONLY the returned
|
||||
document (#256). Revisions and capability flags are computed from the whole
|
||||
stored configuration: a caller that asked for one floor must not receive a
|
||||
revision that describes only that floor.
|
||||
"""
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
@@ -1140,12 +1085,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"config": project_config(
|
||||
config,
|
||||
space_id=msg.get("space_id"),
|
||||
fields=msg.get("fields"),
|
||||
marker_fields=msg.get("marker_fields"),
|
||||
),
|
||||
"config": config,
|
||||
"rev": config_rev,
|
||||
"virtual_lights": virtual_lights,
|
||||
"can_write": may_write(hass, getattr(connection, "user", None)),
|
||||
@@ -1312,12 +1252,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], data.get("config"))
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
except MarkerControlError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
# An internal plan url must name a file that exists. The card can pick a
|
||||
@@ -1337,17 +1272,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
return
|
||||
if msg["config"] == data.get("config"):
|
||||
# A semantic no-op still has to reconcile Repairs with external
|
||||
# file-system changes. It must not create a revision, event, or
|
||||
# discard the optimizer snapshot merely to refresh diagnostics.
|
||||
entry = get_entry(hass)
|
||||
if entry is not None:
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": int(current_rev)})
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await async_save_config_state(
|
||||
rt,
|
||||
@@ -1387,193 +1311,6 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
|
||||
def _space_marker_dependencies(
|
||||
config: dict[str, Any], layout: dict[str, Any], space_id: str,
|
||||
) -> list[str]:
|
||||
"""Active marker ids that make deleting a space unsafe (deduplicated)."""
|
||||
space = next(
|
||||
(item for item in config.get("spaces") or [] if item.get("id") == space_id),
|
||||
None,
|
||||
)
|
||||
room_ids = {
|
||||
str(room.get("id")) for room in (space or {}).get("rooms") or []
|
||||
if room.get("id") is not None
|
||||
}
|
||||
dependencies = {
|
||||
str(marker.get("id"))
|
||||
for marker in config.get("markers") or []
|
||||
if marker.get("removed") is not True
|
||||
and marker.get("id") is not None
|
||||
and (
|
||||
marker.get("space") == space_id
|
||||
or (
|
||||
marker.get("room_id") is not None
|
||||
and str(marker.get("room_id")) in room_ids
|
||||
)
|
||||
or (layout.get(str(marker.get("id"))) or {}).get("s") == space_id
|
||||
)
|
||||
}
|
||||
return sorted(dependencies)
|
||||
|
||||
|
||||
def _space_delete_candidate(
|
||||
config: dict[str, Any], layout: dict[str, Any], space_id: str,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[str], int]:
|
||||
"""Return a pure exact pair; blockers leave both inputs unchanged."""
|
||||
candidate_config = json.loads(json.dumps(config))
|
||||
candidate_layout = json.loads(json.dumps(layout))
|
||||
dependencies = _space_marker_dependencies(candidate_config, candidate_layout, space_id)
|
||||
spaces = candidate_config.get("spaces") or []
|
||||
deleting_last_space = len(spaces) == 1 and spaces[0].get("id") == space_id
|
||||
if dependencies and not deleting_last_space:
|
||||
return candidate_config, candidate_layout, dependencies, 0
|
||||
space = next(
|
||||
(item for item in candidate_config.get("spaces") or []
|
||||
if item.get("id") == space_id),
|
||||
None,
|
||||
)
|
||||
room_ids = {
|
||||
str(room.get("id")) for room in (space or {}).get("rooms") or []
|
||||
if room.get("id") is not None
|
||||
}
|
||||
candidate_config["spaces"] = [
|
||||
item for item in candidate_config.get("spaces") or []
|
||||
if item.get("id") != space_id
|
||||
]
|
||||
for marker in candidate_config.get("markers") or []:
|
||||
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
|
||||
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
|
||||
references_deleted_space = (
|
||||
marker.get("space") == space_id
|
||||
or (
|
||||
marker.get("room_id") is not None
|
||||
and str(marker.get("room_id")) in room_ids
|
||||
)
|
||||
or (
|
||||
isinstance(marker_position, dict)
|
||||
and marker_position.get("s") == space_id
|
||||
)
|
||||
)
|
||||
if deleting_last_space and references_deleted_space:
|
||||
marker.pop("space", None)
|
||||
marker.pop("room_id", None)
|
||||
continue
|
||||
if marker.get("removed") is not True:
|
||||
continue
|
||||
if marker.get("space") == space_id:
|
||||
marker.pop("space", None)
|
||||
if (marker.get("room_id") is not None
|
||||
and str(marker.get("room_id")) in room_ids):
|
||||
marker.pop("room_id", None)
|
||||
removed_layout = 0
|
||||
for key in list(candidate_layout):
|
||||
position = candidate_layout.get(key)
|
||||
if isinstance(position, dict) and position.get("s") == space_id:
|
||||
del candidate_layout[key]
|
||||
removed_layout += 1
|
||||
return candidate_config, candidate_layout, dependencies, removed_layout
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/space/delete",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Delete one space as a crash-recoverable config/layout pair."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only editors may delete spaces")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
space_id = msg["space_id"]
|
||||
if not valid_space_id(space_id):
|
||||
connection.send_error(msg["id"], "invalid_space_id", "Invalid space id")
|
||||
return
|
||||
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
config_rev = int(config_data.get("rev", 0))
|
||||
layout_rev = int(layout_data.get("rev", 0))
|
||||
if (msg["expected_config_rev"] != config_rev
|
||||
or msg["expected_layout_rev"] != layout_rev):
|
||||
connection.send_error(msg["id"], "conflict", "Plan changed elsewhere")
|
||||
return
|
||||
current_config = config_data.get("config") or DEFAULT_CONFIG
|
||||
current_layout = layout_data.get("layout") or {}
|
||||
if not any(
|
||||
item.get("id") == space_id for item in current_config.get("spaces") or []
|
||||
):
|
||||
connection.send_error(msg["id"], "space_not_found", "Space no longer exists")
|
||||
return
|
||||
target_config, target_layout, dependencies, removed_layout = (
|
||||
_space_delete_candidate(current_config, current_layout, space_id)
|
||||
)
|
||||
spaces = current_config.get("spaces") or []
|
||||
deleting_last_space = (
|
||||
len(spaces) == 1 and spaces[0].get("id") == space_id
|
||||
)
|
||||
if dependencies and not deleting_last_space:
|
||||
connection.send_error(
|
||||
msg["id"], "space_in_use",
|
||||
f"Space is still used by {len(dependencies)} active marker(s)",
|
||||
)
|
||||
return
|
||||
target_config = CONFIG_SCHEMA(target_config)
|
||||
target_layout = LAYOUT_SCHEMA(target_layout)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
original_metadata = _layout_metadata(layout_data)
|
||||
final_metadata = {
|
||||
key: value for key, value in original_metadata.items()
|
||||
if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"}
|
||||
}
|
||||
pending = {
|
||||
"kind": "space_delete",
|
||||
"config": canonicalize_config_geometry(target_config),
|
||||
"layout": canonicalize_layout_geometry(target_layout),
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"final_metadata": final_metadata,
|
||||
}
|
||||
rollback = {
|
||||
"kind": "space_delete_rollback",
|
||||
"config": canonicalize_config_geometry(current_config),
|
||||
"layout": canonicalize_layout_geometry(current_layout),
|
||||
"config_rev": config_rev,
|
||||
"layout_rev": layout_rev,
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
await _commit_import_pair(rt, pending, rollback)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_config", str(err))
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("House Plan space delete failed")
|
||||
connection.send_error(msg["id"], "commit_failed", "Space delete failed")
|
||||
return
|
||||
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
|
||||
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
|
||||
_refresh_trail_recorder(hass)
|
||||
connection.send_result(msg["id"], {
|
||||
"ok": True,
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"removed_layout": removed_layout,
|
||||
})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/plan/optimize",
|
||||
@@ -1625,15 +1362,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_value_badges(msg["config"], config_data.get("config"))
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(
|
||||
msg["config"], config_data.get("config"),
|
||||
allow_optimize_rehost=True,
|
||||
)
|
||||
except (
|
||||
MarkerControlError, OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
) as err:
|
||||
except MarkerControlError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
|
||||
@@ -1654,19 +1383,15 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
new_layout_rev = layout_rev + 1
|
||||
backup = {
|
||||
"kind": "optimize",
|
||||
"config": canonicalize_config_geometry(
|
||||
config_data.get("config") or DEFAULT_CONFIG
|
||||
),
|
||||
"layout": canonicalize_layout_geometry(
|
||||
layout_data.get("layout", {})
|
||||
),
|
||||
"config": config_data.get("config") or DEFAULT_CONFIG,
|
||||
"layout": layout_data.get("layout", {}),
|
||||
"created": int(time.time()),
|
||||
"after_config_rev": new_config_rev,
|
||||
"after_layout_rev": new_layout_rev,
|
||||
}
|
||||
pending = {
|
||||
"config": canonicalize_config_geometry(msg["config"]),
|
||||
"layout": canonicalize_layout_geometry(msg["layout"]),
|
||||
"config": msg["config"],
|
||||
"layout": msg["layout"],
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"clear_backup": False,
|
||||
@@ -1736,12 +1461,8 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
|
||||
|
||||
backup = layout_data[_OPTIMIZE_BACKUP]
|
||||
restored_kind = str(backup.get("kind") or "optimize")
|
||||
restored_config = canonicalize_config_geometry(
|
||||
backup.get("config") or DEFAULT_CONFIG
|
||||
)
|
||||
restored_layout = canonicalize_layout_geometry(
|
||||
backup.get("layout") or {}
|
||||
)
|
||||
restored_config = backup.get("config") or DEFAULT_CONFIG
|
||||
restored_layout = backup.get("layout") or {}
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
pending = {
|
||||
|
||||
@@ -8,7 +8,7 @@ public materials.
|
||||
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/`:
|
||||
`npm run bundle:sync` (копия стенда не коммитится, #255).
|
||||
`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`.
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// #276: same-process incremental cost of coincident-partition reconciliation
|
||||
// inside the complete Optimize candidate pass on the deterministic large house.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH_SIZE = 10;
|
||||
const RELATIVE_OVERHEAD = 0.15;
|
||||
const ABSOLUTE_OVERHEAD_MS = 25;
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const emptyReconciliation = (rawSpace, _model, walls) => ({
|
||||
walls: walls || [],
|
||||
partitions: Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : [],
|
||||
openings: Array.isArray(rawSpace?.openings) ? rawSpace.openings : [],
|
||||
partitionsReconciled: 0,
|
||||
openingsRehosted: 0,
|
||||
});
|
||||
const baseline = () => optimizePlans(fixture.config, {}, {}, {
|
||||
reconcileCoincidentPartitions: emptyReconciliation,
|
||||
});
|
||||
const candidate = () => optimizePlans(fixture.config, {});
|
||||
const timed = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH_SIZE; index++) {
|
||||
const result = operation();
|
||||
if (!result || !result.report) throw new Error('Optimize candidate returned no report');
|
||||
}
|
||||
return (performance.now() - start) / BATCH_SIZE;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
|
||||
const baselineTimes = [];
|
||||
const candidateTimes = [];
|
||||
const overheadTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
// ABBA/BAAB cancels first-order clock drift and balances cache/GC order;
|
||||
// batching then averages timer noise before the paired p95 is computed.
|
||||
const candidateFirst = index % 2 === 1;
|
||||
const first = timed(candidateFirst ? candidate : baseline);
|
||||
const second = timed(candidateFirst ? baseline : candidate);
|
||||
const third = timed(candidateFirst ? baseline : candidate);
|
||||
const fourth = timed(candidateFirst ? candidate : baseline);
|
||||
const baselineMs = candidateFirst ? (second + third) / 2 : (first + fourth) / 2;
|
||||
const candidateMs = candidateFirst ? (first + fourth) / 2 : (second + third) / 2;
|
||||
baselineTimes.push(baselineMs);
|
||||
candidateTimes.push(candidateMs);
|
||||
overheadTimes.push(candidateMs - baselineMs);
|
||||
}
|
||||
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values),
|
||||
median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95),
|
||||
max: Math.max(...values),
|
||||
});
|
||||
const baselineSummary = summary(baselineTimes);
|
||||
const candidateSummary = summary(candidateTimes);
|
||||
const overheadSummary = summary(overheadTimes);
|
||||
const measuredOverheadP95 = Math.max(0, overheadSummary.p95);
|
||||
const relativeP95 = measuredOverheadP95
|
||||
/ Math.max(baselineSummary.p95, Number.EPSILON);
|
||||
const pass = measuredOverheadP95 <= ABSOLUTE_OVERHEAD_MS
|
||||
&& relativeP95 <= RELATIVE_OVERHEAD;
|
||||
const report = {
|
||||
issue: 276,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
batchSize: BATCH_SIZE,
|
||||
baseline: baselineSummary,
|
||||
candidate: candidateSummary,
|
||||
pairedOverhead: overheadSummary,
|
||||
budgets: {
|
||||
relativeOverhead: RELATIVE_OVERHEAD,
|
||||
absoluteOverheadMs: ABSOLUTE_OVERHEAD_MS,
|
||||
},
|
||||
measured: { overheadP95Ms: measuredOverheadP95, relativeP95 },
|
||||
pass,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// #291: same-process p95 of the complete config+layout boundary versus the
|
||||
// pre-existing full-candidate clone contract. Batching makes the strict 20%
|
||||
// ratio meaningful even on coarse/loaded CI timers.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
|
||||
} from '../test-build/coordinate-canonicalization.js';
|
||||
|
||||
const WARMUPS = 30;
|
||||
const SAMPLES = 120;
|
||||
const BATCH = 10;
|
||||
const MAX_RATIO = 1.2;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
|
||||
const baseline = () => {
|
||||
JSON.parse(JSON.stringify(fixture.config));
|
||||
JSON.parse(JSON.stringify(fixture.layout || {}));
|
||||
};
|
||||
const candidate = () => {
|
||||
canonicalizeConfigGeometry(fixture.config);
|
||||
canonicalizeLayoutGeometry(fixture.layout || {});
|
||||
};
|
||||
const measure = (operation) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - started) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
const baselineSamples = [];
|
||||
const candidateSamples = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
if (index % 2) {
|
||||
candidateSamples.push(measure(candidate));
|
||||
baselineSamples.push(measure(baseline));
|
||||
} else {
|
||||
baselineSamples.push(measure(baseline));
|
||||
candidateSamples.push(measure(candidate));
|
||||
}
|
||||
}
|
||||
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
|
||||
const baselineP95 = p95(baselineSamples);
|
||||
const candidateP95 = p95(candidateSamples);
|
||||
const ratio = candidateP95 / baselineP95;
|
||||
const report = {
|
||||
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
|
||||
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
|
||||
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
@@ -14,27 +14,11 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
if (!['large-house-v1', 'large-house-isometric-v1'].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 requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.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(
|
||||
@@ -64,10 +48,7 @@ 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,
|
||||
requiresWallFace,
|
||||
}) => {
|
||||
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
@@ -123,8 +104,6 @@ try {
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
@@ -141,7 +120,6 @@ try {
|
||||
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,
|
||||
@@ -156,7 +134,6 @@ try {
|
||||
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')
|
||||
@@ -178,13 +155,6 @@ try {
|
||||
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'
|
||||
@@ -226,112 +196,6 @@ try {
|
||||
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 wallFaceCacheBeforePointer = card._wallFaceGraphCache?.length ?? 0;
|
||||
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,
|
||||
wallFaceCacheStableOnPointer:
|
||||
(card._wallFaceGraphCache?.length ?? 0) === wallFaceCacheBeforePointer,
|
||||
};
|
||||
if (requiresPlanSnap && (
|
||||
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|
||||
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|
||||
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|
||||
|| !planSnapDiagnostics.wallFaceCacheStableOnPointer
|
||||
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
|
||||
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
if (requiresWallFace) {
|
||||
const oldPath = card._path;
|
||||
const oldDraftId = card._activeDraftId;
|
||||
const oldCms = card._draftSegmentCms;
|
||||
const beforePath = [[10, 10]];
|
||||
card._path = [[10, 10], [20, 10]];
|
||||
card._activeDraftId = 'perf-face-draft';
|
||||
card._draftSegmentCms = [15];
|
||||
const acceptedStarted = performance.now();
|
||||
card._offerWallFaces(beforePath);
|
||||
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
|
||||
planSnapDiagnostics.wallFaceCacheEntries = card._wallFaceGraphCache?.length ?? 0;
|
||||
card._wallFaceBatch = null;
|
||||
card._roomDialog = false;
|
||||
card._path = oldPath;
|
||||
card._activeDraftId = oldDraftId;
|
||||
card._draftSegmentCms = oldCms;
|
||||
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|
||||
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
|
||||
throw new Error(`wall-face accepted-click contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
|
||||
}
|
||||
}
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
}) : null;
|
||||
|
||||
const resizePreview = await duration(async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
@@ -415,10 +279,6 @@ try {
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -428,7 +288,6 @@ try {
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -447,7 +306,7 @@ try {
|
||||
return result;
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
isometric, requiresIsometric,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -460,7 +319,6 @@ const metricNames = [
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// #199: same-process production-builder baseline versus the complete Optimize
|
||||
// preflight wrapper on the deterministic 3-floor large-house fixture.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
checkOptimizeGeometry,
|
||||
prepareSpacePhysicalGeometryInputs,
|
||||
} from '../test-build/plan-geometry-preflight.js';
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
floorFootprintGeometry,
|
||||
wallBodiesGeometry,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const ABSOLUTE_P95_MS = 250;
|
||||
const RELATIVE_RATIO = 1.2;
|
||||
const RELATIVE_NOISE_MS = 15;
|
||||
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const models = spaceModels(fixture.config);
|
||||
const prepared = fixture.config.spaces.map((space, index) =>
|
||||
prepareSpacePhysicalGeometryInputs(space, models[index]));
|
||||
|
||||
const directProductionPass = () => {
|
||||
for (const input of prepared) {
|
||||
const hasWalls = input.walls.length > 0 || input.physicalBodies.length > 0;
|
||||
const united = hasWalls
|
||||
? wallBodiesGeometry(
|
||||
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
input.physicalBodies,
|
||||
)
|
||||
: null;
|
||||
if (hasWalls && united == null) throw new Error(`baseline wall failure: ${input.space.id}`);
|
||||
if (input.space.rooms.length && united?.paperGeom == null) {
|
||||
const floor = floorFootprintGeometry(
|
||||
input.space.rooms, input.walls, input.openCuts,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
);
|
||||
if (floor == null) throw new Error(`baseline floor failure: ${input.space.id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const completePreflight = () => {
|
||||
const result = checkOptimizeGeometry(fixture.config);
|
||||
if (!result.ok || result.spaces.length !== LARGE_HOUSE_COUNTS.floors
|
||||
|| result.spaces.some((space) => space.status !== 'ok')) {
|
||||
throw new Error(`candidate preflight failure: ${JSON.stringify(result.spaces)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sample = (operation) => {
|
||||
const start = performance.now();
|
||||
operation();
|
||||
return performance.now() - start;
|
||||
};
|
||||
const run = (operation) => {
|
||||
for (let index = 0; index < WARMUPS; index++) operation();
|
||||
return Array.from({ length: SAMPLES }, () => sample(operation));
|
||||
};
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values),
|
||||
median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95),
|
||||
max: Math.max(...values),
|
||||
});
|
||||
|
||||
const baseline = summary(run(directProductionPass));
|
||||
const candidate = summary(run(completePreflight));
|
||||
const relativeLimit = baseline.p95 * RELATIVE_RATIO + RELATIVE_NOISE_MS;
|
||||
const pass = candidate.p95 <= ABSOLUTE_P95_MS && candidate.p95 <= relativeLimit;
|
||||
const report = {
|
||||
issue: 199,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
baseline,
|
||||
candidate,
|
||||
budgets: {
|
||||
absoluteP95Ms: ABSOLUTE_P95_MS,
|
||||
relativeRatio: RELATIVE_RATIO,
|
||||
relativeNoiseMs: RELATIVE_NOISE_MS,
|
||||
relativeLimitP95Ms: relativeLimit,
|
||||
},
|
||||
pass,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -1,107 +0,0 @@
|
||||
// #277: same-run historical edge-drag baseline versus the fixed-topology
|
||||
// pointer clamp, plus the exact one-space production preflight used on release.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
planEdgeDrag, clampEdgeDrag,
|
||||
resolveSafeResize, clampSafeResize, applySafeResize,
|
||||
safeResizeCachedDeltaCount,
|
||||
} from '../test-build/resize.js';
|
||||
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
|
||||
|
||||
const WARMUPS = 5;
|
||||
const SAMPLES = 20;
|
||||
const BATCH = 25;
|
||||
const POINTER_P95_MS = 16;
|
||||
const POINTER_RATIO = 1.2;
|
||||
const POINTER_NOISE_MS = 0.25;
|
||||
const PREFLIGHT_P95_MS = 75;
|
||||
|
||||
const rooms = [{ id: 'active', poly: [[0, 0], [300, 0], [300, 300], [0, 300]] }];
|
||||
for (let index = 0; index < 199; index++) {
|
||||
const x = 1000 + (index % 20) * 500;
|
||||
const y = Math.floor(index / 20) * 500;
|
||||
rooms.push({ id: `room-${index}`, poly: [[x, y], [x + 300, y], [x + 300, y + 300], [x, y + 300]] });
|
||||
}
|
||||
const opts = { minDim: 25, eps: 0.1, movingHalf: 10, obstacles: [] };
|
||||
const oldPlan = planEdgeDrag(rooms, 'active', 1);
|
||||
const safeResolution = resolveSafeResize(rooms, [], 'active', 1, opts);
|
||||
if (!oldPlan || !safeResolution.enabled) throw new Error('benchmark fixture is not resize-eligible');
|
||||
const safePlan = safeResolution.plan;
|
||||
|
||||
const baselinePointer = () => clampEdgeDrag(rooms, [], oldPlan, 100, 5, opts);
|
||||
const safePointer = () => {
|
||||
const delta = clampSafeResize(rooms, [], safePlan, 100, 5, opts);
|
||||
return applySafeResize(rooms, [], safePlan, delta);
|
||||
};
|
||||
const large = makeLargeHouseFixture();
|
||||
const currentSpaceConfig = { ...large.config, spaces: [large.config.spaces[0]] };
|
||||
const precomputeStart = performance.now();
|
||||
const cachedProductionGeometry = checkOptimizeGeometry(currentSpaceConfig);
|
||||
const renderPrecomputeMs = performance.now() - precomputeStart;
|
||||
if (!cachedProductionGeometry.ok
|
||||
|| cachedProductionGeometry.spaces.some((space) => space.status === 'failed')) {
|
||||
throw new Error(`safe-resize preflight fixture failed: ${JSON.stringify(cachedProductionGeometry.spaces)}`);
|
||||
}
|
||||
// The final preview frame owns the expensive production union. pointerup reads
|
||||
// the exact cached result for that cfg epoch; this measures commit latency, not
|
||||
// the existing large-house render budget measured by benchmark:large-house.
|
||||
const preflight = () => {
|
||||
if (!cachedProductionGeometry.ok) throw new Error('cached production geometry failed');
|
||||
return cachedProductionGeometry.fingerprint;
|
||||
};
|
||||
|
||||
const timedBatch = (operation) => {
|
||||
const start = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - start) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baselinePointer(); safePointer(); preflight();
|
||||
}
|
||||
const baselineTimes = [];
|
||||
const safeTimes = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
const safeFirst = index % 2 === 1;
|
||||
const first = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
const second = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const third = timedBatch(safeFirst ? baselinePointer : safePointer);
|
||||
const fourth = timedBatch(safeFirst ? safePointer : baselinePointer);
|
||||
baselineTimes.push(safeFirst ? (second + third) / 2 : (first + fourth) / 2);
|
||||
safeTimes.push(safeFirst ? (first + fourth) / 2 : (second + third) / 2);
|
||||
}
|
||||
const preflightTimes = Array.from({ length: SAMPLES }, () => {
|
||||
const start = performance.now(); preflight(); return performance.now() - start;
|
||||
});
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values), median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95), max: Math.max(...values),
|
||||
});
|
||||
const baseline = summary(baselineTimes);
|
||||
const candidate = summary(safeTimes);
|
||||
const commitPreflight = summary(preflightTimes);
|
||||
const relativeLimit = baseline.p95 * POINTER_RATIO + POINTER_NOISE_MS;
|
||||
const pass = candidate.p95 <= POINTER_P95_MS
|
||||
&& candidate.p95 <= relativeLimit
|
||||
&& commitPreflight.p95 <= PREFLIGHT_P95_MS
|
||||
&& safeResizeCachedDeltaCount(safePlan) <= 4096;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: { rooms: rooms.length, largeHouse: LARGE_HOUSE_COUNTS },
|
||||
warmups: WARMUPS, samples: SAMPLES, batch: BATCH,
|
||||
baseline, candidate, commitPreflight,
|
||||
renderPrecomputeMs,
|
||||
cacheEntries: safeResizeCachedDeltaCount(safePlan),
|
||||
budgets: {
|
||||
pointerP95Ms: POINTER_P95_MS, pointerRatio: POINTER_RATIO,
|
||||
pointerNoiseMs: POINTER_NOISE_MS, relativeLimit,
|
||||
commitPreflightP95Ms: PREFLIGHT_P95_MS, maxCacheEntries: 4096,
|
||||
},
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -1,78 +0,0 @@
|
||||
// #277: warm Resize-layer render cost on the supported large-house ceiling.
|
||||
// The deterministic snapshot-call assertion catches the original regression
|
||||
// even when runner timing noise happens to keep the p95 below its budget.
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const RENDER_P95_MS = 25;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const result = await page.evaluate(async ({ config, warmups, samples }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(config);
|
||||
card._space = config.spaces[0].id;
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const originalSnapshot = card._rszSnapshot.bind(card);
|
||||
let snapshotCalls = 0;
|
||||
card._rszSnapshot = () => {
|
||||
snapshotCalls++;
|
||||
return originalSnapshot();
|
||||
};
|
||||
|
||||
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
|
||||
snapshotCalls = 0;
|
||||
const times = [];
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const started = performance.now();
|
||||
card._renderResizeLayer(view);
|
||||
times.push(performance.now() - started);
|
||||
}
|
||||
card._rszSnapshot = originalSnapshot;
|
||||
return {
|
||||
times,
|
||||
snapshotCalls,
|
||||
roomCount: card._rszRooms().length,
|
||||
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
|
||||
};
|
||||
}, { config, warmups: WARMUPS, samples: SAMPLES });
|
||||
|
||||
await browser.close();
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const render = {
|
||||
min: Math.min(...result.times),
|
||||
median: quantile(result.times, 0.5),
|
||||
p95: quantile(result.times, 0.95),
|
||||
max: Math.max(...result.times),
|
||||
};
|
||||
const pass = result.snapshotCalls === SAMPLES
|
||||
&& result.roomCount === 20
|
||||
&& result.handleCount === 80
|
||||
&& render.p95 <= RENDER_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
roomCount: result.roomCount,
|
||||
handleCount: result.handleCount,
|
||||
snapshotCalls: result.snapshotCalls,
|
||||
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
|
||||
render,
|
||||
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -1,118 +0,0 @@
|
||||
// #278: component projection overhead and degraded-fixture completion budget.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { prepareSpacePhysicalGeometryInputs } from '../test-build/plan-geometry-preflight.js';
|
||||
import { spaceModels } from '../test-build/space-geometry.js';
|
||||
import {
|
||||
polyclipToPathD, wallBodiesGeometry, wallBodiesUnionPath,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 50;
|
||||
// One projection is sub-millisecond. Time a batch and report per-operation
|
||||
// cost so scheduler/timer quantisation cannot dominate the 10% relative gate.
|
||||
const VALID_BATCH = 100;
|
||||
const RELATIVE_RATIO = 1.1;
|
||||
const OVERHEAD_P95_MS = 20;
|
||||
const DEGRADED_P95_MS = 100;
|
||||
|
||||
const prepare = (config) => {
|
||||
const models = spaceModels(config);
|
||||
return config.spaces.map((space, index) =>
|
||||
prepareSpacePhysicalGeometryInputs(space, models[index]));
|
||||
};
|
||||
const args = (input) => [
|
||||
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
|
||||
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
|
||||
input.physicalBodies,
|
||||
];
|
||||
const large = makeLargeHouseFixture();
|
||||
const validInputs = prepare(large.config);
|
||||
const degradedFixture = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const degradedInput = prepare(degradedFixture.config)[0];
|
||||
|
||||
const validResults = validInputs.map((input) => wallBodiesGeometry(...args(input)));
|
||||
if (validResults.some((result) => result.status !== 'ok'))
|
||||
throw new Error(`valid geometry: ${validResults.map((result) => result.status).join(',')}`);
|
||||
const validGeometry = () => {
|
||||
for (const result of validResults) {
|
||||
// The previous production projection also serialized primary + paper.
|
||||
if (!polyclipToPathD(result.geom) || !polyclipToPathD(result.paperGeom))
|
||||
throw new Error('valid legacy projection');
|
||||
}
|
||||
};
|
||||
const validProjection = () => {
|
||||
for (const result of validResults) {
|
||||
const paths = result.components.map((component) => polyclipToPathD(component.geom));
|
||||
const paper = polyclipToPathD(result.paperGeom);
|
||||
if (!paths.length || paths.some((path) => !path) || !paper)
|
||||
throw new Error('valid component projection');
|
||||
}
|
||||
};
|
||||
const degradedProjection = () => {
|
||||
const result = wallBodiesUnionPath(...args(degradedInput));
|
||||
if (!result || result.status !== 'degraded-extra' || result.paths.length !== 2)
|
||||
throw new Error(`degraded projection: ${result?.status || 'null'}`);
|
||||
};
|
||||
const timed = (operation, iterations = 1) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < iterations; index++) operation();
|
||||
return performance.now() - started;
|
||||
};
|
||||
const run = (operation) => {
|
||||
for (let index = 0; index < WARMUPS; index++) operation();
|
||||
return Array.from({ length: SAMPLES }, () => timed(operation));
|
||||
};
|
||||
const runPairs = () => {
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
validGeometry();
|
||||
validProjection();
|
||||
}
|
||||
const baseline = [], candidate = [], overhead = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
let baseMs, candidateMs;
|
||||
if (index % 2 === 0) {
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
} else {
|
||||
candidateMs = timed(validProjection, VALID_BATCH) / VALID_BATCH;
|
||||
baseMs = timed(validGeometry, VALID_BATCH) / VALID_BATCH;
|
||||
}
|
||||
baseline.push(baseMs);
|
||||
candidate.push(candidateMs);
|
||||
overhead.push(candidateMs - baseMs);
|
||||
}
|
||||
return { baseline, candidate, overhead };
|
||||
};
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const summary = (values) => ({
|
||||
min: Math.min(...values), median: quantile(values, 0.5),
|
||||
p95: quantile(values, 0.95), max: Math.max(...values),
|
||||
});
|
||||
|
||||
const pairs = runPairs();
|
||||
const baseline = summary(pairs.baseline);
|
||||
const candidate = summary(pairs.candidate);
|
||||
const degraded = summary(run(degradedProjection));
|
||||
const relativeLimit = baseline.p95 * RELATIVE_RATIO;
|
||||
const overheadP95 = quantile(pairs.overhead, 0.95);
|
||||
const pass = candidate.p95 <= relativeLimit && overheadP95 <= OVERHEAD_P95_MS
|
||||
&& degraded.p95 <= DEGRADED_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 278, fixture: LARGE_HOUSE_COUNTS, warmups: WARMUPS, samples: SAMPLES,
|
||||
validBatch: VALID_BATCH,
|
||||
baseline, candidate, degraded, overheadP95,
|
||||
budgets: {
|
||||
relativeRatio: RELATIVE_RATIO, overheadP95Ms: OVERHEAD_P95_MS,
|
||||
relativeLimitP95Ms: relativeLimit, degradedP95Ms: DEGRADED_P95_MS,
|
||||
},
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -29,32 +29,3 @@ export async function assertFreshDemoBundle(page, root = process.cwd()) {
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Env switch that lets a debugging session run against a stale bundle. */
|
||||
export const ALLOW_STALE_BUNDLE = 'HP_ALLOW_STALE_BUNDLE';
|
||||
|
||||
/**
|
||||
* The freshness gate for every browser check, escape hatch included (#236).
|
||||
*
|
||||
* The smoke launcher had no freshness check at all, while golden runs and
|
||||
* benchmarks did. A smoke against a stale `demo/srv/assets/houseplan-card.js`
|
||||
* does not fail cleanly: on #234 three assertions went red and a fourth went
|
||||
* GREEN, because the old code was wrong in two places that agreed with each
|
||||
* other. A partly-red partly-green result looks like a logic defect and sends
|
||||
* the reader hunting in the wrong file.
|
||||
*
|
||||
* Skipping is allowed for debugging, but never silently: a skipped guard that
|
||||
* says nothing is the same silent success this project keeps digging out.
|
||||
*/
|
||||
export async function assertFreshDemoBundleUnlessAllowed(
|
||||
page, root = process.cwd(), env = process.env,
|
||||
) {
|
||||
if (env[ALLOW_STALE_BUNDLE]) {
|
||||
console.warn(
|
||||
`[houseplan] ${ALLOW_STALE_BUNDLE} is set — bundle freshness NOT verified. `
|
||||
+ 'A red result may mean a stale bundle rather than a defect (#236).',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return assertFreshDemoBundle(page, root);
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Issue #211: human-reviewable Reference SVG <-> Runtime matrix.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { mdiLightbulbSpot } from '@mdi/js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const artifactDir = resolve('artifacts/device-icon-reference');
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
|
||||
const referenceAsset = (theme, file, coreSize) => {
|
||||
let source = readFileSync(resolve('demo/srv/reference/device-icons', theme, file), 'utf8');
|
||||
if (file === 'Lock.svg') {
|
||||
const old = theme === 'Dark' ? '#252525' : 'black';
|
||||
source = source.replaceAll(old, '#66D17A');
|
||||
if (theme === 'Dark') source = source.replaceAll('fill="white"', 'fill="#252525"');
|
||||
}
|
||||
if (file === 'Unlock.svg') {
|
||||
source = source.replaceAll(theme === 'Dark' ? '#1DC21D' : '#F0A00C', '#F0410C');
|
||||
}
|
||||
const nativeWidth = Number(source.match(/<svg[^>]*width="([\d.]+)"/)?.[1] || 127);
|
||||
return {
|
||||
url: `data:image/svg+xml;base64,${Buffer.from(source).toString('base64')}`,
|
||||
displayWidth: nativeWidth * coreSize / 80,
|
||||
};
|
||||
};
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1280, height: 960 }, 1, [], { colorScheme: 'dark' },
|
||||
);
|
||||
|
||||
await page.evaluate((path) => { window.__ICONS['mdi:lightbulb-spot'] = path; }, mdiLightbulbSpot);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const marker = (id, patch) => ({
|
||||
...(c._serverCfg.markers || []).find((item) => item.id === id),
|
||||
id, binding: `device:${id}`, ...patch,
|
||||
});
|
||||
const replacements = new Map([
|
||||
['d_light1', marker('d_light1', { display: 'badge', icon: 'mdi:lightbulb-spot' })],
|
||||
['d_tv', marker('d_tv', { display: 'value' })],
|
||||
['d_temp', marker('d_temp', {
|
||||
display: 'badge',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
|
||||
position: 'right',
|
||||
},
|
||||
})],
|
||||
]);
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []).filter((item) => !replacements.has(item.id)),
|
||||
...replacements.values(),
|
||||
];
|
||||
c.hass = {
|
||||
...c.hass,
|
||||
states: {
|
||||
...c.hass.states,
|
||||
'sensor.living_temp': {
|
||||
...c.hass.states['sensor.living_temp'],
|
||||
state: '23',
|
||||
attributes: { ...c.hass.states['sensor.living_temp']?.attributes, unit_of_measurement: '%' },
|
||||
},
|
||||
'media_player.tv': {
|
||||
...c.hass.states['media_player.tv'],
|
||||
state: 'Working',
|
||||
},
|
||||
},
|
||||
};
|
||||
c._regSignature = '';
|
||||
c._cfgEpoch++;
|
||||
c._maybeRebuildDevices();
|
||||
c._setMode('view');
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const qaStyle = document.createElement('style');
|
||||
qaStyle.textContent = '.devtip{display:none!important}';
|
||||
(c.renderRoot || c.shadowRoot).append(qaStyle);
|
||||
await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)));
|
||||
});
|
||||
|
||||
const selector = (id) => `.dev[data-id="${id}"]`;
|
||||
|
||||
async function runtimePng(theme, row, size) {
|
||||
await page.mouse.move(1, 1);
|
||||
await page.evaluate(({ id, themeName, classes, px, clearValues }) => {
|
||||
const node = (window.__card.renderRoot || window.__card.shadowRoot)
|
||||
.querySelector(`.dev[data-id="${id}"]`);
|
||||
for (const marker of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.dev'))
|
||||
marker.style.visibility = marker === node ? 'visible' : 'hidden';
|
||||
node.classList.remove(...[
|
||||
'theme-light', 'theme-dark', 'on', 'open', 'alarm', 'unavail', 'virtual',
|
||||
'sel', 'lock-locked', 'lock-unlocked',
|
||||
]);
|
||||
node.classList.add(`theme-${themeName}`, ...classes);
|
||||
node.style.setProperty('--device-base-size', `${px}px`);
|
||||
node.style.setProperty('--dev-scale', '1');
|
||||
node.querySelector('.device-core')?.style.setProperty('transition', 'none');
|
||||
node.querySelector('.device-shell-frame')?.style.setProperty('transition', 'none');
|
||||
if (clearValues) node.querySelectorAll('.value-badge').forEach((value) => value.remove());
|
||||
node.blur();
|
||||
}, {
|
||||
id: row.id,
|
||||
themeName: theme.toLowerCase(),
|
||||
classes: row.classes || [],
|
||||
px: size,
|
||||
clearValues: row.clearValues || false,
|
||||
});
|
||||
if (row.hover) {
|
||||
await page.hover(selector(row.id));
|
||||
await page.waitForTimeout(180);
|
||||
}
|
||||
if (row.focus) {
|
||||
await page.$eval(selector(row.id), (node) => node.focus());
|
||||
}
|
||||
await page.$eval(selector(row.id), (node) => {
|
||||
for (const tooltip of (window.__card.renderRoot || window.__card.shadowRoot).querySelectorAll('.devtip'))
|
||||
tooltip.style.setProperty('display', 'none', 'important');
|
||||
node.querySelector('.lqi')?.style.setProperty('display', 'none');
|
||||
});
|
||||
const clip = await page.$eval(selector(row.id), (node) => {
|
||||
const shell = node.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const pad = 22;
|
||||
return {
|
||||
x: Math.max(0, shell.left - pad),
|
||||
y: Math.max(0, shell.top - pad),
|
||||
width: shell.width + pad * 2,
|
||||
height: shell.height + pad * 2,
|
||||
};
|
||||
});
|
||||
return (await page.screenshot({ clip })).toString('base64');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
{ label: 'Default', file: 'Icon Default.svg', id: 'd_light1' },
|
||||
{ label: 'Hover', file: 'Icon Hover.svg', id: 'd_light1', hover: true },
|
||||
{ label: 'Active', file: 'Icon Active.svg', id: 'd_light1', classes: ['on'] },
|
||||
{ label: 'Lock', file: 'Lock.svg', id: 'd_lock', classes: ['lock-locked'] },
|
||||
{ label: 'Unlock', file: 'Unlock.svg', id: 'd_lock', classes: ['lock-unlocked'] },
|
||||
{ label: 'Selected', file: 'Selected.svg', id: 'd_light1', classes: ['sel'] },
|
||||
{ label: 'Focus', file: 'Focus Visible.svg', id: 'd_light1', focus: true },
|
||||
{ label: 'Alert', file: 'Alert Value.svg', id: 'd_temp', classes: ['alarm'] },
|
||||
{ label: 'Virtual', file: 'Virtual Device Default.svg', id: 'd_motion', classes: ['virtual'] },
|
||||
{ label: 'Unavailable', file: 'Unavailable.svg', id: 'd_light1', classes: ['unavail'] },
|
||||
{ label: 'Text', file: 'Text Default.svg', id: 'd_tv' },
|
||||
{ label: 'Double Right', file: 'Double Default Right.svg', id: 'd_temp' },
|
||||
];
|
||||
|
||||
const matrix = [];
|
||||
for (const theme of ['Light', 'Dark']) {
|
||||
for (const row of rows) {
|
||||
matrix.push({
|
||||
theme,
|
||||
row,
|
||||
size: 56,
|
||||
runtime: await runtimePng(theme, row, 56),
|
||||
});
|
||||
}
|
||||
for (const size of [32, 96]) {
|
||||
const row = rows[0];
|
||||
matrix.push({ theme, row, size, runtime: await runtimePng(theme, row, size) });
|
||||
}
|
||||
const textRow = rows.find((row) => row.label === 'Text');
|
||||
matrix.push({ theme, row: textRow, size: 96, runtime: await runtimePng(theme, textRow, 96) });
|
||||
}
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
const body = matrix.map(({ theme, row, size, runtime }) => {
|
||||
const reference = referenceAsset(theme, row.file, size);
|
||||
return `
|
||||
<tr>
|
||||
<td>${theme}</td><td>${escapeHtml(row.label)}</td><td>${size}px</td>
|
||||
<td class="preview"><img style="width:${reference.displayWidth}px" src="${reference.url}" alt="Reference ${escapeHtml(row.label)}"></td>
|
||||
<td class="preview runtime"><img src="data:image/png;base64,${runtime}" alt="Runtime ${escapeHtml(row.label)}"></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
const html = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Device icon reference/runtime matrix</title>
|
||||
<style>
|
||||
body{margin:24px;background:#777;color:#111;font:16px system-ui,sans-serif}
|
||||
h1,p{max-width:1100px} table{border-collapse:collapse;width:100%;background:#aaa}
|
||||
th,td{border:1px solid #555;padding:8px;text-align:left} th{position:sticky;top:0;background:#ddd;z-index:2}
|
||||
.preview{width:38%;text-align:center;background:linear-gradient(135deg,#d5d5d5 50%,#666 50%)}
|
||||
.preview img{display:block;margin:auto;max-width:300px;max-height:180px}.runtime img{image-rendering:auto}
|
||||
</style></head><body>
|
||||
<h1>House Plan device icons: package 1.1.1 vs runtime</h1>
|
||||
<p>Issues #211/#217. Reference SVG is loaded directly from the designer package; Runtime is a fresh browser capture. Default covers 32/56/96 px and Text has an additional large 96 px row so its outer stadium curvature is reviewable. Dark Unlock is evaluated using the owner's amber override from #179.</p>
|
||||
<table><thead><tr><th>Theme</th><th>State/layout</th><th>Core</th><th>Reference SVG</th><th>Runtime</th></tr></thead>
|
||||
<tbody>${body}</tbody></table></body></html>`;
|
||||
const htmlPath = resolve(artifactDir, 'device-icons-reference-runtime.html');
|
||||
writeFileSync(htmlPath, html);
|
||||
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await page.screenshot({
|
||||
path: resolve(artifactDir, 'device-icons-reference-runtime.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await browser.close();
|
||||
console.log(`OK device icon reference/runtime matrix: ${htmlPath}`);
|
||||
@@ -1,85 +0,0 @@
|
||||
/** Local-only visual proof for #275; external backup contents are never printed. */
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const input = process.argv[2];
|
||||
const outputDir = resolve(process.argv[3] || '.');
|
||||
const mode = process.argv.includes('--optimized') ? 'optimized' : 'raw';
|
||||
const nodesArg = process.argv.find((value) => value.startsWith('--nodes='))?.slice(8) || '';
|
||||
const nodes = nodesArg.split(';').filter(Boolean).map((pair) => pair.split(',').map(Number));
|
||||
if (!input) {
|
||||
console.error('usage: node demo/capture_wall_strip_backup.mjs <backup> <outdir> '
|
||||
+ '[--optimized] [--nodes=x,y;x,y]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const backup = JSON.parse(readFileSync(input, 'utf8'));
|
||||
const payload = backup?.payload && typeof backup.payload === 'object' ? backup.payload : backup;
|
||||
const source = {
|
||||
config: payload?.config && typeof payload.config === 'object' ? payload.config : payload,
|
||||
layout: payload?.layout && typeof payload.layout === 'object' ? payload.layout : {},
|
||||
};
|
||||
const rendered = mode === 'optimized'
|
||||
? optimizePlans(source.config, source.layout)
|
||||
: source;
|
||||
const config = rendered.config;
|
||||
const layout = rendered.layout;
|
||||
if (!Array.isArray(config?.spaces) || !config.spaces.length) {
|
||||
throw new Error('backup has no spaces');
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const { page, browser } = await launch({ width: 1800, height: 1250 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async ({ cfg, lay }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = structuredClone(lay);
|
||||
card._space = cfg.spaces[0].id;
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card._isoGeometryCache.clear();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._fitAll();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, { cfg: config, lay: layout });
|
||||
|
||||
const fullPath = resolve(outputDir, `${mode}-full.png`);
|
||||
await page.screenshot({ path: fullPath, animations: 'disabled' });
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const center = await page.evaluate(([x, y]) => {
|
||||
const card = window.__card;
|
||||
const svg = (card.shadowRoot || card.renderRoot).querySelector('.stage svg');
|
||||
const matrix = svg?.getScreenCTM?.();
|
||||
if (!matrix) return null;
|
||||
const point = new DOMPoint(x * 1000, y * 1000).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
}, nodes[index]);
|
||||
if (!center) continue;
|
||||
const width = 420, height = 360;
|
||||
const clip = {
|
||||
x: Math.max(0, Math.min(1800 - width, center.x - width / 2)),
|
||||
y: Math.max(0, Math.min(1250 - height, center.y - height / 2)),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
await page.screenshot({
|
||||
path: resolve(outputDir, `${mode}-node-${index + 1}.png`),
|
||||
clip,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({ mode, full: fullPath, crops: nodes.length }));
|
||||
@@ -1,158 +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 { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
import { launch } from '../serve.mjs';
|
||||
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
|
||||
|
||||
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');
|
||||
|
||||
|
||||
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 {
|
||||
// Свежесть бандла проверяется строго, вместе с версией: картинки обязаны
|
||||
// приехать из бандла, собранного из ЭТОГО дерева. А в манифест пишется
|
||||
// версионно-нечувствительный отпечаток (#245) — номер версии на скриншотах
|
||||
// не виден, и требовать из-за него пересъёмки нечестно.
|
||||
await assertFreshDemoBundle(page, ROOT);
|
||||
const fingerprint = visualFingerprint(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',
|
||||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||||
chromium: browser.version(),
|
||||
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();
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Каталог сценариев съёмки документации. Отдельным модулем, потому что его
|
||||
* читают трое: сам капчур, `scripts/check-docs.mjs` и приёмка артефакта
|
||||
* `scripts/docs-accept.mjs` (#246). Импортировать его из `capture.mjs` нельзя —
|
||||
* тот скрипт при импорте поднимает браузер и снимает картинки.
|
||||
*/
|
||||
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,
|
||||
},
|
||||
]);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
* visual-regression tooling. Nothing here depends on a real HA installation.
|
||||
*/
|
||||
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
|
||||
const FLOOR_COUNT = 3;
|
||||
const ROOMS_PER_FLOOR = 20;
|
||||
const DEVICE_COUNT = 200;
|
||||
@@ -215,14 +213,8 @@ export const makeLargeHouseFixture = () => {
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'glow', show_borders: true, show_names: true },
|
||||
rooms,
|
||||
// The key must be the real one. A label like `perf-wall-0-3` does not
|
||||
// parse as coordinates, so neither the exact match nor the tolerant
|
||||
// fallback in lookupWall finds the record: every solid edge of this
|
||||
// fixture resolved to zero thickness and the plan carried no wall bodies
|
||||
// at all, while the same fixture backs four golden scenes and all six
|
||||
// performance budgets (#260).
|
||||
walls: segments.map((wall) => ({
|
||||
key: fixtureWallKey(wall.a, wall.b), cm: 15, a: wall.a, b: wall.b,
|
||||
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),
|
||||
|
||||
@@ -4,13 +4,22 @@ 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 — and a
|
||||
// key that merely differs in precision is found only through the tolerant
|
||||
// fallback in lookupWall, which is luck rather than contract (#260). The
|
||||
// formula lives in one place for every fixture and is pinned to the product one
|
||||
// by test/fixture-wall-key.test.mjs.
|
||||
export { fixtureWallKey } from './wall-key.mjs';
|
||||
import { fixtureWallKey } from './wall-key.mjs';
|
||||
// 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();
|
||||
@@ -48,11 +57,6 @@ const lightingRooms = [
|
||||
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',
|
||||
@@ -118,25 +122,7 @@ const lightingSpace = {
|
||||
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 runtime = () => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
@@ -150,8 +136,7 @@ const runtime = (includeAppliance = false) => {
|
||||
// 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 }]),
|
||||
[...geometryRooms, ...lightingRooms].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('-', '_')}`;
|
||||
@@ -186,44 +171,6 @@ const runtime = (includeAppliance = false) => {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -235,12 +182,9 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
config: {
|
||||
spaces: [
|
||||
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
||||
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
|
||||
],
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
// 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.
|
||||
@@ -257,10 +201,6 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) =>
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(applianceLifecycle),
|
||||
counts: applianceLifecycle ? {
|
||||
...VISUAL_MATRIX_COUNTS,
|
||||
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
|
||||
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
|
||||
} : VISUAL_MATRIX_COUNTS,
|
||||
...runtime(),
|
||||
counts: VISUAL_MATRIX_COUNTS,
|
||||
});
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Ключ записи толщины стены — один на все фикстуры проекта.
|
||||
*
|
||||
* Копия формулы из `src/wall-thickness.ts`, и копия здесь неизбежна. Фикстуры
|
||||
* обязаны оставаться без внешних импортов: бэкенд-гейт запускает их как
|
||||
* `node --input-type=module --eval "import * as f from './demo/fixtures/…'"`
|
||||
* в job без `npm ci` и без `test-build/` (`.github/workflows/validate.yml`,
|
||||
* job `backend`), а `scripts/source-fingerprint.mjs` хеширует только `src/**`
|
||||
* и `.mjs` из `demo/fixtures` и `demo/golden` — код, втянутый из `scripts/`,
|
||||
* менял бы поведение фикстуры при неизменном отпечатке, на котором стоят и
|
||||
* валидность golden-эталонов, и переиспользование гейтов.
|
||||
*
|
||||
* Поэтому файл лежит ЗДЕСЬ, внутри `demo/fixtures`: так он попадает в
|
||||
* отпечаток, и так его видит одна привязка вместо трёх копий формулы.
|
||||
* `test/fixture-wall-key.test.mjs` сверяет его с продуктовым `wallKey`.
|
||||
*
|
||||
* Ловушка, из-за которой этот файл и появился (#260): точность зависит от шага.
|
||||
* При `pitch = 1/240` продукт печатает ШЕСТЬ знаков, а не четыре — фикстура с
|
||||
* четырьмя расходилась с продуктом на каждой записи и находилась только через
|
||||
* терпимый запас `lookupWall`. Метка вместо ключа (`perf-wall-0-3`) не
|
||||
* находилась вовсе: все сплошные рёбра оставались с нулевой толщиной.
|
||||
*/
|
||||
|
||||
/** Шаг решётки редактора в нормализованных координатах (`GRID_N = 240`). */
|
||||
export const WALL_KEY_PITCH = 1 / 240;
|
||||
|
||||
/** Направление стены по модулю 180°: стена одна и та же с любого конца. */
|
||||
const direction = (a, b) => {
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-12) return [1, 0];
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
|
||||
return [dx, dy];
|
||||
};
|
||||
|
||||
/**
|
||||
* Координата, отличающаяся от узла решётки не больше точности хранения, — это
|
||||
* тот же узел (#258). Канонизация опознания, а не снап геометрии: произвольная
|
||||
* точка вне решётки остаётся вне решётки. Без этого шага ничья округления на
|
||||
* стене нечётной длины в шагах разводила один и тот же ключ на два.
|
||||
*/
|
||||
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
|
||||
const canonical = (value, pitch) => {
|
||||
if (!(pitch > 0) || !Number.isFinite(value)) return value;
|
||||
const snapped = Math.round(value / pitch) * pitch;
|
||||
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
|
||||
};
|
||||
|
||||
export const fixtureWallKey = (a, b, pitch = WALL_KEY_PITCH) => {
|
||||
const quantise = (value) => (pitch > 0 && Number.isFinite(value)
|
||||
? Math.round(value / pitch) * pitch : value);
|
||||
const ca = [canonical(a[0], pitch), canonical(a[1], pitch)];
|
||||
const cb = [canonical(b[0], pitch), canonical(b[1], pitch)];
|
||||
const mx = quantise((ca[0] + cb[0]) / 2);
|
||||
const my = quantise((ca[1] + cb[1]) / 2);
|
||||
const [dx, dy] = direction(ca, cb);
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
const bucket = Math.round(angle * 1800) / 1800;
|
||||
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
|
||||
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
|
||||
};
|
||||
@@ -35,7 +35,7 @@ Build and copy the exact current source first:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run bundle:sync
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
npm run golden:capture
|
||||
```
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,138 +1,59 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 46,
|
||||
"acceptedAt": "2026-08-25T20:12:50.448Z",
|
||||
"sourceFingerprint": "e28606975682347f90f12971053825ce474085cbddd56082e867370b1039d14d",
|
||||
"matrixVersion": 18,
|
||||
"acceptedAt": "2026-08-13T19:26:19.387Z",
|
||||
"sourceFingerprint": "27fda3d75e4cda95b9d85a9481f15ccd44b96d8c9d3311e0476718d36e2588c5",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
|
||||
"hidden-wall-diagnostics-plan-dark": "da6e725ddfad7c807408a710b78090dc222b56dcee484a29baa68b5452fc44e5",
|
||||
"coincident-partition-before-dark": "19626db704fccb7a42b519ffb20b7bad82c726e8845c752ab451f731d264561c",
|
||||
"coincident-partition-thin-dark": "3afede1f99b86454bf4e50d9628196a2240243e034644592c5c3efa1b4561930",
|
||||
"coincident-partition-thick-dark": "57960bd09e6cbbeb073561692690ca68a72d6dbf8242198ef73b8d4ae4d8fbcc",
|
||||
"coincident-partition-virtual-dark": "da7adbf931ad95f4e620d9d088177f1da20d88a21b9e85b6ff2675925d5557b4",
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "17e49394c300887bb5349bf477292571e96e209c9515db6853348883d95a1422",
|
||||
"split-corner-wall-thick-dark": "f7864a6b2ab58586540f836183c9e59f09f9f57165ec0e0b9cd8444ada71153c",
|
||||
"split-zero-divider-taper-dark": "3de38befb41f15ef4047da1390e061b1b5756340142dd9204d011287ff39be5b",
|
||||
"isometric-geometry-view-dark": "43ad006844a78b502b36aeeb82d82669532e06d2c798de928a5207acd24bd5f9",
|
||||
"isometric-geometry-view-light": "0fcb10ffdc0baa9006d1c2f0406ebf19e71ba9feda180979a51b74166d7200d2",
|
||||
"isometric-live-layers-dark": "8aaa914fc45780f67dd5c008a106696d21235a9acee7402f9cc8ae51a26660f3",
|
||||
"isometric-no-borders-dark": "c105382d883bbdae8562b756ccbf47ac1fcf46675c6aa7654289856223a3ebf0",
|
||||
"isometric-touch-kiosk-dark": "be122ceec8114374ba384f51d5d9287e498a97354865c5a9f91d505ca18d8640",
|
||||
"isometric-large-warm-remount-dark": "d76e3334bf80bff265b1e8aa6770c5eff5df6fbfebeb4703b04d048c4252eb68",
|
||||
"geometry-view-dark-fit": "1b80d2d0fe84cfe15f1fa29ccc2a2dcc66b4c5e62d1722c06c00442c78979be0",
|
||||
"geometry-view-light-fit": "91caf6621af00eb0cd16589e2fc79976cdca1e8467d0e86151ecbc46948612d9",
|
||||
"room-label-parity-view-dark": "7f1da43466a13f82a7595dddabe6938ed974debfb14c8f40e729e273a53ac4ff",
|
||||
"room-label-parity-plan-dark": "5f976d5743d68a27b594e1a5ddf45e5ad3708f5c703409e5cefb7c96d3048096",
|
||||
"room-label-parity-view-light": "ee3d303fb2b329c356a4354d355c29694130a38d4812ffbfefbe861f5d3f9860",
|
||||
"room-label-parity-plan-light": "d32b538499a031d4310b243cc9ef43610f6cd67aabfde7cd01b59c88b0363ec5",
|
||||
"washer-active-cycle-dark": "d74a646bc71b8d6d0d5b09e6862b4846b0e369da2520afa76d5b3d601c832a15",
|
||||
"washer-idle-cycle-dark": "f7fae5c2f6856a3df5bd3f3028fcf829ef0039c7b43c91c27ac86d25626e8d66",
|
||||
"day-cycle-dawn-dark": "3f32bc1ed042475516f7e239aedd7fee11717b00fa2103ae9f1231e006d64015",
|
||||
"day-cycle-day-dark": "1a0f53c948ae669c7ab4e1dcb931c11e7c878974862637dd1f596011876a7ed4",
|
||||
"day-cycle-dusk-dark": "ec95e99ac3c7e4d6affa2f7944759727251dc4f1b62e2855e421a7e48f7280eb",
|
||||
"day-cycle-night-dark": "cc49dc53dabb63b7dd800eaa65c8748972fcf3c1abd948b34d1e0f973e63f0d2",
|
||||
"geometry-plan-editor-dark": "fd8d067daa3b2a4e572dea9644486e6324318e8ea4e5f43e3df2069a05389a88",
|
||||
"safe-resize-handles-clamp-light": "ed705040357cad043095baf12d02cc69abdc3db27ff40066a6979be85d035f77",
|
||||
"safe-resize-handles-clamp-dark": "9d81a45f3acac787cde1b9eb74a858020fbad09670b8ff5b081869b5eca73b82",
|
||||
"space-tab-drop-before-light": "401dded3d7fdff3e823d46eac1b3cef2d6e0eb05f48b5ea289cdd8ecd941dee0",
|
||||
"space-tab-drop-after-dark": "a851621040e14f911b1080488f2c75e3d9bc0d5fef74fb5d0081f80b023ff0a2",
|
||||
"plan-snap-endpoint-light": "db967076cd9fd50c2a2a1337e4ecbf4ef67cb7f800401f6227497c75415951d5",
|
||||
"plan-snap-line-gaps-dark": "ff1b6bbf2e1b4272bb2f0e96d92136aa6960ee24acef1b63b4478e54441a4b58",
|
||||
"wall-junctions-plan-preview-light": "fa81fa6c6dba92578a4beee5ed29f407443a3bc61affa25c44fcfefab0c7070c",
|
||||
"wall-junctions-plan-t-dark": "f8a517d316a99ca9ee5b4ff56f515be15d440adfc551bbdaaa5e9d9a411a79bc",
|
||||
"wall-junctions-view-dark": "7b859c4f25f8a5dd4fca64d5b2f7aa64b65845fabbe83bc2388c2f99d29f71b1",
|
||||
"junction-patch-resilience-plan-dark": "57ea7522c438e52292f226f975bb112930976b6e072b72322180d52ec0277f4f",
|
||||
"junction-patch-resilience-view-dark": "1f0df26ed72bcc5bef06a8366d7043ad40b4a2bb744fa5a64248c29eae968424",
|
||||
"wall-union-isolation-view-light": "291c045758be2814be88d12ab46bdc3d12cb99a03dbbcc9a1539c2c9a5931edf",
|
||||
"wall-union-isolation-view-dark": "616fc6cc7144c9596ae52421027914e222f30f26de45215fa7e7b6175ca78543",
|
||||
"multiwall-junction-bevel-view-dark": "e226d023fbab0502eb747395d43a46bb56bcf2be037ba4f9b7ad74ba406d9671",
|
||||
"orthogonal-strip-cell-5-view-dark": "9b8e4bd22cbf1a82ff48f052822967c61179290a3234ae48ef547642329b5a90",
|
||||
"orthogonal-strip-cell-1-view-dark": "e59e51ad3645144161459c438a07420730c4dcfd2c33bd2b18e8c3e57ea84515",
|
||||
"wall-key-roundtrip-view-dark": "df46b4e209f98b97958748097ddc646ddc64d8285c2651762de71ec2456ac690",
|
||||
"isometric-wall-junctions-dark": "d1956cbbde9a6a02953ce80eb8a0f74ca06ea268a4c60bb09b5b3981c26d9731",
|
||||
"opening-placement-door-thick-wall-dark": "1157139aac7325b40e3869b8c3b5444509b4bc24a415ddf6f8d5402624f3a04a",
|
||||
"opening-placement-passage-thick-wall-dark": "6d4a9a953b70401ca11cd6ce96c2e0e0781bbfff71e758dc57958a4163e2712d",
|
||||
"opening-placement-passage-thick-wall-light": "b004b1395652c8a8eef5f5002b471ecf8f2f0347f158a14a056ff2b7382e4ea3",
|
||||
"opening-symbol-room-wall-light": "562fed297210ef542de608e384c2d344dddd193a5b43615930c9f76faaf53b42",
|
||||
"opening-symbol-diagonal-partition-dark": "620102c00e77613cb341c906829bfcc7e9a5430075aa576f563adafa8bdd8f08",
|
||||
"opening-symbol-flip-pairs-light": "33364f51fb65e49bfdd6c41d6e0c53542c89fadc45a3c6e5adc590144c4676ea",
|
||||
"isometric-opening-symbol-parity-dark": "302daf5d90d4af5ef90036cc10e957e6fc909ea56f9f2af938ab850d4f608e34",
|
||||
"geometry-devices-editor-dark": "4b91bdc00082e33d2529ea3cf7ce7af36383a84838f4a2c909463df24483b47b",
|
||||
"geometry-decor-editor-dark": "8b03bc87d2353cb6ef8fb4e2dec3f0d3be85de903ac9346171ea4aaa31170a66",
|
||||
"tray-wide-selection-en": "d140cf6a51c634a38d2e3661c4b61835d63673174fd934b85f9da0db4fa20d1a",
|
||||
"tray-wide-tool-ru": "806ff7c5ae3d2e708a4682833af7dc28a88001ef09e4ca564c91a5e6023b297d",
|
||||
"tray-medium-group-en": "5a1adbaa64d2b73f294003e2752a79008f7ec313ab41dd0514b6b38918b8c00e",
|
||||
"tray-medium-selection-ru": "53c7ea5099772fd14c2e27ad414c4d57d87c020866992b7a0b0dd62a22fbeb7e",
|
||||
"tray-narrow-palette-en": "5bc029bb7a4b077a45abc0c18f54360f781caa3684befb63cfa714efcfaf7edd",
|
||||
"tray-narrow-tool-ru": "32e27435ec9f0ce6db2e2800ffd71b6a5fcb1ab7dde17454a3ba7dda680b8a45",
|
||||
"geometry-diagonal-45-opening-dark": "a0167f3028cd8ef8283c92ae7e803940cf0eb0db45f1b137108a4e009741a5df",
|
||||
"openings-thick-wall-dark": "c45bae26ec0b4f719f72e5b92e3b8e03260d7bda4b3182d4c563b50f656e68a1",
|
||||
"openings-filled-tunnel-dark": "b916d3ce48bba7487ed4b548bf659b610243ebcde07206c21fff65867792d0d1",
|
||||
"decor-over-opaque-hover-light": "553d279db44aa0a4a6855496036b0f6ddda9570cef133e09a30f29f41b7d0c31",
|
||||
"decor-over-glow-base-dark": "8e5b8630798e31936f8e7dd52d1cba73a3f80fd3d3217a5ad59d50b5dc12a26d",
|
||||
"openings-hidden-view-dark": "d99f365376e6ece668060181ce8c3a5ebdb93ec5a8c64e08b0aef5af592c6874",
|
||||
"lighting-glow-sun-dark": "8599aea80a0479442f891adb30aecd647ddf217e28e3f0da5c21b41e21d746ec",
|
||||
"device-value-badge-positions-dark": "ae163e96cd976e006eacd09ebc75440d600e81c063e38da27d53000e4e51c68d",
|
||||
"device-icon-state-table-light": "969879fc7baeb193639a03701eb8bacc9469f6160b8d41a318db8da83590e51d",
|
||||
"device-icon-state-table-dark": "045145a87ccde3894334009c6b9edd690654fd048c55b770e09d5ed79f9a7351",
|
||||
"device-text-shell-long-light": "a96d16ac0a02ed322bab949e34ff7ce59da5f5db42e02185fac5fde495591bb1",
|
||||
"device-text-shell-long-dark": "f301f3aa1e1eb382018c4329db2909a8d2990f5f917bde83e3de46ff4dc7416c",
|
||||
"lighting-sun-window-state-only-dark": "4da798c4de6d1303d17be246e9a2e63b12da3f53a42136cf745d1a92ad5a4f21",
|
||||
"lighting-fill-light-axis-split-dark": "6b3a4dd637c6fe7e43a131c44c4be232bd81edab417ea9af3860d82c506ad45f",
|
||||
"lighting-fill-temp-axis-split-dark": "6025c6efd3ea3565c13e147db550b017eeb86da0be5dfc82a8b5bd7e623d5852",
|
||||
"lighting-fill-lqi-axis-split-dark": "1ec5c18572fd5be471088286c4aa7c67c3885cb907080fcde44c3583ff794275",
|
||||
"lighting-temp-glow-dark": "274bcd5a19ebbcb63fb64a5d21f838122f1916d428defcaa3c0356d8ea9809a5",
|
||||
"lighting-temp-glow-light": "decc386073a1e93d8d457ec09daf83e8afc180977f5880ce56cc4834a2c203f0",
|
||||
"lighting-custom-glow-dark": "32607e6001c4c8cd541cabf82142f3cbf7f2b298900a02f30c6f44ececbd5907",
|
||||
"lighting-opaque-glow-two-doorways-dark": "bd9f49432c69c8ed622810e96f4708974e2024c58700b0cfa3e5ebd2edd7bc7b",
|
||||
"lighting-custom-glow-light": "973ae32bb3bacbfe633041e89352a9ef1a2222b89a4792feef45c26bfa541459",
|
||||
"lighting-temp-glow-no-sources-dark": "e19b7d86d5dae54f9afc0a1ef04a3848ebec283a5f60e68efdcc7380dff3b0a7",
|
||||
"lighting-temp-glow-room-override-dark": "bebfc27f460cb75e32ab14076a01e3b9729763d7cecd3f457c1b696ed3e57da3",
|
||||
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
|
||||
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
|
||||
"hover-nested-room-dark": "f05ba332a127b56c00a6a94eac727b168ed34cc3f0355f8b783827f7bf55714c",
|
||||
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
|
||||
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
|
||||
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
|
||||
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
|
||||
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
|
||||
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
|
||||
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
|
||||
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
|
||||
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
|
||||
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
|
||||
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
|
||||
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
|
||||
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
|
||||
"junction-owner-repro-dark": "23ae216c2637b8ee9ea8f852002693f74d64021a66d4494e2086a53db8ca6520",
|
||||
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
|
||||
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
|
||||
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
|
||||
"device-dialog-desktop-en": "9739886af3104c2fe3d6d3138293e9fd1a7f480f151decc26f12bb64cab69543",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "f4924466de5e134c2d1f2456ff0c05f8aa8106f6b06398e87a263e3c536f16ea",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "add3275d74ced358a184ec9f864c83735f1196e2bff7d75fb3e12160cb85bfaf",
|
||||
"decor-color-popover-mobile-ru": "51725859afc6a1e2d1b2ae8be270ab584608901623ef7e2d283b6b195644fd16",
|
||||
"decor-color-popover-desktop-en": "8ad229fd3d433f969dc6d09c7677e9949d4f62503196bffbf17030386f7c7511",
|
||||
"general-color-popover-desktop-en": "91c3b7ebae5146c62d172d1ce10ae9e375ed955f23e9dc860976373a334d8be5",
|
||||
"device-ripple-color-popover-mobile-ru": "3ab12c339106004c76fe3c7741d242269e157c40ddfa29afd8f13b445b0a7023",
|
||||
"space-room-color-popover-desktop-ru": "d66d068a1b403f88a5bd3c57f4a9e55c336767c533ddfe3ae251cc4f9da40a74",
|
||||
"backup-full-preview-desktop-en": "c9fe8ca1cf63b9ed98e2d517a2c06639d044d460011d958573e70c6b22bd7a2f",
|
||||
"backup-plan-only-export-desktop-en": "c48b3ccdd7d85d36f132c2d7b4c5dba94a1027e3e3f2ef495b4eb89a23f8ca33",
|
||||
"backup-space-preview-mobile-ru": "82cd93cb495ad77b92d635b4a3715601b1ae0e5e2974edcbbd461fe872c75bad",
|
||||
"optimize-preflight-dialog-dark-en": "6e0706b951db73c7d699173d9cd0cd3cd8857238e3d7e95bc38eeb3691f907b6",
|
||||
"optimize-preflight-dialog-light-ru": "a96af4b4428297d2c672f0d0e8f376e81c27765ecc941781a2d3873a054e2ec3",
|
||||
"optimize-orphan-references-dark-en": "cdf4eb8c79a9961a1e961a4eed4107d4d849ebc5b922ad88ce07befee6b65a9a",
|
||||
"optimize-orphan-references-light-ru": "c07151ac896e43bef6150128cbef8a5798ee113083961ed8eaf97113a0b56e1e",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
|
||||
"junction-309-step-dark": "f5e0e649ee19197026b87687fd3512b18536883f071348589ec3a7f0ea2a216f",
|
||||
"junction-309-spike-dark": "98e709aa2fa58a01fe280635c6a3799e3a5f4158b2ada12c31c38818f92c4f44",
|
||||
"junction-309-hump-dark": "abae96fd96ed8515d7b1e33b84858bdf08ac28a888898549874baa8c98202e7a"
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"isometric-geometry-view-dark": "73939fa61bebe2254591a788fad455a026a5e3b7394eeff27d4fe9a905de7ad2",
|
||||
"isometric-geometry-view-light": "fc62f6ee8935d2e0c57b4918bf5fe554056a5956747b53a752ae265349ce662e",
|
||||
"isometric-live-layers-dark": "869c62bf9cd762c36d342d1a3bbd992969425b46b132f3243439735e2c75c14f",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "5eba7794e563e1ef5b9387184693c819976454d0efd222bd12b38aad8ea03e20",
|
||||
"isometric-large-warm-remount-dark": "798d312671dffebf59034a39f2865a65ece27a84b150e77bc59038bb2567d07c",
|
||||
"geometry-view-dark-fit": "3df272f6c3c3d20e9e375ea037f3dbb885657b94b0b29d0067505f4a73741237",
|
||||
"geometry-view-light-fit": "a7f2c9667d9872dd84a37d5318fd238c9eabcc0f413017eb19e02204587b4e1a",
|
||||
"geometry-plan-editor-dark": "19b5c84943b70074ba1aae51d1e54f9a59c44dde34fff89e32f0babb1e90a2ca",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "024ac666ac4dac01a36f7d1fdbf3bb41f76a627377f7c02dc373f429dfe96d43",
|
||||
"tray-wide-tool-ru": "669fc1cf04433b936c0e9d05799ad19eda8bc003d5ab71937d0512c8f49ba921",
|
||||
"tray-medium-group-en": "50cd3980b21f554bec2e76f4d2e8032f4bd30c0679477eb2f5cbe22f68935b7c",
|
||||
"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": "1957c1c797c7793fb8dcf592ca74c9f2e3eccfcf4bac1d0187482c055bebb93b",
|
||||
"backup-space-preview-mobile-ru": "998c6b52c1cc95109feb440a9966cade738154ceeae387246d40f8c56f9e8a3a"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 348 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 31 KiB |