Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb510290a | ||
|
|
932773773f | ||
|
|
6c7958c6d0 | ||
|
|
a449edc545 | ||
|
|
e88c23b8ee | ||
|
|
acad3b32c1 | ||
|
|
b443a333e0 | ||
|
|
66fa8f476c | ||
|
|
047363c2d3 | ||
|
|
1e8503bd46 | ||
|
|
135497b272 | ||
|
|
0c2a5dedea | ||
|
|
c1676cf26a | ||
|
|
c65cbcc96c | ||
|
|
01fe48de00 | ||
|
|
9baf533c90 | ||
|
|
563a850aac | ||
|
|
9e5ff0b8a0 | ||
|
|
3fe0f8c443 | ||
|
|
742b3279a2 | ||
|
|
9f77e3e932 | ||
|
|
083621342a | ||
|
|
4f20befd77 | ||
|
|
2aaabc48d6 | ||
|
|
b9bf210804 | ||
|
|
d6007dc444 | ||
|
|
54c5ca3840 | ||
|
|
57fc434d4f | ||
|
|
9530ee2e5a | ||
|
|
58ba15ac89 | ||
|
|
f46be0e0e2 | ||
|
|
8086399aa6 | ||
|
|
034eb3f5f3 | ||
|
|
76f75f85aa | ||
|
|
7f1655c7f5 | ||
|
|
e46ef6f55c | ||
|
|
63eea47ac6 | ||
|
|
f54b9c0ccd | ||
|
|
aac2978359 | ||
|
|
fc1e7951e4 | ||
|
|
e206e8761c | ||
|
|
8a25b5224f | ||
|
|
1f11f8f410 | ||
|
|
f4b1a4766f | ||
|
|
4fda569f74 | ||
|
|
393ec62c61 | ||
|
|
dae2efc280 | ||
|
|
e727023bf4 | ||
|
|
690f57aaba | ||
|
|
c4ba218ee8 | ||
|
|
df093c7e51 | ||
|
|
a9d999e2f2 | ||
|
|
6ca9aacebc | ||
|
|
ae7fb621e7 | ||
|
|
4d71f57b4f | ||
|
|
0ee69ccb20 | ||
|
|
6cbf9fbdf0 | ||
|
|
fd0b52f3a9 | ||
|
|
2667f71bd6 | ||
|
|
c27185cfa4 | ||
|
|
a05aa5dc06 | ||
|
|
93a29cd79e | ||
|
|
260a994f5f | ||
|
|
c9a00b2a37 | ||
|
|
4a8f44210f | ||
|
|
91c8b01d7d | ||
|
|
bc478b1756 | ||
|
|
135a18a3ca | ||
|
|
c23b2d7a90 | ||
|
|
61978d4f1e | ||
|
|
141c657813 | ||
|
|
0040615a45 | ||
|
|
ff9f20f52e | ||
|
|
a1b8861eff | ||
|
|
2e47473619 | ||
|
|
b369eb5dcd | ||
|
|
7f397a6875 | ||
|
|
bf2937c1ed | ||
|
|
8c6ac30300 | ||
|
|
6df4722438 | ||
|
|
bdae05dafc | ||
|
|
4ed86b38bc | ||
|
|
9c0e9fab68 | ||
|
|
154c662a0b | ||
|
|
6beb40435f | ||
|
|
e8a5771a63 | ||
|
|
f7c8609ac5 | ||
|
|
8ce7d1f0bb | ||
|
|
d8e3b82da6 | ||
|
|
18f5155bbf | ||
|
|
b94b1b93cd | ||
|
|
82cf3ad2db | ||
|
|
0253c4765c | ||
|
|
88a647f6b2 | ||
|
|
053e2a9b68 | ||
|
|
91f460e80c | ||
|
|
32518c6284 | ||
|
|
9f2c5f5ff4 | ||
|
|
20d7883699 | ||
|
|
6ebf12af1e | ||
|
|
09143e23a6 | ||
|
|
0e6cb7570b | ||
|
|
321d153c22 | ||
|
|
7f70b64f48 | ||
|
|
6c37cd5f05 | ||
|
|
7642c484d2 | ||
|
|
ec7408f3d5 | ||
|
|
0f8d35f516 | ||
|
|
295257240d | ||
|
|
f2fcf0d594 | ||
|
|
3e4e549b56 | ||
|
|
875b09cd8d | ||
|
|
84cd5f9331 | ||
|
|
debb13baa2 | ||
|
|
ab2a014568 | ||
|
|
558dae95cd | ||
|
|
1937c32572 | ||
|
|
5ed2821161 | ||
|
|
f66cf8ad4d | ||
|
|
d66cd2ebab | ||
|
|
f0e7700805 | ||
|
|
aad625a84d | ||
|
|
b57ea94cb8 | ||
|
|
737e7b62aa | ||
|
|
f11a4e1085 | ||
|
|
548677a99c | ||
|
|
087f7cf381 | ||
|
|
b860ef4c43 | ||
|
|
3b0b9eea50 | ||
|
|
0cf10613f2 | ||
|
|
e894ce2986 | ||
|
|
ac30f8913d | ||
|
|
de46db3343 | ||
|
|
782ff54e0f | ||
|
|
b989c84b71 | ||
|
|
400ca7043e | ||
|
|
9f5d729538 | ||
|
|
8eb4bab7c6 | ||
|
|
6c48c6d5c6 | ||
|
|
5bebc26aeb | ||
|
|
f5c36da648 | ||
|
|
e9a148315a | ||
|
|
fb4096f67b | ||
|
|
e83da25085 | ||
|
|
ae10b2861b | ||
|
|
eef3634f23 | ||
|
|
6ecbedfb85 | ||
|
|
e6366b6548 |
@@ -41,7 +41,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
|
||||
@@ -13,6 +13,11 @@ 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'
|
||||
@@ -27,25 +32,32 @@ concurrency:
|
||||
jobs:
|
||||
mutants:
|
||||
runs-on: ubuntu-latest
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
|
||||
# гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
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@v4
|
||||
uses: actions/checkout@v7
|
||||
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@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ steps.base.outputs.sha }}
|
||||
path: baseline
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -150,6 +150,8 @@ 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
|
||||
@@ -164,12 +166,13 @@ 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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: full-performance
|
||||
path: artifacts/performance
|
||||
|
||||
@@ -131,17 +131,37 @@ jobs:
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
# Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с
|
||||
# мёртвым PAT молча уходит от github-actions[bot] — 403 при
|
||||
# contents: read. Отказ обязан быть громким и правильным.
|
||||
persist-credentials: false
|
||||
|
||||
# Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился
|
||||
# только на публикации документа — после сорока минут работы ревьюера.
|
||||
- name: Секрет HP_PROCESS_TOKEN жив
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен"
|
||||
exit 1
|
||||
fi
|
||||
if ! login=$(gh api user -q .login 2>/dev/null); then
|
||||
echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
echo "токен жив, действует от: $login"
|
||||
|
||||
# Окружение готовит workflow, а не модель своими ходами. Раньше промпт
|
||||
# велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без
|
||||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||||
# тяжёлых job, здесь его не было.
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -154,8 +174,15 @@ jobs:
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
branch=$(git ls-remote --heads origin "issue/${NUM}-*" \
|
||||
| head -1 | sed 's|.*refs/heads/||')
|
||||
# Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом
|
||||
# жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую.
|
||||
git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true
|
||||
branches=$(git for-each-ref --sort=-committerdate \
|
||||
--format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*")
|
||||
branch=$(printf '%s\n' "$branches" | head -1)
|
||||
if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then
|
||||
echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить."
|
||||
fi
|
||||
if [ -n "$branch" ]; then
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
@@ -174,14 +201,19 @@ jobs:
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
# библиотека когда-нибудь пропадёт из образа, Chromium не запустится с
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
@@ -303,6 +335,8 @@ jobs:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
run: |
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
@@ -313,12 +347,28 @@ 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
|
||||
git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true
|
||||
git add docs/reviews 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
echo "::warning::документ ревью не создан"
|
||||
exit 0
|
||||
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
|
||||
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
|
||||
# коммиттер GitHub). Провал — когда файла нет и на ветке.
|
||||
git fetch -q origin "$target"
|
||||
if git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "документ уже опубликован ревьюером: $doc"
|
||||
exit 0
|
||||
fi
|
||||
# Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше
|
||||
# здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ
|
||||
# остались только комментариями, метки переставились, и пропажу
|
||||
# заметило лишь следующее ревью — issue #171. Падение ДО шага с
|
||||
# меткой сохраняет инвариант «метка не сменилась = прогон упал».
|
||||
echo "::error::вердикт есть, а документа $doc нет ни в рабочей копии, ни в $target — ревью без артефакта (#171)"
|
||||
exit 1
|
||||
fi
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
@@ -338,13 +388,22 @@ jobs:
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
rebase "origin/$target"; then
|
||||
git rebase --abort || true
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт"
|
||||
exit 0
|
||||
# Тоже вердикт без артефакта: раньше exit 0 переставил бы метку.
|
||||
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
|
||||
exit 1
|
||||
fi
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
fi
|
||||
echo "документ опубликован в $target"
|
||||
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
|
||||
# документом, названным не по формату, — тот же вердикт без
|
||||
# артефакта, только дороже в обнаружении.
|
||||
git fetch -q origin "$target"
|
||||
if ! git cat-file -e "origin/$target:$doc" 2>/dev/null; then
|
||||
echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)"
|
||||
exit 1
|
||||
fi
|
||||
echo "документ опубликован в $target: $doc"
|
||||
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
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@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
@@ -173,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@v7
|
||||
uses: actions/github-script@v9
|
||||
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@v4
|
||||
- uses: actions/checkout@v7
|
||||
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@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
@@ -47,10 +47,10 @@ jobs:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
- name: Verify compositor frame continuity for a stable release
|
||||
@@ -61,13 +61,13 @@ jobs:
|
||||
npm run continuity:screencast
|
||||
- name: Upload failed continuity frames
|
||||
if: ${{ failure() && !github.event.release.prerelease }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: continuity-screencast
|
||||
path: artifacts/continuity-screencast
|
||||
- run: cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
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@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
|
||||
@@ -15,12 +15,21 @@ 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
|
||||
|
||||
provenance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Validate commit trailers and hook mode
|
||||
env:
|
||||
@@ -28,8 +37,9 @@ jobs:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
run: |
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
|
||||
|
||||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||||
@@ -40,9 +50,9 @@ jobs:
|
||||
process-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- name: Process gate
|
||||
env:
|
||||
@@ -50,34 +60,89 @@ jobs:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
DEVELOPMENT_BRANCH: dev
|
||||
TARGET_REF: ${{ github.ref }}
|
||||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||||
node scripts/process-gate.mjs --github-range --issues
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
|
||||
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
|
||||
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
|
||||
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
|
||||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
zero=$(printf '%040d' 0)
|
||||
base="$BEFORE_SHA"
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||||
# иначе классифицировалась бы вся история.
|
||||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||||
git fetch -q origin dev
|
||||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||||
fi
|
||||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||||
printf '%s\n' "$files" | head -50
|
||||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||||
{
|
||||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
|
||||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
hacs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: HACS validation
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
|
||||
hassfest:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
|
||||
frontend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -98,8 +163,8 @@ jobs:
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -125,7 +190,7 @@ jobs:
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: smoke-logs
|
||||
path: /tmp/smoke-logs
|
||||
@@ -136,8 +201,8 @@ jobs:
|
||||
needs: frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -158,7 +223,7 @@ jobs:
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
@@ -170,8 +235,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -188,20 +253,22 @@ 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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
|
||||
backend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
# Browser fixtures are generated by their real ESM factories and then
|
||||
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with: { python-version: "3.13" }
|
||||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
- name: Backend unit tests (pure + HA harness)
|
||||
|
||||
@@ -38,8 +38,7 @@ 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. [GitHub Projects (v2)](https://github.com/users/Matysh/projects/1) is a
|
||||
human-facing view synchronised from the labels, not the source of truth.
|
||||
issue. Labels are the whole of it: GitHub Projects is no longer used.
|
||||
|
||||
Two shortcuts exist for small work. `small` — the light track: the spec lives in
|
||||
the issue body and its review is a comment. `trivial` — the short track: no spec
|
||||
@@ -155,6 +154,28 @@ 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
|
||||
@@ -299,9 +320,19 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
```
|
||||
|
||||
During the implementation cycle 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.
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
|
||||
environment, which changes one thing (#151): **before moving an issue to
|
||||
`S7-code-review`, run the smokes named in its AC locally** — `node
|
||||
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) and `golden:verify` are advisory; **the canon
|
||||
does not move**: the beta gate is CI at the exact SHA, and baselines are accepted
|
||||
only via `npm run golden:accept -- --reviewed` on a complete Linux CI artefact.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
|
||||
@@ -18,13 +18,12 @@ 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) 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.
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the only
|
||||
active backlog. An issue owns scope and acceptance criteria; its **labels** own
|
||||
priority and workflow status — `PROCESS.md` §9 holds the vocabulary. Before
|
||||
starting planned work, link it to an existing issue or create one, and keep it
|
||||
current until the verified result is closed. Design specs and ADRs may support an
|
||||
issue, but they do not replace it or maintain a separate checklist.
|
||||
|
||||
## Five-minute setup
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках, Project v2 остаётся человеческим представлением. При расхождении
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. При расхождении этого документа с
|
||||
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
|
||||
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
|
||||
@@ -86,9 +86,12 @@ 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`;
|
||||
@@ -98,10 +101,21 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. лёгкий трек — да/нет по критериям §5.
|
||||
- **Приоритет и ценность — поля владельца.** Агент предлагает, владелец
|
||||
утверждает; иначе агенты приоритизируют сами и P1 разрастается.
|
||||
- **Выход:** «ТЗ в работе» либо «Отклонено» с записанной причиной.
|
||||
8. трек: обычный / `small` / `trivial` по критериям §5 и §5.1.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
останавливает работу. Право отклонить задачу (`rejected`) остаётся за
|
||||
владельцем на любой стадии.
|
||||
- **Вопросов владельцу на этом этапе нет.** Единственный класс вопросов, который
|
||||
вообще задаётся владельцу, — продуктовые (§7.1: что человек видит или делает,
|
||||
объём видимых изменений), и их место — этап ТЗ, пачкой, с вариантами по
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
@@ -492,9 +506,12 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
|
||||
## 9. Метки — канонический статус
|
||||
|
||||
Статус читается из меток: их видно в списке issue и их читает любой токен с
|
||||
доступом к Issues, в отличие от Project v2, который требует отдельного скоупа.
|
||||
Project v2 остаётся человеческим представлением и синхронизируется по меткам.
|
||||
Статус читается из меток: их видно в списке issue, их читает любой токен с
|
||||
доступом к Issues, и по ним же работает конвейер — смена метки порождает событие
|
||||
(§10.4). **Project v2 не используется** (решение владельца 2026-08-14): второе
|
||||
представление статуса рядом с метками требовало отдельного скоупа токена,
|
||||
синхронизации и внимания, а давало вид доски. Два источника одного факта
|
||||
расходятся — это уже случалось с колонкой «Статус ТЗ» в `docs/specs/README.md`.
|
||||
|
||||
**Имена меток английские** (решение владельца 2026-08-12). Русские имена в этом
|
||||
документе были только на бумаге; репозиторий с самого начала жил на английских.
|
||||
@@ -571,6 +588,13 @@ Project v2 остаётся человеческим представление
|
||||
считается от `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`, поэтому при его отсутствии хук печатает
|
||||
предупреждение и выполняет только офлайн-часть. Это сознательная уступка: хук,
|
||||
который не работает в самолёте, отключают целиком, а строгий проход всё равно
|
||||
|
||||
@@ -1,379 +1,157 @@
|
||||
# 🏠 House Plan — interactive floor plan card for Home Assistant
|
||||
# 🏠 House Plan — a live home map 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)
|
||||
|
||||
**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.
|
||||
📘 **[Full user guide](docs/USER-GUIDE.md)** · 🇷🇺 **[Русский](README.ru.md)** · 🗂 **[Project issues](https://github.com/Matysh/houseplan-card/issues)**
|
||||
|
||||
> **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.
|
||||
<!-- docs-section: overview -->
|
||||
|
||||

|
||||
## Your whole home at a glance
|
||||
|
||||
> ### 🚀 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.
|
||||
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.
|
||||
|
||||
🇷🇺 [Документация на русском](README.ru.md) · 💬 [Telegram chat: **@ha_houseplan**](https://t.me/ha_houseplan)
|
||||

|
||||
|
||||
**Feature highlights**
|
||||
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.
|
||||
|
||||
- ♾️ **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.
|
||||
> **Edit on a desktop computer.** View and kiosk are fully supported on phones
|
||||
> and tablets. The editors are designed primarily for a mouse and keyboard;
|
||||
> individual touch editing operations may be awkward or unavailable. See the
|
||||
> exact [touch support contract](docs/TOUCH-SUPPORT.md).
|
||||
|
||||
---
|
||||
<!-- docs-section: features -->
|
||||
|
||||
## What it is and why
|
||||
## What House Plan provides
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
This is convenient when:
|
||||

|
||||
|
||||
- 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.
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
The integration consists of two parts that are installed together:
|
||||
## Your first working room
|
||||
|
||||
- **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.
|
||||
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.
|
||||
|
||||
---
|
||||

|
||||
|
||||
## 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.
|
||||

|
||||
|
||||
Key advantages in short:
|
||||
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.
|
||||
|
||||
- **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.
|
||||
<!-- docs-section: installation -->
|
||||
|
||||
## Installation
|
||||
|
||||
One click if you already run 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. 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**.
|
||||
|
||||
### Via HACS (recommended)
|
||||
The card is registered automatically. If you manage Lovelace resources
|
||||
manually, use the URL served by the integration:
|
||||
|
||||
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**.
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
The card is registered automatically — no need to add a Lovelace resource manually.
|
||||
Do not use the on-disk path inside `custom_components`; Home Assistant does not
|
||||
serve that path as a JavaScript module.
|
||||
|
||||
> **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.
|
||||
### Manual installation
|
||||
|
||||
### Manually
|
||||
Copy `custom_components/houseplan` to `config/custom_components`, restart Home
|
||||
Assistant, and add the House Plan integration.
|
||||
|
||||
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**.
|
||||
### Add the card
|
||||
|
||||
### Adding a plan screen
|
||||
|
||||
Create a new dashboard tab (a "Panel" view works best) and add the card:
|
||||
Create a dashboard view (Panel works best) and add the card in the UI or as:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: House plan
|
||||
```
|
||||
|
||||
Nothing else needs to be specified — everything else is configured right on the screen.
|
||||
Different screens may start on different spaces:
|
||||
|
||||
---
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
default_floor: ground
|
||||
```
|
||||
|
||||
## How to use
|
||||
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.
|
||||
|
||||
### Step 1. Add a space (floor)
|
||||
## Detailed documentation
|
||||
|
||||
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).
|
||||
- [Full user guide](docs/USER-GUIDE.md)
|
||||
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
|
||||
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
|
||||
- [Background editor](docs/DECOR-EDITOR.md)
|
||||
- [Robot vacuums](docs/VACUUM.md)
|
||||
- [Touch support](docs/TOUCH-SUPPORT.md)
|
||||
|
||||

|
||||
<!-- docs-section: support -->
|
||||
|
||||
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.
|
||||
## Support and feedback
|
||||
|
||||

|
||||
- Questions and plan examples: [Telegram @ha_houseplan](https://t.me/ha_houseplan).
|
||||
- Bugs and proposals: [GitHub Issues](https://github.com/Matysh/houseplan-card/issues).
|
||||
- Before reporting, update House Plan, restart HA and hard-refresh the page.
|
||||
Include the version, browser, logs and reproduction steps; private entity IDs
|
||||
may be replaced with fictional ones.
|
||||
|
||||
> 💡 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.
|
||||
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).
|
||||
|
||||
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>
|
||||
License: [MIT](LICENSE).
|
||||
|
||||
@@ -1,351 +1,161 @@
|
||||
# 🏠 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/stargazers)
|
||||
[](https://github.com/Matysh/houseplan-card/actions)
|
||||
[](https://demo.houseplan.tech)
|
||||
[](https://t.me/ha_houseplan)
|
||||
|
||||
📘 **[Полное руководство пользователя](docs/USER-GUIDE.ru.md)** · 🗂 **[Беклог проекта](https://github.com/users/Matysh/projects/1)**
|
||||
📘 **[Полное руководство](docs/USER-GUIDE.ru.md)** · 🇬🇧 **[English](README.md)** · 🗂 **[Задачи проекта](https://github.com/Matysh/houseplan-card/issues)**
|
||||
|
||||
**Превратите Home Assistant в живую интерактивную карту дома.** Загрузите или
|
||||
нарисуйте план этажа, обведите комнаты мышкой — и умные устройства появятся на
|
||||
своих местах: живые состояния, свет по клику, температура и влажность по
|
||||
комнатам, карта Zigbee-сигнала, светящиеся пятна ламп и полноэкранный
|
||||
киоск-режим для настенного планшета. Без YAML, без Inkscape и внешних
|
||||
редакторов — весь план настраивается прямо на дашборде.
|
||||
<!-- docs-section: overview -->
|
||||
|
||||
> **Редактировать планы рекомендуется на компьютере.** Режим просмотра и
|
||||
> киоск полноценно поддерживаются на телефонах и планшетах. Редакторы рассчитаны
|
||||
> прежде всего на desktop с мышью и клавиатурой: на touch-устройстве отдельные
|
||||
> операции могут быть менее удобны, работать ограниченно или отсутствовать.
|
||||
## Дом целиком — одним взглядом
|
||||
|
||||

|
||||
House Plan превращает Home Assistant в живую карту дома. Загрузите изображение
|
||||
плана или нарисуйте комнаты прямо на дашборде, свяжите их с зонами Home
|
||||
Assistant — и устройства появятся на плане автоматически. Сразу видно, где
|
||||
горит свет, открыта дверь, слишком холодно, слабый Zigbee-сигнал или сработал
|
||||
датчик протечки.
|
||||
|
||||
> ### 🚀 Попробовать вживую — без установки
|
||||
> **[demo.houseplan.tech](https://demo.houseplan.tech)** — настоящий Home
|
||||
> Assistant с готовым планом. Вход **`demo`** / **`demo`**, можно нажимать всё:
|
||||
> включать свет, открывать редакторы, ломать что угодно. Каждый час стенд сам
|
||||
> возвращается в исходное состояние.
|
||||

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

|
||||
|
||||
House Plan показывает ваш умный дом так, как он выглядит на самом деле — на плане этажей. Вместо длинных списков сущностей вы видите комнаты и устройства на своих местах: где протечка, какая температура в детской, включён ли свет в прихожей, открыты ли ворота.
|
||||
<!-- docs-section: first-run -->
|
||||
|
||||
Это удобно, когда:
|
||||
## Первая рабочая комната
|
||||
|
||||
- устройств много, и списками пользоваться неудобно;
|
||||
- нужно быстро понять состояние дома «одним взглядом»;
|
||||
- хочется отдать доступ близким — по картинке разберётся любой;
|
||||
- вы хотите красивый обзорный экран для настенного планшета.
|
||||
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:
|
||||

|
||||
|
||||
| | 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/USER-GUIDE.ru.md). Возможности подложки отдельно
|
||||
зафиксированы в [документе редактора](docs/DECOR-EDITOR.md), а роботов — в
|
||||
[руководстве по пылесосам](docs/VACUUM.md).
|
||||
|
||||
**Одной фразой:** 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).
|
||||
<!-- docs-section: installation -->
|
||||
|
||||
## Установка
|
||||
|
||||
В один клик, если у вас уже есть 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**.
|
||||
|
||||
### Через HACS (рекомендуется)
|
||||
Карточка регистрируется автоматически. Если ресурсы Lovelace управляются вручную,
|
||||
добавьте именно URL, который публикует интеграция:
|
||||
|
||||
1. Откройте **HACS → меню (⋮) → Custom repositories**.
|
||||
2. Вставьте URL этого репозитория, категория — **Integration**, и нажмите **Add**.
|
||||
3. Найдите в списке **House Plan**, установите и **перезапустите Home Assistant**.
|
||||
4. Перейдите в **Настройки → Устройства и службы → Добавить интеграцию** и выберите **House Plan**.
|
||||
```yaml
|
||||
resources:
|
||||
- url: /houseplan_files/houseplan-card.js
|
||||
type: module
|
||||
```
|
||||
|
||||
Карточка подключается автоматически — добавлять ресурс 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`: Home Assistant не
|
||||
публикует его как JavaScript-модуль.
|
||||
|
||||
### Вручную
|
||||
|
||||
1. Скопируйте папку `custom_components/houseplan` в каталог `config/custom_components` вашего Home Assistant.
|
||||
2. Перезапустите Home Assistant.
|
||||
3. Добавьте интеграцию: **Настройки → Устройства и службы → Добавить интеграцию → House Plan**.
|
||||
Скопируйте `custom_components/houseplan` в `config/custom_components`,
|
||||
перезапустите Home Assistant и добавьте интеграцию House Plan.
|
||||
|
||||
### Добавление экрана с планом
|
||||
### Добавление карточки
|
||||
|
||||
Создайте новую вкладку дашборда (удобнее всего — в режиме «Панель»/Panel) и добавьте карточку:
|
||||
Создайте представление дашборда (лучше Panel) и добавьте карточку через UI либо:
|
||||
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
title: План дома
|
||||
```
|
||||
|
||||
Больше ничего указывать не нужно — всё остальное настраивается прямо на экране.
|
||||
Для нескольких экранов можно задать разные стартовые пространства:
|
||||
|
||||
---
|
||||
```yaml
|
||||
type: custom:houseplan-card
|
||||
default_floor: ground
|
||||
```
|
||||
|
||||
## Как пользоваться
|
||||
Все карточки используют общие серверные комнаты и координаты. Текущий режим,
|
||||
масштаб и выбранное пространство локальны для экрана. Одновременное
|
||||
редактирование поддерживает синхронизацию и проверку ревизий, но один объект
|
||||
лучше не менять параллельно в двух браузерах.
|
||||
|
||||
### Шаг 1. Добавьте пространство (этаж)
|
||||
## Где искать подробности
|
||||
|
||||
При первом открытии план ещё пуст — House Plan сразу предложит создать первое пространство.
|
||||
- [Полное руководство пользователя](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)
|
||||
|
||||
Если в вашем Home Assistant уже настроены **этажи**, мастер предложит создать
|
||||
пространство для каждого: названия подставятся сами, план попросит по очереди,
|
||||
любой этаж можно пропустить.
|
||||
<!-- docs-section: support -->
|
||||
|
||||

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

|
||||
Скриншоты в документации получены воспроизводимой командой
|
||||
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
|
||||
fingerprint исходников и хеш каждого изображения находятся в
|
||||
[индексе снимков](docs/images/screenshots.json).
|
||||
|
||||
> 💡 Подложку можно нарисовать в любом планировщике (например, РЕМПЛАННЕР) или сфотографировать бумажный план. Лучше всего 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>
|
||||
Лицензия: [MIT](LICENSE).
|
||||
|
||||
@@ -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 = 1
|
||||
STORAGE_MINOR_VERSION = 2
|
||||
FRONTEND_URL = "/houseplan_files/houseplan-card.js"
|
||||
PLANS_URL = "/houseplan_files/plans"
|
||||
PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
||||
@@ -46,7 +46,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.64.0-beta.1"
|
||||
VERSION = "1.65.0-beta.2"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
@@ -65,5 +65,5 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {},
|
||||
"settings": {"bg_mode": "daynight"},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
@@ -47,12 +48,43 @@ from .validation import (
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_value_badges,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
)
|
||||
|
||||
FORMAT = "houseplan-export"
|
||||
_PROTO_KEYS = {"__proto__", "prototype", "constructor"}
|
||||
_SAFE_FILE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
_LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
|
||||
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
|
||||
)
|
||||
_SPACE_DISPLAY_FIELDS = (
|
||||
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
||||
"fill_mode", "custom_fill", "glow_enabled", "temp_min", "temp_max",
|
||||
"show_lqi", "hide_decor", "hide_openings", "label_temp", "label_hum",
|
||||
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
|
||||
"sun_rays",
|
||||
)
|
||||
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
|
||||
_ROOM_DISPLAY_FIELDS = (
|
||||
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
|
||||
)
|
||||
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
|
||||
_DECOR_KIND_FIELDS = {
|
||||
"line": ("x1", "y1", "x2", "y2", "line_style"),
|
||||
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
|
||||
}
|
||||
|
||||
|
||||
class ImportFailure(Exception):
|
||||
@@ -73,6 +105,35 @@ def _json_copy(value: Any) -> Any:
|
||||
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
||||
|
||||
|
||||
def _background_mode(settings: Any) -> str | None:
|
||||
if not isinstance(settings, dict):
|
||||
return None
|
||||
mode = settings.get("bg_mode")
|
||||
return mode if mode in ("static", "daynight") else None
|
||||
|
||||
|
||||
def _materialize_global_background(config: dict[str, Any], fallback: str = "static") -> str:
|
||||
"""Make the global mode portable instead of relying on a code default."""
|
||||
settings = config.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
config["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _materialize_space_background(space: dict[str, Any], fallback: str) -> str:
|
||||
"""Make one space independent from the target installation's default."""
|
||||
settings = space.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
space["settings"] = settings
|
||||
mode = _background_mode(settings) or fallback
|
||||
settings["bg_mode"] = mode
|
||||
return mode
|
||||
|
||||
|
||||
def _stored_model_version(config: dict[str, Any]) -> int:
|
||||
"""Return the model actually stored without silently upgrading it."""
|
||||
value = config.get("model_version", 0)
|
||||
@@ -110,6 +171,136 @@ def live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any
|
||||
}
|
||||
|
||||
|
||||
def _pick_fields(source: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""Copy only fields explicitly classified as portable plan data."""
|
||||
return {key: _json_copy(source[key]) for key in fields if key in source}
|
||||
|
||||
|
||||
def _is_live_text_reference(raw: str) -> bool:
|
||||
"""Mirror ``liveTextReference`` without evaluating Home Assistant state."""
|
||||
ref = raw.strip()
|
||||
if not ref:
|
||||
return False
|
||||
entity = ref
|
||||
attribute = ""
|
||||
colon = ref.find(":")
|
||||
if colon >= 0:
|
||||
entity = ref[:colon].strip()
|
||||
attribute = ref[colon + 1:].strip()
|
||||
else:
|
||||
parts = ref.split(".")
|
||||
if len(parts) > 2:
|
||||
entity = ".".join(parts[:2])
|
||||
attribute = ".".join(parts[2:])
|
||||
if _LIVE_TEXT_ENTITY.fullmatch(entity) is None:
|
||||
return False
|
||||
if colon >= 0 and not attribute:
|
||||
return False
|
||||
return not attribute or _LIVE_TEXT_ATTRIBUTE.fullmatch(attribute) is not None
|
||||
|
||||
|
||||
def _plan_only_text(value: str) -> str:
|
||||
"""Freeze every recognized live reference while preserving authored copy."""
|
||||
replaced = _LIVE_TEXT_TOKEN.sub(
|
||||
lambda match: _PLAN_ONLY_DASH
|
||||
if _is_live_text_reference(match.group(1)) else match.group(0),
|
||||
value,
|
||||
)
|
||||
return replaced.replace("{}", _PLAN_ONLY_DASH)
|
||||
|
||||
|
||||
def _project_plan_only_room(room: dict[str, Any]) -> dict[str, Any]:
|
||||
projected = _pick_fields(room, _ROOM_PLAN_FIELDS)
|
||||
if "settings" in room:
|
||||
settings = room.get("settings")
|
||||
projected["settings"] = (
|
||||
_pick_fields(settings, _ROOM_DISPLAY_FIELDS)
|
||||
if isinstance(settings, dict) else None
|
||||
)
|
||||
return projected
|
||||
|
||||
|
||||
def _project_plan_only_decor(shape: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = str(shape.get("kind", ""))
|
||||
projected = _pick_fields(
|
||||
shape, _DECOR_COMMON_FIELDS + _DECOR_KIND_FIELDS.get(kind, ()),
|
||||
)
|
||||
if kind == "text" and isinstance(projected.get("text"), str):
|
||||
projected["text"] = _plan_only_text(projected["text"])
|
||||
return projected
|
||||
|
||||
|
||||
def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the fail-closed geometry/presentation projection for #167."""
|
||||
projected = _pick_fields(space, _SPACE_PLAN_FIELDS)
|
||||
if "settings" in space:
|
||||
projected["settings"] = _pick_fields(
|
||||
space.get("settings") or {}, _SPACE_DISPLAY_FIELDS,
|
||||
)
|
||||
projected["rooms"] = [
|
||||
_project_plan_only_room(room) for room in space.get("rooms") or []
|
||||
]
|
||||
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("walls", ("key", "cm", "a", "b")),
|
||||
("room_drafts", ("id", "points", "segments")),
|
||||
("partitions", ("id", "a", "b", "cm")),
|
||||
("wall_columns", ("id", "shape", "center", "cm", "angle")),
|
||||
("open_spans", ("a", "b")),
|
||||
)
|
||||
for name, fields in collections:
|
||||
if name not in space:
|
||||
continue
|
||||
values = []
|
||||
for item in space.get(name) or []:
|
||||
selected = _pick_fields(item, fields)
|
||||
if name == "room_drafts" and "segments" in selected:
|
||||
selected["segments"] = [
|
||||
_pick_fields(segment, ("cm",))
|
||||
for segment in selected.get("segments") or []
|
||||
]
|
||||
values.append(selected)
|
||||
projected[name] = values
|
||||
if "openings" in space:
|
||||
projected["openings"] = [
|
||||
_pick_fields(
|
||||
opening,
|
||||
("id", "type", "x", "y", "angle", "length")
|
||||
+ (() if opening.get("type") == "passage" else ("flip_h", "flip_v"))
|
||||
+ (("host",) if opening.get("host") else ()),
|
||||
)
|
||||
for opening in space.get("openings") or []
|
||||
]
|
||||
if "decor" in space:
|
||||
projected["decor"] = [
|
||||
_project_plan_only_decor(shape) for shape in space.get("decor") or []
|
||||
]
|
||||
return projected
|
||||
|
||||
|
||||
def _plan_only_room_label_layout(
|
||||
layout: dict[str, Any], space: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
space_id = str(space.get("id", ""))
|
||||
room_ids = {str(room.get("id", "")) for room in space.get("rooms") or []}
|
||||
projected: dict[str, Any] = {}
|
||||
for key, pos in layout.items():
|
||||
if not (
|
||||
isinstance(key, str)
|
||||
and key.startswith("rl_")
|
||||
and key[3:] in room_ids
|
||||
and isinstance(pos, dict)
|
||||
and str(pos.get("s", "")) == space_id
|
||||
):
|
||||
continue
|
||||
value = _pick_fields(pos, ("x", "y", "s"))
|
||||
scale = pos.get("k")
|
||||
if isinstance(scale, (int, float)) and not isinstance(scale, bool) \
|
||||
and math.isfinite(scale) and 0.5 <= scale <= 3:
|
||||
value["k"] = scale
|
||||
projected[key] = value
|
||||
return projected
|
||||
|
||||
|
||||
def _marker_owned(marker: dict[str, Any], space: dict[str, Any], layout: dict[str, Any]) -> bool:
|
||||
marker_id = str(marker.get("id", ""))
|
||||
room_ids = {str(room.get("id")) for room in space.get("rooms") or []}
|
||||
@@ -225,9 +416,12 @@ def create_export(
|
||||
*,
|
||||
kind: str,
|
||||
space_id: str | None,
|
||||
plan_only: bool = False,
|
||||
card_version: str,
|
||||
config_root: Path,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
if not isinstance(plan_only, bool) or plan_only and kind != "space":
|
||||
raise ImportFailure("invalid_format", "Plan-only export requires one space")
|
||||
raw_config = _json_copy(
|
||||
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
|
||||
)
|
||||
@@ -239,6 +433,7 @@ def create_export(
|
||||
# payload would either duplicate it or tempt an exporter to silently stamp
|
||||
# the current version over an older/future stored model.
|
||||
config.pop("model_version", None)
|
||||
global_background = _materialize_global_background(config)
|
||||
layout = live_layout(config, LAYOUT_SCHEMA(_json_copy(layout_data.get("layout") or {})))
|
||||
stamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
title = ""
|
||||
@@ -247,41 +442,47 @@ def create_export(
|
||||
space = next((sp for sp in config.get("spaces") or [] if str(sp.get("id")) == space_id), None)
|
||||
if not space:
|
||||
raise ImportFailure("space_not_found", "Space was not found")
|
||||
_materialize_space_background(space, global_background)
|
||||
title = str(space.get("title") or space.get("id") or "space")
|
||||
selected_layout = {
|
||||
key: pos for key, pos in layout.items()
|
||||
if isinstance(pos, dict) and str(pos.get("s")) == str(space_id)
|
||||
}
|
||||
selected_markers = [
|
||||
m for m in config.get("markers") or []
|
||||
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
|
||||
]
|
||||
selected_ids = {str(marker.get("id")) for marker in selected_markers}
|
||||
for marker in selected_markers:
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
kept = []
|
||||
for ref in controls:
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
and source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
}
|
||||
layout = selected_layout
|
||||
if plan_only:
|
||||
projected_space = _project_plan_only_space(space)
|
||||
config = {"spaces": [projected_space], "markers": []}
|
||||
layout = _plan_only_room_label_layout(selected_layout, projected_space)
|
||||
else:
|
||||
selected_markers = [
|
||||
m for m in config.get("markers") or []
|
||||
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
|
||||
]
|
||||
selected_ids = {str(marker.get("id")) for marker in selected_markers}
|
||||
for marker in selected_markers:
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
kept = []
|
||||
for ref in controls:
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
and source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
}
|
||||
layout = selected_layout
|
||||
elif kind != "full":
|
||||
raise ImportFailure("invalid_format", "Unknown export kind")
|
||||
document = {
|
||||
@@ -296,7 +497,10 @@ def create_export(
|
||||
"payload": {"config": config, "layout": layout},
|
||||
"placement_manifest": placement_manifest(config, layout),
|
||||
"content_manifest": content_manifest(config, config_root),
|
||||
"transfer": {"dropped_marker_links": dropped_marker_links},
|
||||
"transfer": {
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
**({"plan_only": True} if plan_only else {}),
|
||||
},
|
||||
}
|
||||
if len(json.dumps(
|
||||
document, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
@@ -358,6 +562,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
config = CONFIG_SCHEMA(_json_copy(payload.get("config")))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
if document["kind"] == "full":
|
||||
_materialize_global_background(config)
|
||||
else:
|
||||
for space in config.get("spaces") or []:
|
||||
if isinstance(space, dict):
|
||||
_materialize_space_background(space, "static")
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(_json_copy(payload.get("layout") or {}))
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
@@ -379,10 +589,13 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
if document["kind"] == "space" and placement_ids != set(layout):
|
||||
raise ImportFailure("invalid_format", "Placement manifest does not match layout")
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
plan_only = _transfer_plan_only(document)
|
||||
document["transfer"] = {
|
||||
**(document.get("transfer") or {}),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
}
|
||||
if plan_only:
|
||||
_validate_plan_only_document(document, config, layout, placement)
|
||||
if len(json.dumps(
|
||||
config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
).encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
@@ -453,6 +666,51 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _transfer_plan_only(document: dict[str, Any]) -> bool:
|
||||
"""Read strict additive plan-only metadata without widening old exports."""
|
||||
transfer = document.get("transfer")
|
||||
if transfer is None:
|
||||
return False
|
||||
if not isinstance(transfer, dict):
|
||||
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
|
||||
value = transfer.get("plan_only", False)
|
||||
if not isinstance(value, bool):
|
||||
raise ImportFailure("invalid_format", "Plan-only metadata must be a boolean")
|
||||
if value and document.get("kind") != "space":
|
||||
raise ImportFailure("invalid_format", "Plan-only metadata requires one space")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_plan_only_document(
|
||||
document: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
placement: list[Any],
|
||||
) -> None:
|
||||
"""Reject forged plan-only flags unless every privacy invariant is true."""
|
||||
spaces = config.get("spaces") or []
|
||||
if len(spaces) != 1 or config.get("markers") != []:
|
||||
raise ImportFailure("invalid_format", "Plan-only export has invalid owners")
|
||||
expected_config = CONFIG_SCHEMA({
|
||||
"spaces": [_project_plan_only_space(spaces[0])],
|
||||
"markers": [],
|
||||
})
|
||||
if config != expected_config:
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private fields")
|
||||
expected_layout = _plan_only_room_label_layout(layout, spaces[0])
|
||||
if layout != expected_layout:
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains device layout")
|
||||
expected_placement = placement_manifest(config, layout)
|
||||
if placement != expected_placement:
|
||||
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
|
||||
content = document.get("content_manifest")
|
||||
if not isinstance(content, list) or any(
|
||||
not isinstance(item, dict) or item.get("owner") != "space"
|
||||
for item in content
|
||||
):
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private content")
|
||||
|
||||
|
||||
def _drop_invalid_import_marker_links(
|
||||
config: dict[str, Any], *, clean_ids: set[str] | None = None,
|
||||
) -> int:
|
||||
@@ -560,6 +818,7 @@ def build_space_merge(
|
||||
if len(spaces) != 1:
|
||||
raise ImportFailure("invalid_config", "A space export must contain exactly one space")
|
||||
space = spaces[0]
|
||||
_materialize_space_background(space, "static")
|
||||
used = {
|
||||
str(value)
|
||||
for sp in current_config.get("spaces") or []
|
||||
@@ -583,6 +842,16 @@ def build_space_merge(
|
||||
for room in space.get("rooms") or []:
|
||||
if room.get("open_to"):
|
||||
room["open_to"] = [old_room_ids.get(str(value), str(value)) for value in room["open_to"]]
|
||||
# Opening ownership is part of the same space-local id graph. Remap the
|
||||
# nested reference together with the partition itself; otherwise the
|
||||
# invariant validator correctly rejects a copied space whose host.id still
|
||||
# names the source partition.
|
||||
for opening in space.get("openings") or []:
|
||||
host = opening.get("host") if isinstance(opening, dict) else None
|
||||
if isinstance(host, dict) and host.get("kind") == "partition":
|
||||
old_host_id = str(host.get("id"))
|
||||
if old_host_id in id_map:
|
||||
host["id"] = id_map[old_host_id]
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -637,7 +906,7 @@ def build_space_merge(
|
||||
for field in (
|
||||
"area", "controls", "tap_action", "tap_target", "tap_confirm",
|
||||
"vacuum", "is_light", "use_climate_temp", "glow_color",
|
||||
"glow_radius_cm", "light_entity", "hidden", "removed",
|
||||
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
|
||||
"value_badge",
|
||||
):
|
||||
marker.pop(field, None)
|
||||
@@ -722,7 +991,9 @@ def build_space_merge(
|
||||
validate_marker_controls(merged_config, current_config)
|
||||
validate_marker_light_entities(merged_config, current_config)
|
||||
validate_marker_value_badges(merged_config, current_config)
|
||||
except MarkerControlError as err:
|
||||
validate_opening_passages(merged_config, current_config)
|
||||
validate_partition_opening_hosts(merged_config, current_config)
|
||||
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
try:
|
||||
merged_layout = LAYOUT_SCHEMA(merged_layout)
|
||||
@@ -881,6 +1152,13 @@ def create_preview(
|
||||
current_config = current_config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
|
||||
current_layout = current_layout_data.get("layout") or {}
|
||||
details: dict[str, Any] = {}
|
||||
try:
|
||||
# Every imported opening is new to this installation. A forged full or
|
||||
# one-space file must not use the broken-read exception reserved for an
|
||||
# already stored legacy passage.
|
||||
validate_opening_passages(incoming_config, validate_all=True)
|
||||
except OpeningPassageError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
if document["kind"] == "space":
|
||||
_merged_config, _merged_layout, details = build_space_merge(
|
||||
document, current_config, current_layout, duplicate_policy,
|
||||
@@ -926,6 +1204,7 @@ def create_preview(
|
||||
runtime.import_previews[token] = candidate
|
||||
preview = {
|
||||
"kind": document["kind"],
|
||||
"plan_only": _transfer_plan_only(document),
|
||||
"created_at": document.get("created_at"),
|
||||
"card_version": document.get("card_version"),
|
||||
"integration_version": document.get("integration_version"),
|
||||
@@ -1005,6 +1284,7 @@ def revalidate_candidate(
|
||||
return {
|
||||
"preview": {
|
||||
"kind": document["kind"],
|
||||
"plan_only": _transfer_plan_only(document),
|
||||
"counts": _counts(incoming["config"], incoming["layout"]),
|
||||
"current_counts": _counts(current_config, current_layout),
|
||||
"source": "same" if candidate.get("same_source") else "foreign",
|
||||
@@ -1039,6 +1319,7 @@ def prepare_apply(
|
||||
_detach_missing(imported_config, candidate.get("content") or [])
|
||||
if document["kind"] == "full":
|
||||
config = imported_config
|
||||
_materialize_global_background(config)
|
||||
# Full exports keep the data-model version in the portable envelope so
|
||||
# the payload can be validated independently. Restore it before the
|
||||
# configuration is persisted; otherwise every full round-trip silently
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.64.0-beta.1"
|
||||
"version": "1.65.0-beta.2"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
@@ -22,6 +23,32 @@ from .const import (
|
||||
|
||||
|
||||
_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):
|
||||
@@ -39,10 +66,9 @@ class HouseplanStore(Store):
|
||||
old_minor_version: int,
|
||||
old_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
data = old_data
|
||||
# if old_major_version == 1 and old_minor_version < 2:
|
||||
# ...migrate...
|
||||
return data
|
||||
if old_major_version == 1 and old_minor_version < 2:
|
||||
return migrate_config_background_mode(old_data)
|
||||
return old_data
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -32,6 +32,93 @@ 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"
|
||||
|
||||
|
||||
def validate_partition_opening_hosts(
|
||||
config: dict, previous: dict | None = None
|
||||
) -> None:
|
||||
"""Prevent an older writer from silently downgrading hosted openings.
|
||||
|
||||
Deleting the opening together with its partition remains valid. Only a
|
||||
surviving record that previously had an explicit host must keep one.
|
||||
"""
|
||||
old_spaces = {
|
||||
str(space.get("id")): space for space in (previous or {}).get("spaces") or []
|
||||
}
|
||||
for space in config.get("spaces") or []:
|
||||
old_space = old_spaces.get(str(space.get("id", "")))
|
||||
if not old_space:
|
||||
continue
|
||||
old_openings = {
|
||||
str(opening.get("id")): opening
|
||||
for opening in old_space.get("openings") or []
|
||||
}
|
||||
for opening in space.get("openings") or []:
|
||||
old = old_openings.get(str(opening.get("id", "")))
|
||||
if old and old.get("host") is not None and opening.get("host") is None:
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space.get('id', '')}; opening={opening.get('id', '')}; host removed"
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
@@ -135,7 +222,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 leading-light choices without rejecting dormant data.
|
||||
"""Validate new/changed light/switch 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
|
||||
@@ -152,16 +239,26 @@ def validate_marker_light_entities(
|
||||
marker, marker_id, old_by_id, old_markers, new_ids,
|
||||
consumed_old_ids, validate_all,
|
||||
)
|
||||
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.*"
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
def validate_marker_controls(
|
||||
@@ -703,6 +800,15 @@ 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."""
|
||||
@@ -720,6 +826,29 @@ 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
|
||||
|
||||
|
||||
@@ -773,7 +902,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
vol.Required("type"): vol.Any("door", "window", "gate"),
|
||||
vol.Required("type"): vol.Any("door", "window", "gate", "passage"),
|
||||
vol.Required("x"): _GEOM,
|
||||
vol.Required("y"): _GEOM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
@@ -785,6 +914,7 @@ 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,
|
||||
)
|
||||
@@ -885,6 +1015,9 @@ 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(
|
||||
|
||||
@@ -57,7 +57,9 @@ from .virtual_lights import (
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, sanitize_filename,
|
||||
PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, sanitize_filename,
|
||||
validate_opening_passages, validate_partition_opening_hosts,
|
||||
validate_marker_controls, validate_marker_light_entities,
|
||||
validate_marker_value_badges, valid_space_id,
|
||||
)
|
||||
@@ -263,6 +265,7 @@ 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,
|
||||
}
|
||||
)
|
||||
@@ -287,6 +290,7 @@ 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("")),
|
||||
)
|
||||
@@ -1252,7 +1256,9 @@ 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"))
|
||||
except MarkerControlError as err:
|
||||
validate_opening_passages(msg["config"], data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) 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
|
||||
@@ -1362,7 +1368,9 @@ 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"))
|
||||
except MarkerControlError as err:
|
||||
validate_opening_passages(msg["config"], config_data.get("config"))
|
||||
validate_partition_opening_hosts(msg["config"], config_data.get("config"))
|
||||
except (MarkerControlError, OpeningPassageError, PartitionOpeningHostError) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
|
||||
|
||||
@@ -14,11 +14,27 @@ 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'].includes(profile))
|
||||
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const 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(
|
||||
@@ -48,7 +64,10 @@ 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 }) => {
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
@@ -104,6 +123,8 @@ 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?.();
|
||||
@@ -120,6 +141,7 @@ 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,
|
||||
@@ -134,6 +156,7 @@ 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')
|
||||
@@ -155,6 +178,13 @@ 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'
|
||||
@@ -196,6 +226,112 @@ 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';
|
||||
@@ -279,6 +415,10 @@ try {
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
...(planSnapPointer ? {
|
||||
planSnapPointerMs: planSnapPointer.ms,
|
||||
planSnapDiagnostics,
|
||||
} : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -288,6 +428,7 @@ try {
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -306,7 +447,7 @@ try {
|
||||
return result;
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
@@ -319,6 +460,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||||
import { launch } from '../serve.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||||
const BUNDLE = resolve(ROOT, 'dist/houseplan-card.js');
|
||||
const DEMO_BUNDLE = resolve(ROOT, 'demo/srv/assets/houseplan-card.js');
|
||||
const INTEGRATION_BUNDLE = resolve(ROOT, 'custom_components/houseplan/frontend/houseplan-card.js');
|
||||
const SCRIPT = fileURLToPath(import.meta.url);
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
export const DOC_SCREENSHOT_VERSION = 1;
|
||||
export const DOC_SCREENSHOTS = Object.freeze([
|
||||
{
|
||||
id: 'view-desktop', file: '01-view-desktop.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'view-touch', file: '02-view-touch.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true, kiosk: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'space-create', file: '03-space-create.png', fixture: 'empty', noFloors: true,
|
||||
title: 'House Plan', language: 'en', theme: 'dark',
|
||||
viewport: { width: 900, height: 850 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'room-contour-close', file: '04-room-contour-close.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.18, 0.18], [0.40, 0.18], [0.40, 0.40], [0.18, 0.40]],
|
||||
pointer: [0.18, 0.18], cms: [440, 440, 440], cm: 15,
|
||||
},
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'plan-context-tray', file: '05-plan-context-tray.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'plan', editorTray: 'plan-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'device-editor', file: '06-device-editor.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'device-display-preview', file: '06-device-display-preview.png', fixture: 'visual',
|
||||
space: 'golden-lighting', dialog: 'device', deviceId: 'golden-light-two',
|
||||
deviceName: 'Living-room ceiling light', devicePresentationPreview: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 1100 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
{
|
||||
id: 'background-editor', file: '07-background-editor.png', fixture: 'visual',
|
||||
space: 'golden-geometry', mode: 'decor', editorTray: 'decor-selection',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'page',
|
||||
},
|
||||
{
|
||||
id: 'room-card', file: '08-room-card.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', roomMetrics: true,
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1180, height: 900 }, capture: 'room-card',
|
||||
},
|
||||
{
|
||||
id: 'device-info', file: '09-device-info.png', fixture: 'visual',
|
||||
space: 'golden-lighting', mode: 'view', dialog: 'device-info',
|
||||
deviceId: 'golden-light-two', deviceName: 'Living-room ceiling light',
|
||||
title: 'House Plan — synthetic home', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, capture: 'page', expectDialog: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const roomCardClip = (page) => page.evaluate(() => {
|
||||
const card = window.__goldenCard;
|
||||
const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
|
||||
const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
|
||||
if (!target) throw new Error('documentation room card is missing');
|
||||
const rect = target.getBoundingClientRect();
|
||||
const marginX = 80;
|
||||
const marginY = 70;
|
||||
return {
|
||||
x: Math.max(0, rect.left - marginX),
|
||||
y: Math.max(0, rect.top - marginY),
|
||||
width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
|
||||
height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Documentation-only presentation state. Keep these mutations out of the
|
||||
* golden harness: changing that release fixture would invalidate every visual
|
||||
* baseline even though the production component and golden matrix are intact.
|
||||
*/
|
||||
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const card = window.__goldenCard;
|
||||
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
|
||||
|
||||
if (current.title) {
|
||||
card.setConfig({ ...card._config, title: current.title });
|
||||
}
|
||||
|
||||
if (current.roomMetrics) {
|
||||
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
|
||||
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
label_temp: true,
|
||||
label_hum: true,
|
||||
label_lqi: true,
|
||||
label_light: true,
|
||||
};
|
||||
card._cfgEpoch += 1;
|
||||
card._modelCache = null;
|
||||
}
|
||||
|
||||
if (current.fixture === 'empty') {
|
||||
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
|
||||
card._cfgEpoch += 1;
|
||||
card._modelCache = null;
|
||||
card._space = '';
|
||||
card._onboardingShown = true;
|
||||
card.hass = { ...card.hass, floors: {} };
|
||||
card._openSpaceDialog('create');
|
||||
}
|
||||
|
||||
if (current.dialog === 'device-info') {
|
||||
const device = card._devices.find((item) => item.id === current.deviceId);
|
||||
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
|
||||
card._infoCard = device;
|
||||
}
|
||||
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
if (current.devicePresentationPreview) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const preview = dialog?.querySelector('hp-device-preview');
|
||||
await preview?.updateComplete;
|
||||
if (!body || !preview)
|
||||
throw new Error('documentation device presentation preview is missing');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
body.scrollTop += previewRect.top - bodyRect.top - 180;
|
||||
await frame();
|
||||
const visibleBody = body.getBoundingClientRect();
|
||||
const visiblePreview = preview.getBoundingClientRect();
|
||||
if (visiblePreview.top < visibleBody.top - 1 || visiblePreview.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('documentation viewport does not show the device presentation preview');
|
||||
}
|
||||
|
||||
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
|
||||
}, scenario);
|
||||
|
||||
mkdirSync(OUTPUT, { recursive: true });
|
||||
copyFileSync(BUNDLE, DEMO_BUNDLE);
|
||||
copyFileSync(BUNDLE, INTEGRATION_BUNDLE);
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const browserErrors = [];
|
||||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||||
|
||||
try {
|
||||
const fingerprint = await assertFreshDemoBundle(page, ROOT);
|
||||
const scenarios = {};
|
||||
for (const scenario of DOC_SCREENSHOTS) {
|
||||
await prepareGoldenScenario(page, scenario);
|
||||
const runtime = await applyDocumentationState(page, scenario);
|
||||
if (scenario.expectDialog && !runtime.dialog)
|
||||
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
|
||||
const clip = scenario.capture === 'room-card'
|
||||
? await roomCardClip(page)
|
||||
: await goldenClip(page, scenario.capture);
|
||||
const image = await page.screenshot({
|
||||
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
|
||||
});
|
||||
writeFileSync(resolve(OUTPUT, scenario.file), image);
|
||||
scenarios[scenario.id] = {
|
||||
file: scenario.file,
|
||||
viewport: scenario.viewport,
|
||||
theme: scenario.theme,
|
||||
language: scenario.language,
|
||||
sourceSha256: fingerprint,
|
||||
imageSha256: sha256(image),
|
||||
};
|
||||
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
|
||||
}
|
||||
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
|
||||
const manifest = {
|
||||
version: DOC_SCREENSHOT_VERSION,
|
||||
fixture: 'synthetic-only',
|
||||
sourceFingerprint: fingerprint,
|
||||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||||
command: 'npm run build && node demo/docs/capture.mjs',
|
||||
scenarios,
|
||||
};
|
||||
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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 });
|
||||
}
|
||||
@@ -57,6 +57,11 @@ 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',
|
||||
@@ -122,7 +127,25 @@ const lightingSpace = {
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = () => {
|
||||
const applianceSpace = {
|
||||
id: 'golden-appliance',
|
||||
title: 'Appliance lifecycle',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'none', glow_enabled: false, show_borders: true, show_names: true,
|
||||
sun_rays: false, bg_mode: 'static',
|
||||
},
|
||||
rooms: applianceRooms,
|
||||
walls: wallsFor('appliance', applianceRooms, 15),
|
||||
openings: [],
|
||||
partitions: [],
|
||||
wall_columns: [],
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = (includeAppliance = false) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
@@ -136,7 +159,8 @@ const runtime = () => {
|
||||
// The production projection must preserve them.
|
||||
const layout = {};
|
||||
const areas = Object.fromEntries(
|
||||
[...geometryRooms, ...lightingRooms].map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
[...geometryRooms, ...lightingRooms, ...(includeAppliance ? applianceRooms : [])]
|
||||
.map((room) => [room.area, { area_id: room.area, name: room.name }]),
|
||||
);
|
||||
const add = (id, domain, area, x, y, state, attributes = {}) => {
|
||||
const entityId = `${domain}.${id.replaceAll('-', '_')}`;
|
||||
@@ -171,6 +195,44 @@ const runtime = () => {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -182,9 +244,12 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = () => ({
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
|
||||
config: {
|
||||
spaces: [structuredClone(geometrySpace), structuredClone(lightingSpace)],
|
||||
spaces: [
|
||||
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
||||
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
|
||||
],
|
||||
// A persisted marker is part of the fixture contract for scenarios that
|
||||
// override per-source Glow controls. The device/layout alone are not a
|
||||
// saved marker configuration and must not be silently treated as one.
|
||||
@@ -201,6 +266,10 @@ export const makeVisualMatrixFixture = () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(),
|
||||
counts: VISUAL_MATRIX_COUNTS,
|
||||
...runtime(applianceLifecycle),
|
||||
counts: applianceLifecycle ? {
|
||||
...VISUAL_MATRIX_COUNTS,
|
||||
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
|
||||
rooms: VISUAL_MATRIX_COUNTS.rooms + applianceRooms.length,
|
||||
} : VISUAL_MATRIX_COUNTS,
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 59 KiB |
@@ -1,32 +1,45 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 19,
|
||||
"acceptedAt": "2026-08-14T01:16:59.692Z",
|
||||
"sourceFingerprint": "9c4070fd77873e165069512796ed2379a9abee8632afac520efbf42d0a0515ba",
|
||||
"matrixVersion": 26,
|
||||
"acceptedAt": "2026-08-18T19:24:52.977Z",
|
||||
"sourceFingerprint": "5d50c30316c1e2ec9b9102c7c8f14512fedf610a1a5a43eaa065827ca6e60d13",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"split-corner-wall-before-dark": "3176dc67f54d5309f87c94e1077b4f69eb1db9f660469fbf97953038323430f3",
|
||||
"split-corner-wall-thin-dark": "6da64905a3a4f8e4b4d457e5b20d2d55e0e7c2c601316a088c4bcc6557d35cc6",
|
||||
"split-corner-wall-thick-dark": "494d559aa71ee85f90b8cfa11c1d3087fa123e963dec2b0975e7ce6c1520852a",
|
||||
"isometric-geometry-view-dark": "4816ec4b22211c73765f9fac81741df685202d21df6b0fbc1649b23357086da3",
|
||||
"isometric-geometry-view-light": "8ec81fc4f254ae6ebab55bc5f0ef5325a4e5d327cd1fb53bf5f293f06fbec671",
|
||||
"split-zero-divider-taper-dark": "4c63083b1cb9e1079fa56e6991a035124d0ce22527489712a7a9161f1ccf2107",
|
||||
"isometric-geometry-view-dark": "9bc8eb0da8746bddc0b5245b337eef7477d4ce6fb39ac576cab1e49057fd837c",
|
||||
"isometric-geometry-view-light": "144f1cc3107562bc252cdcb54165f2c92569f4cf3484554148a7fdb78de372ec",
|
||||
"isometric-live-layers-dark": "c841269f672c7ad8b208a549cc87592bd26f2a9e226f793a2b79592b65ee54c6",
|
||||
"isometric-no-borders-dark": "36f972f95704bff81ea1a59bdf3cd2cf7b636ec7871780460e98b23e3ebc3da2",
|
||||
"isometric-touch-kiosk-dark": "fd2e74c5966b4adcf4e66021e2cef781873f9a2b58f16ad19d57fb451ba45dc9",
|
||||
"isometric-touch-kiosk-dark": "745482a797e236058e3ddd56cb5d85d700c9333a3aeb764333addeaba40cf6b0",
|
||||
"isometric-large-warm-remount-dark": "0afc1069d334f10be2d0ed135ee0eb52e9272a1a1ad1224e00f05753b8789d0c",
|
||||
"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",
|
||||
"geometry-view-dark-fit": "438817c56cd8f91ef63778a3d3d22a4ee1fb675064bc24bf613fb752d6c503d9",
|
||||
"geometry-view-light-fit": "667d38fd55a946ea6930b8674c4036e75c261ba4f111f893367ae72ed19500fc",
|
||||
"washer-active-cycle-dark": "7597ffad91165f2f3cc9647e5081c03d4ffd722313bf74f8dcf5fe6dc52d1dcf",
|
||||
"washer-idle-cycle-dark": "6b1de9786f4852659d2d453fa39340626a7944417f88c5a37355b62c458b6ac1",
|
||||
"day-cycle-dawn-dark": "289e6edd6c206308d3257775b2948085a24698398995bc6ce72201745705c58c",
|
||||
"day-cycle-day-dark": "df27409e83f4492d3f69dc6cebf2a6e7799e154698425b17fb3ab8cfddb426a6",
|
||||
"day-cycle-dusk-dark": "9798e9dac27e69727adbe9c9a782a3bd3dc5dd0850e7a49852c069d54b382f9a",
|
||||
"day-cycle-night-dark": "1abf9528ac05dc3a963eb19a8b3ed4b7bb9ce647f7b83dab4fd2e736756cd770",
|
||||
"geometry-plan-editor-dark": "0e7b01e74c82ab5072bffbfaddfaa21b07089662b19f491a7c6783a4152af9fb",
|
||||
"plan-snap-endpoint-light": "6187b35a780db09626bd9b8a06461dc133e4027722e965908811226f260d327a",
|
||||
"plan-snap-line-gaps-dark": "5ae0634f63718be4b2c1fcabd2b474ecbc980fe96a3bdd2987ca8356472b1076",
|
||||
"wall-junctions-plan-preview-light": "a8a04b5c611a880880b6b6d64d10eb50576a5a377c984a5b397075b225739bd5",
|
||||
"wall-junctions-plan-t-dark": "479ab196fb52b332615182401e2680ccfbd4362f25b0a294b481b32195119d72",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "4393770bb97cb051d76f5b2aba9d314a8789a20278ccafb31c048300ccb4d8cf",
|
||||
"geometry-devices-editor-dark": "2ec2647797c71f6c6406f6e33fcbf70d5cb6d36a01a65c70ecabc7ad11e86183",
|
||||
"geometry-decor-editor-dark": "46798a59d99982c4e73596861e20b6e466f6e7aa1d70a26bf917e1bd3bf73cb1",
|
||||
"tray-wide-selection-en": "995477b887a302c25d8a0fe540a1b8ee56369e21100cdb367a3bf37c8dcc99c1",
|
||||
"tray-wide-tool-ru": "0fa205ba48803255f8e0dc51286b7fc151ccfd319dfb932fd4927fff124d0396",
|
||||
"tray-medium-group-en": "b6170877b12de6ff7b03e4c3a0099141a7fa010448bac914ce6dafe321594b83",
|
||||
"tray-medium-selection-ru": "e40dcdadf3ebdbec200175dc6648382daa91a232d081c8add1f6646c7d1a0ee5",
|
||||
"tray-narrow-palette-en": "861adc403fbf0aff1e45b27fc07c4856f3ca409b58ef87cc4a81d870a09dbb9a",
|
||||
"tray-narrow-tool-ru": "a144a1927ab60a45630f95d36d163c9db1e5ff8714558b0703678be0695ec19e",
|
||||
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
@@ -45,15 +58,18 @@
|
||||
"lighting-temp-glow-room-override-dark": "0a35d3508526187ea18e44456cfb8cd9e578a1864e896fad1c3eec2892c753e0",
|
||||
"lighting-manual-auto-spill-overlap-dark": "6324dbe2079a255e7a194720c8c19f210549ac734e564270bc1373e6385b9cac",
|
||||
"hover-over-glow-dark": "fc14ba6f6b670e61c0fb5be277e67551ea2da7a06b5c167a8c2c989f1de08910",
|
||||
"hover-nested-room-dark": "6c09526ad885c4555063def5b43287b41124a81d90972c425084dba6e622d055",
|
||||
"hover-nested-room-dark": "2db78e53a76fa9b7cdc4597f23fc2c8ae439e5a56d82895109ca968d73075640",
|
||||
"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",
|
||||
"toggle-entity-dialog-desktop-en": "013e2d01382435d995e8deece19e07e9bcced4fe80952cbab765e642b3d641d6",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
|
||||
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
|
||||
"decor-color-popover-mobile-ru": "91837c71c52eb5447d9d1754038b559838af595d41f44e6587ea8f4c40f96ed9",
|
||||
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
|
||||
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
|
||||
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 335 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: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -1,7 +1,9 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture()
|
||||
: makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle });
|
||||
|
||||
const themeVars = {
|
||||
dark: {
|
||||
@@ -50,11 +52,39 @@ async function stableEnvironment(page, scenario) {
|
||||
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
export function prepareGoldenFixture(scenario) {
|
||||
const fixture = fixtureFor(scenario.fixture);
|
||||
const fixture = fixtureFor(scenario);
|
||||
if (scenario.cornerSplitWall) {
|
||||
const stage = scenario.cornerSplitWall;
|
||||
if (!['before', 'thin', 'thick'].includes(stage))
|
||||
if (!['before', 'thin', 'thick', 'zero-taper'].includes(stage))
|
||||
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
|
||||
if (stage === 'zero-taper') {
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.405];
|
||||
const br = [0.90, 0.80], notchBottom = [0.60, 0.80];
|
||||
const notch = [0.60, 0.40], bl = [0.10, 0.40];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
name: 'Zero-depth angled Split',
|
||||
rooms: [
|
||||
{ id: 'zero-divider-main', name: 'Main room', area: null,
|
||||
poly: [a, tr, split, notch, bl] },
|
||||
{ id: 'zero-divider-child', name: 'New room', area: null,
|
||||
poly: [split, br, notchBottom, notch] },
|
||||
],
|
||||
walls: [
|
||||
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
|
||||
entry(br, notchBottom, 15), entry(notchBottom, notch, 15),
|
||||
entry(notch, bl, 15), entry(bl, a, 15),
|
||||
],
|
||||
settings: {
|
||||
show_borders: true, show_names: false,
|
||||
fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 },
|
||||
},
|
||||
});
|
||||
return fixture;
|
||||
}
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
|
||||
const br = [0.90, 0.90], bl = [0.10, 0.90];
|
||||
const entry = (from, to, cm) => ({
|
||||
@@ -79,6 +109,36 @@ export function prepareGoldenFixture(scenario) {
|
||||
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
|
||||
});
|
||||
}
|
||||
if (scenario.wallJunctions) {
|
||||
const a = [0.06, 0.06], tr = [0.94, 0.06], br = [0.94, 0.94], bl = [0.06, 0.94];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Wall junctions',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
rooms: [{ id: 'junction-room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
|
||||
walls: [entry(a, tr, 10), entry(tr, br, 10), entry(br, bl, 10), entry(bl, a, 10)],
|
||||
partitions: [
|
||||
{ id: 'junction-l-a', a: [0.16, 0.25], b: [0.38, 0.25], cm: 18 },
|
||||
{ id: 'junction-l-b', a: [0.38, 0.25], b: [0.38, 0.46], cm: 30 },
|
||||
{ id: 'junction-oblique-a', a: [0.58, 0.22], b: [0.78, 0.38], cm: 22 },
|
||||
{ id: 'junction-oblique-b', a: [0.78, 0.38], b: [0.62, 0.52], cm: 14 },
|
||||
{ id: 'junction-t-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
|
||||
{ id: 'junction-t-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
|
||||
{ id: 'junction-room-branch', a: [0.30, 0.82], b: [0.30, 0.94], cm: 18 },
|
||||
],
|
||||
room_drafts: [{
|
||||
id: 'junction-draft', points: [[0.16, 0.54], [0.30, 0.54], [0.30, 0.64]],
|
||||
segments: [{ cm: 12 }, { cm: 20 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
});
|
||||
}
|
||||
const requireSpace = () => {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
@@ -89,15 +149,18 @@ export function prepareGoldenFixture(scenario) {
|
||||
throw new Error(`golden deviceName references missing device: ${scenario.deviceId || '<empty>'}`);
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|
||||
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|
||||
|| typeof scenario.northDeg === 'number') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
|
||||
...(scenario.bgMode ? { bg_mode: scenario.bgMode } : {}),
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
|
||||
...(typeof scenario.northDeg === 'number' ? { north_deg: scenario.northDeg } : {}),
|
||||
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
|
||||
};
|
||||
}
|
||||
@@ -282,6 +345,15 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig(cardConfig);
|
||||
host.replaceChildren(card);
|
||||
if (scenario.testOnlyLabsSnapshot) {
|
||||
if (!scenario.labs?.length || typeof card._onLabsSnapshot !== 'function') {
|
||||
throw new Error(`invalid test-only Labs contract: ${scenario.id}`);
|
||||
}
|
||||
// connectedCallback has already received the real (expired) registry
|
||||
// snapshot. Inject only the renderer fixture before hass/model boot so
|
||||
// fit and warm-remount follow the former live-Labs lifecycle exactly.
|
||||
card._onLabsSnapshot({ active: Object.freeze([...scenario.labs]), space: '' });
|
||||
}
|
||||
card.hass = hassFor();
|
||||
await until(() => card._loadOk && card._model?.length === fixture.config.spaces.length);
|
||||
await card.updateComplete;
|
||||
@@ -312,12 +384,67 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
card._applyView(scenario.zoom, 500, 500);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.wallJunctionPreview) {
|
||||
const { path, pointer, cms, cm } = scenario.wallJunctionPreview;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!Array.isArray(path) || !path.length || !path.every(validPoint)
|
||||
|| !validPoint(pointer) || !Array.isArray(cms) || !cms.every(Number.isFinite)
|
||||
|| !(Number(cm) > 0)) {
|
||||
throw new Error(`invalid golden wallJunctionPreview contract: ${scenario.id}`);
|
||||
}
|
||||
card._tool = 'draw';
|
||||
card._activeDraftId = null;
|
||||
card._path = path.map((point) => [point[0] * 1000, point[1] * card._spaceH]);
|
||||
card._draftSegmentCms = [...cms];
|
||||
card._drawWallField = String(cm);
|
||||
card._cursorPt = [pointer[0] * 1000, pointer[1] * card._spaceH];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (!card.renderRoot.querySelector('.drawwall-preview'))
|
||||
throw new Error(`golden wall junction preview did not render: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.planSnap) {
|
||||
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!['draw', 'partition'].includes(tool) || !validPoint(pointer)
|
||||
|| (anchor != null && !validPoint(anchor))
|
||||
|| !['endpoint', 'line'].includes(expectedKind)) {
|
||||
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
|
||||
}
|
||||
card._tool = tool;
|
||||
card._activeDraftId = null;
|
||||
card._path = anchor ? [[anchor[0] * 1000, anchor[1] * card._spaceH]] : [];
|
||||
card._clearPlanSnapHover();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const svgRoot = card.renderRoot.querySelector('.stage svg');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
|
||||
.matrixTransform(svgRoot.getScreenCTM());
|
||||
stage.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerId: 992, pointerType: 'mouse',
|
||||
clientX: screen.x, clientY: screen.y,
|
||||
}));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
const active = overlay?.querySelector('.plan-snap-node[data-active="true"]');
|
||||
if (!overlay || active?.getAttribute('data-kind') !== expectedKind
|
||||
|| overlay.querySelectorAll('.plan-snap-node[data-active="true"]').length !== 1) {
|
||||
throw new Error(`golden plan snap candidate did not render: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.openingPreview) {
|
||||
const { type, pointer } = scenario.openingPreview;
|
||||
if (!['window', 'door', 'gate'].includes(type)
|
||||
@@ -453,6 +580,25 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (visibleRole.top < visibleBody.top - 1 || visibleRadius.bottom > visibleBody.bottom + 1)
|
||||
throw new Error('golden viewport does not show the complete device light-source controls');
|
||||
}
|
||||
if (scenario.deviceToggleEntity) {
|
||||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||||
const body = dialog?.querySelector('.body');
|
||||
const select = dialog?.querySelector('#marker-toggle-entity');
|
||||
const warning = dialog?.querySelector('.markertoggleentity [role="status"]');
|
||||
const childLock = 'switch.golden_washer_child_lock';
|
||||
if (!body || !select || select.options.length !== 3)
|
||||
throw new Error('golden toggle-entity selector is incomplete');
|
||||
if (scenario.deviceToggleEntity === 'selected'
|
||||
&& (select.value !== childLock || warning))
|
||||
throw new Error('golden selected toggle entity is not projected');
|
||||
if (scenario.deviceToggleEntity === 'stale'
|
||||
&& (select.value !== '' || !warning?.textContent?.includes('switch.golden_washer_removed')))
|
||||
throw new Error('golden stale toggle entity warning is missing');
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const selectRect = select.getBoundingClientRect();
|
||||
body.scrollTop += selectRect.top - bodyRect.top - 12;
|
||||
await frame();
|
||||
}
|
||||
if (scenario.openHelp) {
|
||||
const help = card.renderRoot.querySelector(`hp-help[data-help-key="${scenario.openHelp}"]`);
|
||||
await help?.updateComplete;
|
||||
@@ -472,6 +618,13 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await dialog?.updateComplete;
|
||||
dialog?.renderRoot?.querySelector('.close')?.focus();
|
||||
}
|
||||
} else if (scenario.dialog === 'backup-export-plan-only') {
|
||||
card._openBackupExport();
|
||||
card._backupExportDialog = {
|
||||
...card._backupExportDialog, kind: 'space', planOnly: true,
|
||||
};
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
|
||||
const full = scenario.dialog === 'backup-full';
|
||||
card._backupImportDialog = {
|
||||
@@ -479,10 +632,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
size: 12345,
|
||||
token: 'golden-token',
|
||||
preview: {
|
||||
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
|
||||
kind: full ? 'full' : 'space', plan_only: !full,
|
||||
source: full ? 'foreign' : 'same',
|
||||
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
|
||||
duplicates: full ? 0 : 2,
|
||||
counts: { spaces: 1, rooms: 4, markers: full ? 12 : 0, layout: full ? 15 : 4 },
|
||||
duplicates: 0,
|
||||
confirmation_required: full,
|
||||
content: full
|
||||
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
|
||||
@@ -510,6 +664,17 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
trigger.click();
|
||||
await picker.updateComplete;
|
||||
}
|
||||
if (scenario.dayCycle) {
|
||||
const environment = card.renderRoot.querySelector('.hp-day-cycle-env');
|
||||
const active = card.renderRoot.querySelector('.hp-day-cycle-bg.active');
|
||||
const paper = card.renderRoot.querySelector('.hp-paperg');
|
||||
if (environment?.dataset.dayCyclePhase !== scenario.dayCycle.phase
|
||||
|| active?.dataset.dayCycleLayer !== scenario.dayCycle.phase
|
||||
|| !active?.getAttribute('style')?.includes(scenario.dayCycle.top)
|
||||
|| !paper) {
|
||||
throw new Error(`golden day-cycle contract did not render: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
await document.fonts?.ready;
|
||||
await frame();
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 19;
|
||||
export const GOLDEN_MATRIX_VERSION = 26;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
// The product flag expires at 1.65.0 by contract. Golden keeps the dormant
|
||||
// renderer covered through an explicit card-only snapshot; this never changes
|
||||
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
|
||||
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
@@ -14,29 +18,85 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-zero-divider-taper-dark', fixture: 'visual', space: 'golden-zero-divider',
|
||||
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', showBorders: false, fillMode: 'custom',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, showBorders: false, fillMode: 'custom',
|
||||
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', kiosk: true,
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, kiosk: true,
|
||||
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
|
||||
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', warmRemount: true,
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture, warmRemount: true,
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'washer-active-cycle-dark', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', mode: 'view',
|
||||
stateOverrides: { 'sensor.golden_washer_status': { state: 'start' } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'washer-idle-cycle-dark', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', mode: 'view',
|
||||
stateOverrides: { 'sensor.golden_washer_status': { state: 'done' } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-dawn-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'dawn', top: '#aabdd1' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 95, elevation: -2, rising: true } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-day-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'day', top: '#dce9ef' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 180, elevation: 42, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-dusk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'dusk', top: '#48536c' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 265, elevation: -2, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'day-cycle-night-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
bgMode: 'daynight', dayCycle: { phase: 'night', top: '#111a27' },
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: -12, rising: false } } },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-line-gaps-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: {
|
||||
tool: 'draw', anchor: [0.70, 0.455], pointer: [0.482, 0.455], expectedKind: 'line',
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-preview-light', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.16, 0.15], [0.38, 0.15]], pointer: [0.38, 0.25], cms: [12], cm: 24,
|
||||
},
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-t-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.50, 0.54]], pointer: [0.50, 0.70], cms: [], cm: 24,
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-view-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
// complete 90 cm door preset while still proving rotation, inner-face
|
||||
@@ -119,8 +179,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
// The golden screenshot is backed by a second, sun-layer-hidden capture.
|
||||
// A real painted ray must account for enough changed pixels; DOM-only
|
||||
// presence or an accidentally accepted empty baseline is not sufficient.
|
||||
glowEnabled: false, allLightsOff: true,
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: 24 } } },
|
||||
glowEnabled: false, allLightsOff: true, northDeg: 90,
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 270, elevation: 24 } } },
|
||||
sunRayPixels: { minPixels: 500, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...sunWindow },
|
||||
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
@@ -188,6 +248,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
deviceName: 'Контроллер освещения гостиной с намеренно очень длинным названием',
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
{ id: 'toggle-entity-dialog-desktop-en', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
|
||||
deviceToggleEntity: 'selected', markerOverrides: [{
|
||||
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
|
||||
toggle_entity: 'switch.golden_washer_child_lock',
|
||||
}],
|
||||
language: 'en', theme: 'light', viewport: { width: 1180, height: 1100 }, ...page },
|
||||
{ id: 'toggle-entity-dialog-mobile-ru', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-appliance', dialog: 'device', deviceId: 'golden-washer',
|
||||
deviceToggleEntity: 'stale', markerOverrides: [{
|
||||
id: 'golden-washer', binding: 'device:golden-washer', tap_action: 'toggle',
|
||||
toggle_entity: 'switch.golden_washer_removed',
|
||||
}],
|
||||
language: 'ru', theme: 'dark', viewport: { width: 390, height: 1000 }, ...page },
|
||||
{ id: 'device-help-popover-light-ru', fixture: 'visual', space: 'golden-lighting',
|
||||
dialog: 'device', deviceId: 'golden-light-two', deviceLightControls: true,
|
||||
openHelp: 'marker.glow_mode.help', helpTextRegion: { key: 'marker.glow_mode.help', minPixels: 30 },
|
||||
@@ -198,6 +272,9 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-full', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'backup-plan-only-export-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-export-plan-only', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-space', language: 'ru', theme: 'light',
|
||||
viewport: { width: 390, height: 820 }, ...page },
|
||||
|
||||
@@ -4,14 +4,25 @@
|
||||
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
|
||||
40 columns and 500 decor objects on three floors.
|
||||
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. It enables
|
||||
`hp-labs=iso`, selects iso per fixture space, measures the View toggle and
|
||||
records the capped `isoGeometry` cache. A comparison SHA predating #89 remains
|
||||
flat while reporting the same profile. The dedicated
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. Because the
|
||||
product flag expires at 1.65.0, the runner injects an explicit test-only Labs
|
||||
snapshot, selects iso per fixture space, measures the View toggle and records
|
||||
the capped `isoGeometry` cache. A comparison SHA predating #89 remains flat
|
||||
while reporting the same profile. The dedicated
|
||||
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
|
||||
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
|
||||
budgets of the original profile. The same 60-room/60-partition fixture gains
|
||||
six saved open outlines, renders the Plan snap overlay, and sends 120 real
|
||||
pointer moves across endpoint, line and miss targets. Candidate bundles fail
|
||||
inside the runner if the static DOM or geometry cache grows, more than one
|
||||
active node appears, endpoint/line paths are not both exercised, or config and
|
||||
websocket traffic change. Its dedicated budget retains every original timing,
|
||||
heap and cache ceiling and adds the measured pointer series plus a one-entry
|
||||
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
@@ -110,6 +121,7 @@ Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
```
|
||||
|
||||
A local report is diagnostic only; it cannot replace the CI comparison.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-plan-snap-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 200,
|
||||
"hardMaxMs": 2500
|
||||
},
|
||||
"firstStableRenderMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 3000
|
||||
},
|
||||
"planSnapPointerMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 5000
|
||||
},
|
||||
"spaceSwitchMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1500
|
||||
},
|
||||
"stateUpdateMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 75,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"resizePreviewMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.4,
|
||||
"noiseAllowanceMs": 150,
|
||||
"hardMaxMs": 2000
|
||||
},
|
||||
"panZoomMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 60,
|
||||
"hardMaxMs": 500
|
||||
},
|
||||
"settingsDialogMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.5,
|
||||
"noiseAllowanceMs": 100,
|
||||
"hardMaxMs": 1000
|
||||
},
|
||||
"switchCycleMs": {
|
||||
"stat": "median",
|
||||
"maxRegressionRatio": 0.35,
|
||||
"noiseAllowanceMs": 250,
|
||||
"hardMaxMs": 7000
|
||||
}
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.3,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.35,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.3,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.75,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -33,24 +33,38 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_gridPitch',
|
||||
'_loadOk',
|
||||
'_model',
|
||||
'_path',
|
||||
'_rszDrag',
|
||||
'_serverCfg',
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
// A comparison SHA before #89 is intentionally flat; the isometric runner
|
||||
// checks these two members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze(['_isoGeometryCache', '_setProjection']),
|
||||
// checks these members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze([
|
||||
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
|
||||
'_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_draftSegmentCms: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_model: 'array',
|
||||
_onLabsSnapshot: 'function',
|
||||
_offerWallFaces: 'function',
|
||||
_path: 'array',
|
||||
_isoGeometryCache: 'map',
|
||||
_planSnapGeometryCache: 'object',
|
||||
_roomDialog: 'boolean',
|
||||
_setProjection: 'function',
|
||||
_serverCfg: 'object',
|
||||
_tool: 'string',
|
||||
_wallFaceGraphCache: 'array',
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Three stills of the daynight scale (noon/sunset/night) for the white-day review.
|
||||
// Four stills of the #146 day-cycle environment.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const { page, browser } = await launch({ width: 700, height: 700 }, 1);
|
||||
@@ -16,21 +16,22 @@ await page.evaluate(async () => {
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), north_deg: 0, sun_rays: true, bg_mode: 'daynight' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
});
|
||||
const shot = async (az, el, name) => {
|
||||
await page.evaluate(async ([az, el]) => {
|
||||
const shot = async (az, el, rising, name) => {
|
||||
await page.evaluate(async ([az, el, rising]) => {
|
||||
const c = window.__card;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
attributes: { azimuth: az, elevation: el, rising },
|
||||
} } };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
}, [az, el]);
|
||||
}, [az, el, rising]);
|
||||
await page.waitForTimeout(300);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/${name}.png` });
|
||||
};
|
||||
await shot(180, 60, 'dn_noon');
|
||||
await shot(265, 3, 'dn_sunset');
|
||||
await shot(0, -20, 'dn_night');
|
||||
await shot(95, -2, true, 'dn_dawn');
|
||||
await shot(180, 60, false, 'dn_noon');
|
||||
await shot(265, -2, false, 'dn_sunset');
|
||||
await shot(0, -20, false, 'dn_night');
|
||||
await browser.close();
|
||||
console.log('shots done');
|
||||
|
||||
@@ -19,6 +19,40 @@ const result = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const exportRadios = root().querySelectorAll('input[name="backup-kind"]').length;
|
||||
const exportWarning = !!root().querySelector('.backupwarn');
|
||||
const planOnlyHiddenForFull = !root().querySelector('.backupplanonly');
|
||||
root().querySelector('input[name="backup-kind"][value="space"]')?.click();
|
||||
await card.updateComplete;
|
||||
const planOnly = root().querySelector('.backupplanonly input[type="checkbox"]');
|
||||
planOnly?.click();
|
||||
await card.updateComplete;
|
||||
const planOnlySpace = {
|
||||
visible: !!planOnly,
|
||||
checked: card._backupExportDialog?.planOnly === true,
|
||||
labelled: root().querySelector('.backupplanonly')?.textContent
|
||||
.includes(card._t('backup.plan_only')) === true,
|
||||
};
|
||||
root().querySelector('input[name="backup-kind"][value="full"]')?.click();
|
||||
await card.updateComplete;
|
||||
const planOnlyResetForFull = card._backupExportDialog?.planOnly === false
|
||||
&& !root().querySelector('.backupplanonly');
|
||||
|
||||
let sentExport = null;
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
sentExport = message;
|
||||
return { document: { smoke: true }, filename: 'plan-only-smoke.json' };
|
||||
},
|
||||
};
|
||||
card._backupExportDialog = {
|
||||
kind: 'space', planOnly: true, busy: false, error: '',
|
||||
};
|
||||
await card._runBackupExport();
|
||||
const planOnlyRequest = {
|
||||
kind: sentExport?.kind,
|
||||
planOnly: sentExport?.plan_only,
|
||||
space: sentExport?.space_id,
|
||||
};
|
||||
|
||||
card._backupExportDialog = null;
|
||||
card._backupImportDialog = {
|
||||
@@ -40,7 +74,31 @@ const result = await page.evaluate(async () => {
|
||||
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
|
||||
<= root().querySelector('hp-dialog').clientWidth,
|
||||
};
|
||||
return { group, actions, keyboardImport, exportRadios, exportWarning, importSafe };
|
||||
card._backupImportDialog = {
|
||||
filename: 'houseplan-space-plan-only.json', size: 2048, token: 'plan-only',
|
||||
preview: {
|
||||
kind: 'space', plan_only: true, source: 'same',
|
||||
created_at: '2026-08-17T00:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 2, markers: 0, layout: 2 },
|
||||
bindings: { device: 0, entity: 0, virtual: 0, active: 0, disabled: 0, missing: 0 },
|
||||
duplicates: 0, confirmation_required: false, content: [],
|
||||
},
|
||||
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
|
||||
confirmMissing: false, busy: false, error: '',
|
||||
};
|
||||
await card.updateComplete;
|
||||
const planOnlyPreview = {
|
||||
visible: root().querySelector('.backupplanonlystatus')?.textContent
|
||||
=== card._t('backup.plan_only_preview'),
|
||||
noDuplicatePolicy: !root().querySelector('.backupchoices'),
|
||||
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
|
||||
<= root().querySelector('hp-dialog').clientWidth,
|
||||
};
|
||||
return {
|
||||
group, actions, keyboardImport, exportRadios, exportWarning,
|
||||
planOnlyHiddenForFull, planOnlySpace, planOnlyResetForFull, planOnlyRequest,
|
||||
importSafe, planOnlyPreview,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(result, {
|
||||
@@ -49,6 +107,11 @@ checkAll(result, {
|
||||
keyboardImport: true,
|
||||
exportRadios: 2,
|
||||
exportWarning: true,
|
||||
planOnlyHiddenForFull: true,
|
||||
planOnlySpace: { visible: true, checked: true, labelled: true },
|
||||
planOnlyResetForFull: true,
|
||||
planOnlyRequest: { kind: 'space', planOnly: true, space: 'f1' },
|
||||
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
|
||||
planOnlyPreview: { visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true },
|
||||
});
|
||||
await finish(browser, result);
|
||||
|
||||
@@ -103,6 +103,25 @@ const res = await page.evaluate(async () => {
|
||||
await settleMode();
|
||||
out.viewPaintedAgain = (await waitStageBg((value) => value === rgb('#0a2a4a'))) === rgb('#0a2a4a');
|
||||
|
||||
// The environment, plan and zoom badge own explicit stacking levels. DOM
|
||||
// order alone let an opaque cross-fade layer cover the plan, while raising
|
||||
// only the plan hid the later percentage badge behind it.
|
||||
const previousBgMode = c._serverCfg.settings.bg_mode;
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._zoom = 2.5;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const zoomWrap = sr().querySelector('.zoomwrap');
|
||||
const zoomBadge = sr().querySelector('.zoombadge');
|
||||
const environment = sr().querySelector('.hp-day-cycle-env');
|
||||
out.dayCycleStaysBehindPlan = getComputedStyle(environment).zIndex === '0'
|
||||
&& getComputedStyle(zoomWrap).zIndex === '1';
|
||||
out.zoomBadgeStaysAbovePlan = !!zoomBadge
|
||||
&& getComputedStyle(zoomBadge).zIndex === '12';
|
||||
if (previousBgMode === undefined) delete c._serverCfg.settings.bg_mode;
|
||||
else c._serverCfg.settings.bg_mode = previousBgMode;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
|
||||
// 9) kiosk instance respects the setting
|
||||
const k = document.createElement('houseplan-card');
|
||||
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
@@ -112,19 +131,27 @@ const res = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
// the kiosk instance re-reads the pristine demo config — inject the color
|
||||
// into ITS copy; what matters here is the kiosk rendering path
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
|
||||
k.hass = { ...c.hass };
|
||||
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a', bg_mode: 'daynight' };
|
||||
k.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 42, rising: false },
|
||||
} } };
|
||||
k.requestUpdate();
|
||||
await k.updateComplete;
|
||||
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
|
||||
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
|
||||
out.kioskSharesDay = kStage?.classList.contains('phase-day')
|
||||
&& kStage.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
|
||||
k.remove();
|
||||
|
||||
// 10) the static space-card paints the same background
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
|
||||
const hass2 = { ...c.hass, callWS: async (m) => {
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456', bg_mode: 'daynight' };
|
||||
const hass2 = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 95, elevation: -2, rising: true },
|
||||
} }, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return origWS.call(c.hass, m);
|
||||
@@ -140,6 +167,11 @@ const res = await page.evaluate(async () => {
|
||||
await sc.updateComplete;
|
||||
const st = sc.renderRoot.querySelector('.hp-static-stage');
|
||||
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
|
||||
out.staticCardSharesDawn = st?.classList.contains('phase-dawn')
|
||||
&& st.querySelector('.hp-day-cycle-env')?.dataset.dayCycleSource === 'sun'
|
||||
&& st.querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'dawn';
|
||||
out.staticCardLayersStayOrdered = getComputedStyle(st.querySelector('svg')).zIndex === '1'
|
||||
&& getComputedStyle(st.querySelector('.devlayer')).zIndex === '2';
|
||||
// ...and the space override beats the global there too
|
||||
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
|
||||
sc.hass = { ...hass2 };
|
||||
@@ -166,8 +198,8 @@ checkAll(res);
|
||||
// bounding box (section 12), and never the backdrop image rect either
|
||||
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
|
||||
// asserts the new rule on exactly the case that used to be the exception.
|
||||
// The scene colour is visible ONLY around the paper; the 'daynight' night
|
||||
// dims the paper via brightness only.
|
||||
// The scene colour is visible ONLY around the paper. The four-phase
|
||||
// environment changes only outside it and adds an alpha-aware outer outline.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
@@ -210,16 +242,18 @@ const paper = await page.evaluate(async () => {
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
|
||||
c._setMode('view');
|
||||
await upd(); await settleMode();
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
// daynight night: the paper stays pixel-identical and opaque; only its
|
||||
// grouped outer outline and the environment may change.
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._serverCfg.settings.north_deg = 0;
|
||||
c._cfgRev++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 180, elevation: -20 },
|
||||
attributes: { azimuth: 180, elevation: -20, rising: false },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
out.nightDoesNotDimPlan = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
|
||||
out.nightEnvironment = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
|
||||
const pn = sr().querySelector('.stage svg .hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
@@ -268,7 +302,7 @@ const drawn = await page.evaluate(async () => {
|
||||
return cs.stroke === 'none' || cs.strokeOpacity === '0' || cs.strokeWidth === '0px';
|
||||
});
|
||||
// paper first, everything else on top of it (one .hp-paperg group wraps
|
||||
// all paper shapes so the daynight drop shadow composites without seams)
|
||||
// all paper shapes so the day-cycle outline composites without seams)
|
||||
const noplanSvg = sr().querySelector('.stage.noplan svg');
|
||||
const noplanLayers = [...noplanSvg.children].find((node) => node.querySelector?.('.hp-paperg')) || noplanSvg;
|
||||
const first = [...noplanLayers.children].find((node) => node.tagName.toLowerCase() !== 'defs');
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// #140: opening and physical-object properties share the existing medium
|
||||
// dialog shell. Measure real localized buttons rather than merely checking
|
||||
// the `wide` attribute, because the contract is usable spare width plus safe
|
||||
// wrapping when the viewport becomes narrow.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const DESKTOP_WIDTH = 1000;
|
||||
const NARROW_WIDTH = 320;
|
||||
const { page, browser } = await launch({ width: DESKTOP_WIDTH, height: 820 }, 1);
|
||||
|
||||
const desktop = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
card._serverStorage = true;
|
||||
card._serverCfg = {
|
||||
spaces: [{
|
||||
id: 'dialog-layout', title: 'Dialog layout', cell_cm: 5,
|
||||
view_box: [0, 0, 1, 1], rooms: [], settings: {},
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._space = 'dialog-layout';
|
||||
card._mode = 'plan';
|
||||
card._modelCache = null;
|
||||
card._saveConfig = () => {};
|
||||
await settle();
|
||||
|
||||
const layout = (kind, language) => {
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
const dialog = root.querySelector('hp-dialog');
|
||||
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
||||
const footer = dialog?.querySelector('.dialog-action-footer');
|
||||
const danger = footer?.querySelector('.dialog-action-danger');
|
||||
const commit = footer?.querySelector('.dialog-action-commit');
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerStyle = footer ? getComputedStyle(footer) : null;
|
||||
const dangerRect = danger?.getBoundingClientRect();
|
||||
const commitRect = commit?.getBoundingClientRect();
|
||||
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
||||
const innerWidth = footerRect && footerStyle
|
||||
? footerRect.width - parseFloat(footerStyle.paddingLeft) - parseFloat(footerStyle.paddingRight)
|
||||
: 0;
|
||||
const contentWidth = (dangerRect?.width || 0) + (commitRect?.width || 0);
|
||||
return {
|
||||
kind,
|
||||
language,
|
||||
widePreset: dialog?.wide === true && dialog.hasAttribute('wide'),
|
||||
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
||||
innerWidth: Number(innerWidth.toFixed(2)),
|
||||
contentWidth: Number(contentWidth.toFixed(2)),
|
||||
spareWidth: Number((innerWidth - contentWidth).toFixed(2)),
|
||||
buttons: buttons.length,
|
||||
oneRow: !!dangerRect && !!commitRect && Math.abs(dangerRect.top - commitRect.top) <= 1,
|
||||
noHorizontalOverflow: !!surface && !!footer
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& footer.scrollWidth <= footer.clientWidth + 1,
|
||||
};
|
||||
};
|
||||
|
||||
const open = async (kind, language) => {
|
||||
card._config = { ...(card._config || {}), language };
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
if (kind === 'opening') {
|
||||
card._openingDialog = {
|
||||
id: 'opening-1', type: 'door', lengthCm: 90,
|
||||
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
||||
x: 200, y: 200, angle: 0,
|
||||
};
|
||||
} else if (kind === 'physical') {
|
||||
card._physicalDialog = {
|
||||
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
||||
};
|
||||
} else {
|
||||
card._openSpaceDialog('edit', 'dialog-layout');
|
||||
}
|
||||
await settle();
|
||||
const result = layout(kind, language);
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
await settle();
|
||||
return result;
|
||||
};
|
||||
|
||||
const metrics = [];
|
||||
for (const language of ['en', 'ru']) {
|
||||
metrics.push(await open('opening', language));
|
||||
metrics.push(await open('physical', language));
|
||||
}
|
||||
const space = await open('space', 'ru');
|
||||
return { metrics, space };
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: NARROW_WIDTH, height: 820 });
|
||||
|
||||
const narrow = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const measure = async (kind) => {
|
||||
card._config = { ...(card._config || {}), language: 'ru' };
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
if (kind === 'opening') {
|
||||
card._openingDialog = {
|
||||
id: 'opening-1', type: 'door', lengthCm: 90,
|
||||
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
||||
x: 200, y: 200, angle: 0,
|
||||
};
|
||||
} else if (kind === 'physical') {
|
||||
card._physicalDialog = {
|
||||
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
||||
};
|
||||
} else if (kind === 'draft') {
|
||||
card._physicalDialog = {
|
||||
kind: 'draft', id: 'draft-1', segment: 0, cm: '12', length: '1 m',
|
||||
};
|
||||
} else {
|
||||
card._openSpaceDialog('edit', 'dialog-layout');
|
||||
}
|
||||
await settle();
|
||||
const root = card.shadowRoot || card.renderRoot;
|
||||
const dialog = root.querySelector('hp-dialog');
|
||||
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
||||
const footer = dialog?.querySelector('.dialog-action-footer');
|
||||
const danger = footer?.querySelector('.dialog-action-danger');
|
||||
const commit = footer?.querySelector('.dialog-action-commit');
|
||||
const surfaceRect = surface?.getBoundingClientRect();
|
||||
const footerRect = footer?.getBoundingClientRect();
|
||||
const footerStyle = footer ? getComputedStyle(footer) : null;
|
||||
const dangerRect = danger?.getBoundingClientRect();
|
||||
const commitRect = commit?.getBoundingClientRect();
|
||||
const innerLeft = footerRect && footerStyle
|
||||
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
|
||||
const innerRight = footerRect && footerStyle
|
||||
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
|
||||
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
||||
const result = {
|
||||
kind,
|
||||
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
||||
insideViewport: !!surfaceRect && surfaceRect.left >= -1
|
||||
&& surfaceRect.right <= innerWidth + 1,
|
||||
noHorizontalOverflow: !!surface && !!footer
|
||||
&& surface.scrollWidth <= surface.clientWidth + 1
|
||||
&& footer.scrollWidth <= footer.clientWidth + 1,
|
||||
buttonsContained: !!footerRect && buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.left >= innerLeft - 1 && rect.right <= innerRight + 1
|
||||
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1;
|
||||
}),
|
||||
wrapped: !!dangerRect && !!commitRect && commitRect.top >= dangerRect.bottom - 1,
|
||||
buttons: buttons.length,
|
||||
};
|
||||
card._openingDialog = null;
|
||||
card._physicalDialog = null;
|
||||
card._spaceDialog = null;
|
||||
await settle();
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
opening: await measure('opening'),
|
||||
physical: await measure('physical'),
|
||||
draft: await measure('draft'),
|
||||
space: await measure('space'),
|
||||
};
|
||||
});
|
||||
|
||||
const checks = {};
|
||||
for (const metric of desktop.metrics) {
|
||||
const key = `${metric.kind}_${metric.language}`;
|
||||
checks[`${key}_medium_shell`] = metric.widePreset
|
||||
&& metric.surfaceWidth >= 499 && metric.surfaceWidth <= 501;
|
||||
checks[`${key}_three_actions_one_row`] = metric.buttons === 3 && metric.oneRow;
|
||||
checks[`${key}_positive_localization_headroom`] = metric.spareWidth > 0;
|
||||
checks[`${key}_no_horizontal_overflow`] = metric.noHorizontalOverflow;
|
||||
}
|
||||
checks.space_dialog_still_uses_medium_shell = desktop.space.widePreset
|
||||
&& desktop.space.surfaceWidth >= 499 && desktop.space.surfaceWidth <= 501;
|
||||
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 3
|
||||
&& desktop.space.oneRow && desktop.space.noHorizontalOverflow;
|
||||
|
||||
for (const kind of ['opening', 'physical']) {
|
||||
const metric = narrow[kind];
|
||||
checks[`${kind}_narrow_fits_viewport`] = metric.surfaceWidth <= NARROW_WIDTH * 0.94 + 1
|
||||
&& metric.insideViewport && metric.noHorizontalOverflow && metric.buttonsContained;
|
||||
checks[`${kind}_narrow_keeps_responsive_wrap`] = metric.buttons === 3 && metric.wrapped;
|
||||
}
|
||||
checks.draft_four_actions_remain_contained = narrow.draft.buttons === 4
|
||||
&& narrow.draft.insideViewport && narrow.draft.noHorizontalOverflow
|
||||
&& narrow.draft.buttonsContained;
|
||||
checks.space_narrow_not_regressed = narrow.space.buttons === 3
|
||||
&& narrow.space.insideViewport && narrow.space.noHorizontalOverflow
|
||||
&& narrow.space.buttonsContained;
|
||||
|
||||
checkAll(checks);
|
||||
await finish(browser, {
|
||||
checks,
|
||||
desktop: {
|
||||
dialogs: desktop.metrics,
|
||||
longest: desktop.metrics.reduce((longest, metric) => (
|
||||
metric.contentWidth > longest.contentWidth ? metric : longest
|
||||
)),
|
||||
space: desktop.space,
|
||||
},
|
||||
narrow,
|
||||
});
|
||||
@@ -22,7 +22,7 @@ const res = await page.evaluate(async () => {
|
||||
out.fieldTrayIsStageOverlay = field?.closest('.editor-secondary-host')?.closest('.stage')
|
||||
=== sr().querySelector('.stage');
|
||||
const wallsButton = sr().querySelector('.editbar button.on');
|
||||
out.drawButtonNamedRoomOutline = ['Room outline', 'Контур комнаты']
|
||||
out.drawButtonNamedWalls = ['Walls', 'Стены']
|
||||
.includes((wallsButton?.textContent || '').trim());
|
||||
|
||||
// draw a small room away from existing rooms (garden/demo f1 may have rooms)
|
||||
|
||||
@@ -5,6 +5,13 @@ const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
c._openSpaceDialog('create');
|
||||
out.newSpaceUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
|
||||
c._spaceDialog = null;
|
||||
c._importQueue = ['Imported floor'];
|
||||
c._openNextImport();
|
||||
out.floorImportUsesDaynight = c._spaceDialog?.bgMode === 'daynight';
|
||||
c._spaceDialog = null;
|
||||
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
|
||||
c._openSettingsDialog(); await c.updateComplete;
|
||||
out.rows = sr().querySelectorAll('.gsrow').length;
|
||||
@@ -67,6 +74,8 @@ checkAll(res, {
|
||||
{ "href": "https://t.me/ha_houseplan", "target": "_blank", "rel": "noopener" },
|
||||
],
|
||||
"saved": {"c": "#ff00ff", "a": 0.5},
|
||||
"newSpaceUsesDaynight": true,
|
||||
"floorImportUsesDaynight": true,
|
||||
"lqiBefore": 7,
|
||||
"lqiAfter": 0,
|
||||
});
|
||||
|
||||
@@ -431,20 +431,44 @@ const rasterFixture = await page.evaluate(async () => {
|
||||
const away = Math.hypot(centre[0] - sourcePos.x, centre[1] - sourcePos.y);
|
||||
const base = Math.atan2(centre[1] - sourcePos.y, centre[0] - sourcePos.x);
|
||||
const half = Math.asin(Math.max(0.02, Math.min(0.9, radius / away)));
|
||||
const at = (angle, distance) => toStage([
|
||||
const atPlan = (angle, distance) => [
|
||||
sourcePos.x + Math.cos(angle) * distance,
|
||||
sourcePos.y + Math.sin(angle) * distance,
|
||||
]);
|
||||
];
|
||||
const far = away + radius + Math.max(10, c._gridPitch);
|
||||
const insidePlan = [atPlan(base, far), atPlan(base, away + radius * 2.2)];
|
||||
const besidePlan = [atPlan(base + half * 4, far), atPlan(base - half * 4, far)];
|
||||
const edgePlan = Array.from({ length: 41 }, (_, index) => {
|
||||
const offset = (index - 20) * 1.0;
|
||||
const angle = base + half + Math.atan2(offset, far);
|
||||
return { offset, point: atPlan(angle, far) };
|
||||
});
|
||||
const distanceToSegment = (point, a, b) => {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length2 = dx * dx + dy * dy;
|
||||
const share = length2 > 0
|
||||
? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / length2))
|
||||
: 0;
|
||||
return Math.hypot(point[0] - (a[0] + dx * share), point[1] - (a[1] + dy * share));
|
||||
};
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const renderedWalls = c._spaceWalls || [];
|
||||
const sampleClearance = Math.max(2, c._gridPitch * 0.5);
|
||||
const liesOnCleanFloor = (point) => {
|
||||
const insideRoom = rooms.some((room) => c._pointInRoom(point, room));
|
||||
const outsideColumn = Math.hypot(point[0] - centre[0], point[1] - centre[1])
|
||||
>= radius + sampleClearance;
|
||||
const outsideWalls = renderedWalls.every((wall) =>
|
||||
distanceToSegment(point, wall.a, wall.b) >= wallDepth / 2 + sampleClearance);
|
||||
return insideRoom && outsideColumn && outsideWalls;
|
||||
};
|
||||
const samplePoints = [...insidePlan, ...besidePlan, ...edgePlan.map((sample) => sample.point)];
|
||||
shadow = {
|
||||
inside: [at(base, far), at(base, away + radius * 2.2)],
|
||||
beside: [at(base + half * 4, far), at(base - half * 4, far)],
|
||||
inside: insidePlan.map(toStage),
|
||||
beside: besidePlan.map(toStage),
|
||||
samplesAreValid: samplePoints.every(liesOnCleanFloor),
|
||||
// Across the shadow edge, one plan unit at a time.
|
||||
edge: Array.from({ length: 41 }, (_, index) => {
|
||||
const offset = (index - 20) * 1.0;
|
||||
const angle = base + half + Math.atan2(offset, far);
|
||||
return { offset, point: at(angle, far) };
|
||||
}),
|
||||
edge: edgePlan.map(({ offset, point }) => ({ offset, point: toStage(point) })),
|
||||
};
|
||||
}
|
||||
return { ready: true, litParts, sourceEid, sourceId: source.id, wallSource, doors, shadow };
|
||||
@@ -546,6 +570,7 @@ if (rasterFixture.ready) {
|
||||
&& metrics.doors.every((door) => door.visibleWidth <= door.openingLength * 2);
|
||||
res.apertureItselfLit = metrics.doors.length === 2
|
||||
&& metrics.doors.every((door) => door.tunnelDelta >= 8);
|
||||
res.shadowSamplesAreValid = rasterFixture.shadow?.samplesAreValid === true;
|
||||
// An opaque body leaves no light behind it, keeps the floor beside it lit,
|
||||
// and the border between the two is a line rather than a soft ramp.
|
||||
res.occluderCastsShadow = !!metrics.shadow
|
||||
@@ -591,6 +616,7 @@ if (rasterFixture.ready) {
|
||||
res.doorwayCarriesLight = false;
|
||||
res.beamWidthBounded = false;
|
||||
res.apertureItselfLit = false;
|
||||
res.shadowSamplesAreValid = false;
|
||||
res.occluderCastsShadow = false;
|
||||
res.shadowEdgeIsCrisp = false;
|
||||
res.sourceInsideWallSuppressed = false;
|
||||
|
||||
@@ -13,6 +13,12 @@ const out = await page.evaluate(async () => {
|
||||
const enable = async () => {
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=iso');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
const active = Object.freeze(['iso']);
|
||||
// The product flag expires at 1.65.0. Keep this renderer/lifecycle smoke
|
||||
// explicit without extending the public Labs registry contract.
|
||||
card._onLabsSnapshot({ active, space: '' });
|
||||
window.__hpLabs = active;
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
|
||||
@@ -11,6 +11,12 @@ const out = await page.evaluate(async () => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof original._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
const testOnlyIso = Object.freeze(['iso']);
|
||||
// The product registry remains expired; this direct snapshot exercises the
|
||||
// dormant Stage 2 renderer and is removed by the real -iso path below.
|
||||
original._onLabsSnapshot({ active: testOnlyIso, space: '' });
|
||||
window.__hpLabs = testOnlyIso;
|
||||
await original.updateComplete;
|
||||
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
@@ -293,6 +299,7 @@ const out = await page.evaluate(async () => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', kiosk });
|
||||
host.replaceChildren(card);
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
card.hass = window.__mkHass();
|
||||
const started = performance.now();
|
||||
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
// #174: a manual-eligible virtual lamp becomes a real, HA-owned light while
|
||||
// another marker controls it. State, actions and touch gestures must all use
|
||||
// the same incoming controller drivers; unlink restores the dormant #107 bit.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1200, height: 900 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const sourceId = 'linked_virtual_lamp';
|
||||
const controllerId = 'linked_wall_relay';
|
||||
const driverId = 'switch.kettle';
|
||||
const spaceId = 'f1';
|
||||
const roomId = 'r2';
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(
|
||||
() => requestAnimationFrame(resolve),
|
||||
));
|
||||
const until = async (predicate, timeout = 6000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('linked-light smoke timed out');
|
||||
await wait(25);
|
||||
}
|
||||
};
|
||||
|
||||
const serverConfig = structuredClone(card._serverCfg);
|
||||
const floor = serverConfig.spaces.find((space) => space.id === spaceId);
|
||||
floor.settings = {
|
||||
...(floor.settings || {}),
|
||||
show_names: true,
|
||||
fill_mode: 'light',
|
||||
glow_enabled: true,
|
||||
label_light: true,
|
||||
};
|
||||
serverConfig.markers = [...(serverConfig.markers || []), {
|
||||
id: controllerId,
|
||||
name: 'Linked wall relay',
|
||||
binding: 'device:d_kettle',
|
||||
tap_action: 'toggle',
|
||||
tap_confirm: false,
|
||||
controls: [`marker:${sourceId}`],
|
||||
space: spaceId,
|
||||
area: 'kitchen',
|
||||
room_id: roomId,
|
||||
}, {
|
||||
id: sourceId,
|
||||
name: 'Linked virtual lamp',
|
||||
binding: 'virtual',
|
||||
is_light: true,
|
||||
tap_action: 'toggle',
|
||||
tap_confirm: false,
|
||||
space: spaceId,
|
||||
area: 'kitchen',
|
||||
room_id: roomId,
|
||||
}];
|
||||
const serverLayout = {
|
||||
...card._layout,
|
||||
[controllerId]: { s: spaceId, x: 0.64, y: 0.28 },
|
||||
[sourceId]: { s: spaceId, x: 0.82, y: 0.28 },
|
||||
};
|
||||
const virtual = {
|
||||
rev: 4,
|
||||
config_rev: card._cfgRev,
|
||||
off: [sourceId],
|
||||
};
|
||||
const serviceCalls = [];
|
||||
let operationalToggleCalls = 0;
|
||||
const baseHass = card.hass;
|
||||
const baseCallWS = baseHass.callWS.bind(baseHass);
|
||||
let testHass;
|
||||
const callWS = async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return {
|
||||
config: serverConfig,
|
||||
rev: virtual.config_rev,
|
||||
can_write: true,
|
||||
virtual_lights: structuredClone(virtual),
|
||||
};
|
||||
if (message.type === 'houseplan/layout/get') return {
|
||||
layout: serverLayout,
|
||||
rev: card._layoutRev,
|
||||
};
|
||||
if (message.type === 'houseplan/virtual_light/toggle') {
|
||||
operationalToggleCalls++;
|
||||
const off = new Set(virtual.off);
|
||||
if (off.has(message.marker_id)) off.delete(message.marker_id);
|
||||
else off.add(message.marker_id);
|
||||
virtual.off = [...off].sort();
|
||||
virtual.rev++;
|
||||
return {
|
||||
marker_id: message.marker_id,
|
||||
on: !off.has(message.marker_id),
|
||||
rev: virtual.rev,
|
||||
};
|
||||
}
|
||||
return baseCallWS(message);
|
||||
};
|
||||
testHass = {
|
||||
...baseHass,
|
||||
states: {
|
||||
...baseHass.states,
|
||||
[driverId]: { ...baseHass.states[driverId], state: 'on' },
|
||||
},
|
||||
callService: async (domain, service, data) => {
|
||||
serviceCalls.push({ domain, service, data: structuredClone(data) });
|
||||
},
|
||||
callWS,
|
||||
};
|
||||
|
||||
card._unsubVirtual?.();
|
||||
card._unsubVirtual = null;
|
||||
card.hass = testHass;
|
||||
await card._reloadConfigOnly(true);
|
||||
card._layout = serverLayout;
|
||||
card._space = spaceId;
|
||||
card._regSignature = '';
|
||||
card._maybeRebuildDevices();
|
||||
card._setMode('view');
|
||||
await until(() => card._devices.some((device) => device.id === sourceId)
|
||||
&& card._devices.some((device) => device.id === controllerId));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
const item = (id) => card._devices.find((device) => device.id === id);
|
||||
const node = (id) => card.renderRoot.querySelector(`.dev[data-id="${id}"]`);
|
||||
const isOn = (id) => card._stateClass(item(id)).includes('on');
|
||||
const roomVisual = () => {
|
||||
const fill = card._resolvedRoomFills(
|
||||
card._spaceModel(), card._spaceDisplayForRender(),
|
||||
).byId.get(roomId);
|
||||
const label = card.renderRoot.querySelector(`.roomlabel[data-id="${roomId}"] .rlm`);
|
||||
return {
|
||||
fill: fill?.color || null,
|
||||
labelExists: !!label,
|
||||
labelLit: !!label?.classList.contains('lit'),
|
||||
labelText: label?.textContent?.trim() || '',
|
||||
glow: card._glowRenderedSources.has(`${spaceId}|${sourceId}`),
|
||||
};
|
||||
};
|
||||
const setDriver = async (state) => {
|
||||
testHass = {
|
||||
...testHass,
|
||||
states: {
|
||||
...testHass.states,
|
||||
[driverId]: { ...testHass.states[driverId], state },
|
||||
},
|
||||
callService: testHass.callService,
|
||||
callWS,
|
||||
};
|
||||
card.hass = testHass;
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
try {
|
||||
await until(() => isOn(sourceId) === (state === 'on'));
|
||||
} catch (error) {
|
||||
throw new Error(`linked driver state did not settle: ${JSON.stringify({
|
||||
expected: state,
|
||||
live: card.hass?.states?.[driverId]?.state,
|
||||
rendered: card._renderPlanHass?.states?.[driverId]?.state,
|
||||
sourceClass: card._stateClass(item(sourceId)),
|
||||
controllerClass: card._stateClass(item(controllerId)),
|
||||
continuity: card._continuity?.state,
|
||||
})}`, { cause: error });
|
||||
}
|
||||
};
|
||||
const click = async (id) => {
|
||||
node(id).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await wait(0);
|
||||
};
|
||||
const targetsOf = (call) => {
|
||||
const value = call?.data?.entity_id;
|
||||
return Array.isArray(value) ? value : value ? [value] : [];
|
||||
};
|
||||
|
||||
const initial = roomVisual();
|
||||
const displayHasLightFill = card._spaceDisplayForRender().fill === 'light';
|
||||
const displayHasLightLabel = card._spaceDisplayForRender().labelLight === true;
|
||||
const initialSourceOn = isOn(sourceId);
|
||||
const initialControllerOn = isOn(controllerId);
|
||||
const linkedManualOffFollowsDriver = isOn(sourceId) && isOn(controllerId)
|
||||
&& initial.glow && initial.labelLit && !!initial.fill;
|
||||
|
||||
// Source click issues a real HA command. Resolving that promise is not an
|
||||
// optimistic state change: every visual waits for the next HA snapshot.
|
||||
await click(sourceId);
|
||||
const sourceCall = serviceCalls.at(-1);
|
||||
const sourceCallsRelay = sourceCall?.service === 'turn_off'
|
||||
&& JSON.stringify(targetsOf(sourceCall)) === JSON.stringify([driverId]);
|
||||
const noOptimisticVisual = isOn(sourceId) && roomVisual().glow;
|
||||
await setDriver('off');
|
||||
await wait(560); // Glow keeps its DOM node for the deliberate fade-out.
|
||||
await card.updateComplete;
|
||||
const off = roomVisual();
|
||||
const offSource = !isOn(sourceId);
|
||||
const offController = !isOn(controllerId);
|
||||
const offTickUpdatesAll = !isOn(sourceId) && !isOn(controllerId)
|
||||
&& !off.glow && !off.labelLit && off.labelText !== initial.labelText
|
||||
&& off.fill !== initial.fill;
|
||||
|
||||
await click(controllerId);
|
||||
const controllerCall = serviceCalls.at(-1);
|
||||
const controllerCallsRelay = controllerCall?.service === 'turn_on'
|
||||
&& JSON.stringify(targetsOf(controllerCall)) === JSON.stringify([driverId]);
|
||||
await setDriver('on');
|
||||
const relit = roomVisual();
|
||||
const relitSource = isOn(sourceId);
|
||||
const relitController = isOn(controllerId);
|
||||
const controllerTickUpdatesAll = relitSource && relitController
|
||||
&& relit.glow && relit.labelLit;
|
||||
|
||||
const beforeAutomation = serviceCalls.length;
|
||||
await setDriver('off');
|
||||
const externalStateUpdateWorks = serviceCalls.length === beforeAutomation
|
||||
&& !isOn(sourceId) && !isOn(controllerId) && !roomVisual().labelLit;
|
||||
|
||||
// One short touch tap is one service call and still waits for HA state.
|
||||
const sourceNode = node(sourceId);
|
||||
const rect = sourceNode.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const pointer = (type, id, target, px = x, py = y, buttons = 0) => target.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
pointerId: id,
|
||||
pointerType: 'touch',
|
||||
clientX: px,
|
||||
clientY: py,
|
||||
button: type === 'pointerdown' ? 0 : -1,
|
||||
buttons,
|
||||
}),
|
||||
);
|
||||
const beforeTap = serviceCalls.length;
|
||||
pointer('pointerdown', 17401, sourceNode, x, y, 1);
|
||||
pointer('pointerup', 17401, sourceNode, x, y, 0);
|
||||
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
await wait(0);
|
||||
const touchTapCallsOnce = serviceCalls.length === beforeTap + 1
|
||||
&& serviceCalls.at(-1)?.service === 'turn_on' && !isOn(sourceId);
|
||||
|
||||
// Long-press opens the existing card; its synthetic click is swallowed.
|
||||
const beforeLongPress = serviceCalls.length;
|
||||
pointer('pointerdown', 17402, sourceNode, x, y, 1);
|
||||
await wait(680);
|
||||
const longPressOpenedInfo = !!card._infoCard;
|
||||
pointer('pointerup', 17402, sourceNode, x, y, 0);
|
||||
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
const longPressNoService = serviceCalls.length === beforeLongPress;
|
||||
card._closeInfoCard();
|
||||
|
||||
const beforeCancel = serviceCalls.length;
|
||||
pointer('pointerdown', 17403, sourceNode, x, y, 1);
|
||||
pointer('pointercancel', 17403, sourceNode, x, y, 0);
|
||||
const pointerCancelNoService = serviceCalls.length === beforeCancel && !isOn(sourceId);
|
||||
|
||||
// A moved one-finger gesture and a two-finger sequence both suppress the
|
||||
// immediately following synthetic click on the marker.
|
||||
const beforePan = serviceCalls.length;
|
||||
pointer('pointerdown', 17404, sourceNode, x, y, 1);
|
||||
pointer('pointermove', 17404, sourceNode, x + 70, y + 45, 1);
|
||||
pointer('pointerup', 17404, sourceNode, x + 70, y + 45, 0);
|
||||
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
const panNoService = serviceCalls.length === beforePan;
|
||||
await wait(20);
|
||||
|
||||
const beforePinch = serviceCalls.length;
|
||||
const stage = card._stageEl;
|
||||
pointer('pointerdown', 17405, sourceNode, x, y, 1);
|
||||
pointer('pointerdown', 17406, stage, x + 100, y, 1);
|
||||
pointer('pointermove', 17405, sourceNode, x - 35, y, 1);
|
||||
pointer('pointermove', 17406, stage, x + 135, y, 1);
|
||||
pointer('pointerup', 17405, sourceNode, x - 35, y, 0);
|
||||
pointer('pointerup', 17406, stage, x + 135, y, 0);
|
||||
sourceNode.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
const pinchNoService = serviceCalls.length === beforePinch;
|
||||
await wait(520);
|
||||
|
||||
// Removing the last relation exposes the preserved manual off-bit. The
|
||||
// same lamp then returns to #107 and uses operational WS, not HA service.
|
||||
const controllerCfg = serverConfig.markers.find((marker) => marker.id === controllerId);
|
||||
controllerCfg.controls = [];
|
||||
await card._reloadConfigOnly(true);
|
||||
card._layout = serverLayout;
|
||||
card._regSignature = '';
|
||||
card._maybeRebuildDevices();
|
||||
await card.updateComplete;
|
||||
await until(() => !isOn(sourceId));
|
||||
const unlinkRestoresManualOff = !isOn(sourceId) && virtual.off.includes(sourceId);
|
||||
const beforeManualService = serviceCalls.length;
|
||||
await click(sourceId);
|
||||
await until(() => isOn(sourceId));
|
||||
const manualToggleRestored = operationalToggleCalls === 1
|
||||
&& serviceCalls.length === beforeManualService && !virtual.off.includes(sourceId);
|
||||
|
||||
return {
|
||||
linkedManualOffFollowsDriver,
|
||||
initialSourceOn,
|
||||
initialControllerOn,
|
||||
initialGlow: initial.glow,
|
||||
initialRoomLabelLit: initial.labelLit,
|
||||
initialRoomLabelExists: initial.labelExists,
|
||||
initialRoomFill: !!initial.fill,
|
||||
displayHasLightFill,
|
||||
displayHasLightLabel,
|
||||
sourceCallsRelay,
|
||||
noOptimisticVisual,
|
||||
offTickUpdatesAll,
|
||||
offSource,
|
||||
offController,
|
||||
offGlowGone: !off.glow,
|
||||
offRoomLabel: !off.labelLit,
|
||||
offRoomFillChanged: off.fill !== initial.fill,
|
||||
controllerCallsRelay,
|
||||
controllerTickUpdatesAll,
|
||||
relitSource,
|
||||
relitController,
|
||||
relitGlow: relit.glow,
|
||||
relitRoomLabel: relit.labelLit,
|
||||
externalStateUpdateWorks,
|
||||
touchTapCallsOnce,
|
||||
longPressOpenedInfo,
|
||||
longPressNoService,
|
||||
pointerCancelNoService,
|
||||
panNoService,
|
||||
pinchNoService,
|
||||
noOperationalToggleWhileLinked: operationalToggleCalls === 1,
|
||||
unlinkRestoresManualOff,
|
||||
manualToggleRestored,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -0,0 +1,172 @@
|
||||
// #157: open passage is negative architecture across editor, Full/Static and
|
||||
// light transport. The pure symbol/static/iso guards have dedicated mutants;
|
||||
// this browser scenario proves the assembled user flow and rendered geometry.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = () => c.shadowRoot || c.renderRoot;
|
||||
const settle = async () => {
|
||||
c._cfgEpoch++;
|
||||
c._lightBarrierCache = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await settle();
|
||||
};
|
||||
const pitch = 1 / 240;
|
||||
const wallKey = (a, b) => {
|
||||
const q = (value) => Math.round(value / pitch) * pitch;
|
||||
let dx = b[0] - a[0]; let dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
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;
|
||||
angle = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
|
||||
};
|
||||
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
sp.rooms = [
|
||||
{ id: 'passage-left', name: 'Left', area: 'living',
|
||||
poly: [[0.1, 0.2], [0.5, 0.2], [0.5, 0.7], [0.1, 0.7]],
|
||||
settings: { fill_mode: 'none' } },
|
||||
{ id: 'passage-right', name: 'Right', area: 'kitchen',
|
||||
poly: [[0.5, 0.2], [0.9, 0.2], [0.9, 0.7], [0.5, 0.7]],
|
||||
settings: { fill_mode: 'none' } },
|
||||
];
|
||||
sp.walls = [{
|
||||
key: wallKey([0.5, 0.2], [0.5, 0.7]),
|
||||
a: [0.5, 0.2], b: [0.5, 0.7], cm: 20,
|
||||
}];
|
||||
sp.openings = [{
|
||||
id: 'passage-main', type: 'passage', x: 0.5, y: 0.45,
|
||||
angle: 90, length: 0.1, future_material: 'stone',
|
||||
}];
|
||||
sp.settings = { ...(sp.settings || {}), show_borders: true, hide_openings: false,
|
||||
fill_mode: 'glow', glow_enabled: true };
|
||||
c._serverCfg.settings = {
|
||||
...(c._serverCfg.settings || {}), glow_radius_cm: 800,
|
||||
};
|
||||
const light = c._devices.find((device) => device.space === c._space
|
||||
&& device.entities.some((entity) => entity.startsWith('light.') && c.hass.states[entity]?.state === 'on'));
|
||||
if (light) c._layout = { ...c._layout, [light.id]: { s: c._space, x: 0.3, y: 0.45 } };
|
||||
|
||||
const litRings = () => [...root().querySelectorAll('defs clipPath[id^="hp-glowclip"] path.glow-lit')]
|
||||
.flatMap((path) => (path.getAttribute('d') || '').split('M').filter(Boolean).map((subpath) => {
|
||||
const values = (subpath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
|
||||
const ring = [];
|
||||
for (let index = 0; index + 1 < values.length; index += 2) ring.push([values[index], values[index + 1]]);
|
||||
return ring;
|
||||
}));
|
||||
const isLit = (point) => litRings().some((ring) => {
|
||||
let inside = false;
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const [x1, y1] = ring[index]; const [x2, y2] = ring[previous];
|
||||
if ((y1 > point[1]) !== (y2 > point[1])
|
||||
&& point[0] < ((x2 - x1) * (point[1] - y1)) / ((y2 - y1) || 1e-12) + x1) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
});
|
||||
|
||||
c._setMode('view');
|
||||
await settleMode();
|
||||
const result = {};
|
||||
result.interiorPassageTransmitsLight = !!light && isLit([650, 450]);
|
||||
result.wallOutsidePassageStaysDark = !!light && !isLit([650, 260]);
|
||||
|
||||
sp.openings[0].type = 'future-opening';
|
||||
await settle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await settle();
|
||||
result.unknownOpeningFailsDark = !isLit([650, 450]);
|
||||
sp.openings[0].type = 'passage';
|
||||
await settle();
|
||||
|
||||
c._setMode('plan');
|
||||
await settleMode();
|
||||
c._activateOpeningPlacement('passage');
|
||||
await settle();
|
||||
result.passagePresetIs90cm = c._openingPreset?.type === 'passage'
|
||||
&& c._openingPreset?.lengthCm === 90;
|
||||
const committed = root().querySelector('.opening[data-kind="passage"]');
|
||||
result.committedHasHitboxButNoSymbol = !!committed?.querySelector('.op-hit')
|
||||
&& committed.querySelectorAll('.op-leaf,.op-arc,.op-glass,line').length === 0;
|
||||
|
||||
// Cancel is a pure draft operation, including stale values hidden by the
|
||||
// passage radio. Save canonicalises those known fields but keeps extensions.
|
||||
Object.assign(sp.openings[0], {
|
||||
type: 'door', contact: 'binary_sensor.window', lock: 'lock.front_door',
|
||||
invert: true, flip_h: true, flip_v: true,
|
||||
});
|
||||
await settle();
|
||||
c._editOpening(c._openingsR.find((opening) => opening.id === 'passage-main'));
|
||||
c._openingDialog = { ...c._openingDialog, type: 'passage' };
|
||||
await settle();
|
||||
const beforeCancel = JSON.stringify(sp.openings[0]);
|
||||
result.warningIsVisibleAndAccessible = !!root().querySelector(
|
||||
'.habindingbanner[role="status"][aria-live="polite"]',
|
||||
);
|
||||
result.passageHidesBindingAndFlipControls = root().querySelectorAll(
|
||||
'hp-dialog .body select, hp-dialog .body input[type="checkbox"]',
|
||||
).length === 0;
|
||||
c._openingDialog = null;
|
||||
await settle();
|
||||
result.cancelKeepsOriginalOpening = JSON.stringify(sp.openings[0]) === beforeCancel;
|
||||
|
||||
c._editOpening(c._openingsR.find((opening) => opening.id === 'passage-main'));
|
||||
c._openingDialog = { ...c._openingDialog, type: 'passage' };
|
||||
c._saveOpening();
|
||||
await settle();
|
||||
const saved = sp.openings.find((opening) => opening.id === 'passage-main');
|
||||
result.saveIsCanonicalAndLossless = saved?.type === 'passage'
|
||||
&& saved.future_material === 'stone'
|
||||
&& ['contact', 'lock', 'invert', 'flip_h', 'flip_v']
|
||||
.every((field) => !Object.prototype.hasOwnProperty.call(saved, field));
|
||||
result.noPassageLockBadge = root().querySelectorAll('.oplock').length === 0;
|
||||
c._openingInfo = c._openingsR.find((opening) => opening.id === 'passage-main');
|
||||
await settle();
|
||||
result.stalePassageInfoIsInert = root().querySelectorAll('.oprow,.lockact').length === 0;
|
||||
c._openingInfo = null;
|
||||
|
||||
// Exercise the actual static custom element with the same immutable snapshot.
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
|
||||
staticCard.hass = c.hass;
|
||||
document.body.append(staticCard);
|
||||
await staticCard.updateComplete;
|
||||
const baseSnap = staticCard._snap || {};
|
||||
staticCard._snap = {
|
||||
...baseSnap,
|
||||
config: c._serverCfg,
|
||||
rev: 157,
|
||||
configFingerprint: 'open-passage-smoke',
|
||||
layout: c._layout,
|
||||
layoutRev: 157,
|
||||
layoutFingerprint: 'open-passage-layout',
|
||||
virtualLights: baseSnap.virtualLights || c._virtualLights,
|
||||
};
|
||||
staticCard._refreshDevices();
|
||||
staticCard.requestUpdate();
|
||||
await staticCard.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
staticCard.requestUpdate();
|
||||
await staticCard.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const staticRoot = staticCard.shadowRoot || staticCard.renderRoot;
|
||||
result.staticCutsAndFillsPassage = !!staticRoot.querySelector('.wallbodies')
|
||||
&& !!staticRoot.querySelector('.static-opening-tunnels [data-kind="passage"]');
|
||||
result.staticDoesNotInventPassageSymbol = staticRoot.querySelectorAll('[data-hp="opening"]').length === 0;
|
||||
staticCard.remove();
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -51,7 +51,7 @@ const res = await page.evaluate(async () => {
|
||||
const epochBeforeMenu = card._cfgEpoch;
|
||||
launcher().click();
|
||||
await settle();
|
||||
out.menuHasThreeTypes = root().querySelectorAll('.editor-group-item').length === 3;
|
||||
out.menuHasFourTypes = root().querySelectorAll('.editor-group-item').length === 4;
|
||||
out.launcherStillDoesNotArm = card._tool !== 'opening' && card._openingPreset == null;
|
||||
out.menuDoesNotResizeStage = Math.abs(
|
||||
root().querySelector('.stage').getBoundingClientRect().height - stageHeightBeforeMenu,
|
||||
@@ -64,6 +64,9 @@ const res = await page.evaluate(async () => {
|
||||
&& card._openingPreset?.type === 'window' && card._openingPreset?.lengthCm === 120;
|
||||
out.doorChoice = await choose('door');
|
||||
out.doorDefault = card._openingPreset?.type === 'door' && card._openingPreset?.lengthCm === 90;
|
||||
out.passageChoice = await choose('passage');
|
||||
out.passageDefault = card._openingPreset?.type === 'passage'
|
||||
&& card._openingPreset?.lengthCm === 90;
|
||||
out.launcherReflectsActivePreset = launcher()?.getAttribute('aria-pressed') === 'true';
|
||||
out.gateChoice = await choose('gate');
|
||||
out.gateDefault = card._openingPreset?.type === 'gate' && card._openingPreset?.lengthCm === 300;
|
||||
@@ -84,6 +87,12 @@ const res = await page.evaluate(async () => {
|
||||
const windowPreview = root().querySelector('.opening-preview[data-kind="window"]');
|
||||
out.windowPreviewGeometry = windowPreview?.querySelectorAll('.op-leaf').length === 2
|
||||
&& windowPreview?.querySelectorAll('.op-arc').length === 2;
|
||||
await choose('passage');
|
||||
card._cursorPt = wallMid;
|
||||
await settle();
|
||||
const passagePreview = root().querySelector('.opening-preview[data-kind="passage"]');
|
||||
out.passagePreviewHasNoSymbol = !!passagePreview
|
||||
&& passagePreview.querySelectorAll('.op-leaf,.op-arc,.op-glass,line').length === 0;
|
||||
await choose('gate');
|
||||
card._cursorPt = wallMid;
|
||||
await settle();
|
||||
@@ -196,8 +205,7 @@ const res = await page.evaluate(async () => {
|
||||
await settle();
|
||||
out.noPreviewAwayFromWalls = !root().querySelector('.opening-preview');
|
||||
|
||||
// Real independent bodies are occluders, not room-owned wall intervals.
|
||||
// They must never become accidental opening-placement targets.
|
||||
// #132: saved independent wall segments are first-class opening hosts.
|
||||
const bodyCentre = card._roomCenter(room);
|
||||
const bodyX = bodyCentre[0] / 1000;
|
||||
const bodyY = bodyCentre[1] / card._spaceH;
|
||||
@@ -208,8 +216,10 @@ const res = await page.evaluate(async () => {
|
||||
card._openingPlacementIntervalsCache = null;
|
||||
card._cursorPt = bodyCentre;
|
||||
await settle();
|
||||
out.partitionIsNotOpeningTarget = !root().querySelector('.opening-preview')
|
||||
&& card._resolveOpeningPlacement(bodyCentre) == null;
|
||||
const partitionCandidate = card._resolveOpeningPlacement(bodyCentre);
|
||||
out.partitionIsOpeningTarget = !!root().querySelector('.opening-preview')
|
||||
&& partitionCandidate?.host?.kind === 'partition'
|
||||
&& partitionCandidate.host.id === 'opening-smoke-partition';
|
||||
card._curSpaceCfg.partitions = [];
|
||||
card._curSpaceCfg.wall_columns = [{
|
||||
id: 'opening-smoke-column', shape: 'square', cm: 30, center: [bodyX, bodyY], angle: 0,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// #113: deleting the final space and receiving an empty plan over WS must be
|
||||
// a supported lifecycle state, not a synthetic SpaceModel or an exception.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
await sleep(40);
|
||||
await card.updateComplete;
|
||||
};
|
||||
const original = structuredClone(card._serverCfg);
|
||||
const first = structuredClone(original.spaces[0]);
|
||||
|
||||
// Exercise the real delete command with exactly one remaining space.
|
||||
card._serverCfg = { ...structuredClone(original), spaces: [first] };
|
||||
card._space = first.id;
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
card._openSpaceDialog('edit', first.id);
|
||||
card._mode = 'plan';
|
||||
card._path = [[100, 100], [200, 100]];
|
||||
card._resumeDraftBySpace = { [first.id]: 'stale-draft' };
|
||||
card._pointers.set(113, { x: 10, y: 10 });
|
||||
card._drag = { id: 'stale-device', sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
|
||||
card._saveConfigDebounced();
|
||||
const confirmBefore = window.confirm;
|
||||
window.confirm = () => true;
|
||||
try {
|
||||
await card._deleteSpace();
|
||||
} finally {
|
||||
window.confirm = confirmBefore;
|
||||
}
|
||||
await settle();
|
||||
|
||||
out.deleteLastRendersEmpty = !!root().querySelector('.empty') && !root().querySelector('.stage');
|
||||
out.deleteLastClearsSelection = card._space === '' && card._spaceModel() === undefined;
|
||||
out.deleteLastAbortsEditorState = card._mode === 'view'
|
||||
&& card._path.length === 0 && card._pointers.size === 0 && card._drag === null
|
||||
&& Object.keys(card._resumeDraftBySpace).length === 0;
|
||||
out.deleteLastClosesEditDialog = card._spaceDialog === null;
|
||||
out.deleteLastCancelsPendingWrite = card._saveConfigDebounced.pending() === false;
|
||||
|
||||
// The empty state still owns global recovery flows.
|
||||
const add = root().querySelector('.empty button.btn.on');
|
||||
add?.click();
|
||||
await settle();
|
||||
out.createFlowSurvivesEmpty = card._spaceDialog?.mode === 'create';
|
||||
|
||||
// Recreate a plan, then reproduce the same transition as a config-updated WS
|
||||
// event. This also arms the once-per-empty cleanup for a second cycle.
|
||||
card._spaceDialog = null;
|
||||
card._serverCfg = structuredClone(original);
|
||||
card._space = original.spaces[0].id;
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
out.recreateRestoresPlan = !!root().querySelector('.stage') && card._spaceModel()?.id === card._space;
|
||||
|
||||
card._mode = 'devices';
|
||||
card._pointers.set(114, { x: 20, y: 20 });
|
||||
card._serverCfg = { ...structuredClone(original), spaces: [] };
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
out.wsEmptyAbortsLiveGesture = card._mode === 'view'
|
||||
&& card._pointers.size === 0 && card._spaceModel() === undefined;
|
||||
|
||||
// Empty plans must remain inert under unrelated HA/render ticks.
|
||||
card._serverCanWrite = false;
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
themes: { ...(card.hass.themes || {}), darkMode: !card.hass.themes?.darkMode },
|
||||
};
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await settle();
|
||||
out.emptySurvivesThemeResizeReadonly = !!root().querySelector('.empty')
|
||||
&& !root().querySelector('.stage') && card._mode === 'view';
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -0,0 +1,147 @@
|
||||
/** Issues #132/#185: independent-wall host lifecycle and structural wall continuity. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 960, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const pointIn = (point, body) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
|
||||
const a = body[i], b = body[j];
|
||||
if (((a[1] > point[1]) !== (b[1] > point[1]))
|
||||
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / ((b[1] - a[1]) || 1e-12) + a[0])
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
card._serverCfg = {
|
||||
spaces: [{
|
||||
id: 'partition-openings', title: 'Partition openings', cell_cm: 5,
|
||||
view_box: [0, 0, 1, 1],
|
||||
rooms: [{
|
||||
id: 'room', name: 'Room', area: null,
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
|
||||
}],
|
||||
partitions: [{ id: 'host', a: [0.25, 0.5], b: [0.75, 0.5], cm: 15 }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._layout = {};
|
||||
card._space = 'partition-openings';
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._saveConfig = () => {
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
card._planSnapGeometryCache = null;
|
||||
card.requestUpdate();
|
||||
};
|
||||
card._setMode('plan');
|
||||
card._activateOpeningPlacement('door');
|
||||
await update();
|
||||
|
||||
const centre = [500, 500];
|
||||
const candidate = card._resolveOpeningPlacement(centre);
|
||||
result.partitionCandidateCarriesStableHost = candidate?.host?.kind === 'partition'
|
||||
&& candidate.host.id === 'host' && Math.abs(candidate.host.t - 0.5) < 1e-9;
|
||||
card._openingClick(centre);
|
||||
card._saveOpening();
|
||||
await update();
|
||||
let space = card._curSpaceCfg;
|
||||
const saved = space.openings?.[0];
|
||||
result.saveMaterializesHostAndCompatibilityGeometry = saved?.host?.id === 'host'
|
||||
&& Math.abs(saved.host.t - 0.5) < 1e-9
|
||||
&& Math.abs(saved.x - 0.5) < 1e-9
|
||||
&& Math.abs(saved.y - 0.5) < 1e-9
|
||||
&& !!root().querySelector(`[data-hp="opening"][data-id="${saved.id}"]`);
|
||||
|
||||
card._physicalBodiesCache = null;
|
||||
const bodies = card._physicalBodiesR();
|
||||
result.hostBodyHasFullDepthOpeningGap = !bodies.some((body) => pointIn(centre, body));
|
||||
|
||||
const base = card._spaceModel().partitions.find((partition) => partition.id === 'host');
|
||||
card._physicalDrag = {
|
||||
pid: 132, kind: 'partition', id: 'host', start: [...base.a],
|
||||
startClient: [0, 0], before: card._geometrySnapshot(), moved: true,
|
||||
base: JSON.parse(JSON.stringify(base)), delta: [50, 25],
|
||||
};
|
||||
card._physicalUp(new PointerEvent('pointerup', { pointerId: 132, bubbles: true }));
|
||||
await update();
|
||||
space = card._curSpaceCfg;
|
||||
const movedOpening = space.openings?.[0];
|
||||
result.rigidHostMoveKeepsTAndMovesProjectionAtomically = !!movedOpening
|
||||
&& Math.abs(movedOpening.host.t - 0.5) < 1e-9
|
||||
&& Math.abs(movedOpening.x - 0.55) < 1e-9
|
||||
&& Math.abs(movedOpening.y - 0.525) < 1e-9;
|
||||
|
||||
card._physicalSel = { kind: 'partition', id: 'host' };
|
||||
card._deletePhysicalSelection();
|
||||
await update();
|
||||
result.deleteRequiresAccessibleListDialog = !!card._partitionDeleteDialog
|
||||
&& root().querySelectorAll('hp-dialog li').length === 1
|
||||
&& !!space.partitions?.length && !!space.openings?.length;
|
||||
card._partitionDeleteDialog = null;
|
||||
await update();
|
||||
result.deleteCancelIsMutationFree = !!space.partitions?.length && !!space.openings?.length;
|
||||
card._physicalSel = { kind: 'partition', id: 'host' };
|
||||
card._deletePhysicalSelection();
|
||||
card._confirmPartitionDelete();
|
||||
await update();
|
||||
result.deleteConfirmCascadesHostAndOpening = !space.partitions && !space.openings;
|
||||
card._undoGeometry();
|
||||
await update();
|
||||
space = card._curSpaceCfg;
|
||||
result.deleteUndoRestoresHostAndOpening = space.partitions?.[0]?.id === 'host'
|
||||
&& space.openings?.[0]?.host?.id === 'host';
|
||||
card._redoGeometry();
|
||||
await update();
|
||||
space = card._curSpaceCfg;
|
||||
result.deleteRedoCascadesAgain = !space.partitions && !space.openings;
|
||||
|
||||
space.partitions = [{ id: 'replacement', a: [0.3, 0.6], b: [0.7, 0.6], cm: 15 }];
|
||||
space.openings = [{
|
||||
id: 'orphan', type: 'door', x: 0.5, y: 0.6, angle: 0, length: 0.1,
|
||||
host: { kind: 'partition', id: 'missing', t: 0.5 },
|
||||
}];
|
||||
card._saveConfig();
|
||||
await update();
|
||||
card._editOpening(card._openingsR[0]);
|
||||
card._rebindPartitionOpening();
|
||||
result.orphanRebindSessionKeepsOriginalIdentity = card._openingRebindId === 'orphan';
|
||||
card._openingClick([500, 600]);
|
||||
card._saveOpening();
|
||||
await update();
|
||||
result.orphanRebindReplacesHostWithoutDuplicate = space.openings.length === 1
|
||||
&& space.openings[0].id === 'orphan'
|
||||
&& space.openings[0].host?.id === 'replacement';
|
||||
|
||||
// Production-bundle regression for #185: a legacy room-wall opening does
|
||||
// not cut the structural segment consumed by #173 room-face detection.
|
||||
space.rooms = [{
|
||||
id: 'room', name: 'Room', area: null,
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
|
||||
}];
|
||||
delete space.partitions;
|
||||
space.openings = [{
|
||||
id: 'legacy-door', type: 'door', x: 0.5, y: 0.1, angle: 0, length: 0.2,
|
||||
}];
|
||||
card._saveConfig();
|
||||
await update();
|
||||
const structural = card._planStructuralGeometrySnapshot().value;
|
||||
result.openingKeepsRoomFaceAxisContinuous = structural.segments.some((segment) =>
|
||||
segment.sourceKind === 'room'
|
||||
&& Math.min(segment.a[0], segment.b[0]) <= 100 + 1e-6
|
||||
&& Math.max(segment.a[0], segment.b[0]) >= 900 - 1e-6
|
||||
&& Math.abs(segment.a[1] - 100) < 1e-6
|
||||
&& Math.abs(segment.b[1] - 100) < 1e-6);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,245 @@
|
||||
/** Issue #137: architectural endpoint/line overlay and exact snap in Plan. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'snap', title: 'Snap', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [
|
||||
{ id: 'left', name: 'Left', area: null,
|
||||
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
|
||||
{ id: 'right', name: 'Right', area: null,
|
||||
poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
|
||||
],
|
||||
openings: [{ id: 'door', type: 'door', x: 0.3, y: 0.1, angle: 0, length: 0.1 }],
|
||||
open_spans: [{ a: [0.5, 0.2], b: [0.5, 0.3] }],
|
||||
room_drafts: [{ id: 'saved', points: [[0.1, 0.6], [0.3, 0.6], [0.3, 0.7]],
|
||||
segments: [{ cm: 15 }, { cm: 15 }] }],
|
||||
partitions: [{ id: 'base-partition', a: [0.6, 0.6], b: [0.9, 0.6], cm: 15 }],
|
||||
wall_columns: [{ id: 'ignored-column', shape: 'square', center: [0.8, 0.35], cm: 25 }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._serverCfg = JSON.parse(JSON.stringify(cfg));
|
||||
card._layout = {};
|
||||
card._space = 'snap';
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._path = [];
|
||||
card._activeDraftId = null;
|
||||
card._clearPlanSnapHover();
|
||||
await update();
|
||||
|
||||
const stage = root().querySelector('.stage');
|
||||
const eventAt = (x, y, type = 'pointermove', extra = {}) => {
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const EventType = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
||||
return new EventType(type, {
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
bubbles: true,
|
||||
pointerId: 41,
|
||||
pointerType: 'mouse',
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
const overlay = () => root().querySelector('[data-hp="plan-snap-overlay"]');
|
||||
const active = () => root().querySelector('.plan-snap-node[data-active="true"]');
|
||||
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
|
||||
|
||||
result.overlayBeforeFirstClick = !!overlay();
|
||||
result.overlayIsPointerTransparent = overlay()?.getAttribute('pointer-events') === 'none'
|
||||
&& getComputedStyle(overlay()).pointerEvents === 'none';
|
||||
const wallBodies = root().querySelector('.wallbodies');
|
||||
result.overlayAfterWallBodies = !!wallBodies && !!overlay()
|
||||
&& !!(wallBodies.compareDocumentPosition(overlay()) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
result.oneLinePerSolidInterval = overlay()?.querySelectorAll('.plan-snap-line').length === 12;
|
||||
result.uniqueSourceEndpointsOnly = overlay()?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length === 11;
|
||||
result.columnIsNotACandidate = ![...overlay().querySelectorAll('.plan-snap-node')].some((node) =>
|
||||
close(+node.getAttribute('cx'), 800) && close(+node.getAttribute('cy'), 350));
|
||||
|
||||
const crosses = (x1, y1, x2, y2, px, py) => {
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const length2 = dx * dx + dy * dy;
|
||||
if (!length2) return false;
|
||||
const t = ((px - x1) * dx + (py - y1) * dy) / length2;
|
||||
if (t <= 0 || t >= 1) return false;
|
||||
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) < 0.01;
|
||||
};
|
||||
const lines = () => [...overlay().querySelectorAll('.plan-snap-line')].map((line) => [
|
||||
+line.getAttribute('x1'), +line.getAttribute('y1'),
|
||||
+line.getAttribute('x2'), +line.getAttribute('y2'),
|
||||
]);
|
||||
result.openingGapHasNoLine = !lines().some((line) => crosses(...line, 300, 100));
|
||||
result.openSpanHasNoLine = !lines().some((line) => crosses(...line, 500, 250));
|
||||
result.cutBoundariesAreNotEndpoints = ![...overlay().querySelectorAll('[data-kind="endpoint"]')].some((node) => {
|
||||
const x = +node.getAttribute('cx'), y = +node.getAttribute('cy');
|
||||
return (close(y, 100) && (close(x, 250) || close(x, 350)))
|
||||
|| (close(x, 500) && (close(y, 200) || close(y, 300)));
|
||||
});
|
||||
const cachedGeometry = card._planSnapGeometryCache?.value;
|
||||
const staticNodesBefore = [...overlay().querySelectorAll(
|
||||
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
|
||||
)];
|
||||
const originalRequestUpdate = card.requestUpdate;
|
||||
let initialHoverUpdates = 0;
|
||||
card.requestUpdate = function (...args) {
|
||||
initialHoverUpdates++;
|
||||
return originalRequestUpdate.apply(this, args);
|
||||
};
|
||||
|
||||
stage.dispatchEvent(eventAt(104, 104));
|
||||
await card.updateComplete;
|
||||
result.endpointHoverIsActive = active()?.getAttribute('data-kind') === 'endpoint'
|
||||
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 100)
|
||||
&& close(+active().getAttribute('r'), card._cmToUnits(10));
|
||||
stage.dispatchEvent(eventAt(735, 606));
|
||||
await card.updateComplete;
|
||||
const initialLineActive = active()?.getAttribute('data-kind') === 'line';
|
||||
stage.dispatchEvent(eventAt(300, 100));
|
||||
await card.updateComplete;
|
||||
const initialMissInactive = !active();
|
||||
stage.dispatchEvent(eventAt(104, 104));
|
||||
await card.updateComplete;
|
||||
card.requestUpdate = originalRequestUpdate;
|
||||
result.initialHoverAvoidsFullCardUpdate = initialHoverUpdates === 0;
|
||||
result.initialHoverCyclesCandidateKinds = initialLineActive && initialMissInactive
|
||||
&& active()?.getAttribute('data-kind') === 'endpoint';
|
||||
result.initialHoverKeepsStaticNodeIdentity = staticNodesBefore.every(
|
||||
(node, index) => node === overlay().querySelectorAll(
|
||||
'.plan-snap-line, .plan-snap-node[data-kind="endpoint"]',
|
||||
)[index],
|
||||
);
|
||||
card._markupClick(eventAt(104, 104, 'click', { shiftKey: true }));
|
||||
await card.updateComplete;
|
||||
result.endpointOverridesGridAndShift = card._path.length === 1
|
||||
&& close(card._path[0][0], 100) && close(card._path[0][1], 100);
|
||||
|
||||
stage.dispatchEvent(eventAt(735, 606));
|
||||
await card.updateComplete;
|
||||
const lineNode = active();
|
||||
result.lineHoverShowsOneDynamicNode = !!lineNode
|
||||
&& lineNode.getAttribute('data-kind') === 'line'
|
||||
&& root().querySelectorAll('.plan-snap-node.dynamic[data-active="true"]').length === 1;
|
||||
const linePoint = lineNode ? [+lineNode.getAttribute('cx'), +lineNode.getAttribute('cy')] : [NaN, NaN];
|
||||
result.lineNodeStaysWallBound = close(linePoint[1], 600);
|
||||
result.lineNodeQuantizesAlongWall = close((linePoint[0] - 600) / card._gridPitch,
|
||||
Math.round((linePoint[0] - 600) / card._gridPitch));
|
||||
result.hoverKeepsStaticGeometryCache = !!cachedGeometry
|
||||
&& card._planSnapGeometryCache?.value === cachedGeometry;
|
||||
const originalPartition = JSON.stringify(card._curSpaceCfg.partitions[0]);
|
||||
card._markupClick(eventAt(735, 606, 'click'));
|
||||
await card.updateComplete;
|
||||
result.drawCommitUsesExactLineNode = card._path.length === 2
|
||||
&& close(card._path[1][0], linePoint[0]) && close(card._path[1][1], linePoint[1]);
|
||||
result.existingPartitionWasNotSplit = card._curSpaceCfg.partitions.length === 1
|
||||
&& JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
|
||||
|
||||
stage.dispatchEvent(eventAt(300, 100));
|
||||
await card.updateComplete;
|
||||
result.openingGapDoesNotActivateSnap = !active();
|
||||
stage.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true, pointerId: 41 }));
|
||||
await card.updateComplete;
|
||||
result.pointerLeaveClearsActiveNode = !active();
|
||||
|
||||
card._cancelPath();
|
||||
card._activeDraftId = 'saved';
|
||||
card._path = [[100, 600], [300, 600], [300, 700]];
|
||||
await update();
|
||||
result.activeDraftExcluded = !lines().some((line) => (
|
||||
close(line[1], 600) && close(line[3], 600)
|
||||
&& Math.min(line[0], line[2]) < 300 && Math.max(line[0], line[2]) > 100
|
||||
) || (
|
||||
close(line[0], 300) && close(line[2], 300)
|
||||
&& Math.min(line[1], line[3]) < 700 && Math.max(line[1], line[3]) > 600
|
||||
));
|
||||
stage.dispatchEvent(eventAt(302, 602));
|
||||
await card.updateComplete;
|
||||
result.intermediateCurrentPointDoesNotSelfSnap = !active();
|
||||
stage.dispatchEvent(eventAt(102, 602));
|
||||
await card.updateComplete;
|
||||
result.firstCurrentPointRemainsClosureTarget = active()?.getAttribute('data-kind') === 'endpoint'
|
||||
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 600);
|
||||
stage.dispatchEvent(eventAt(302, 698));
|
||||
await card.updateComplete;
|
||||
result.currentAnchorDoesNotCreateZeroSegment = !active();
|
||||
card._cancelPath();
|
||||
card._tool = 'partition';
|
||||
await update();
|
||||
card._markupClick(eventAt(735, 606, 'click'));
|
||||
await card.updateComplete;
|
||||
result.tapWithoutHoverSnapsFirstPoint = card._path.length === 1
|
||||
&& close(card._path[0][1], 600)
|
||||
&& close((card._path[0][0] - 600) / card._gridPitch,
|
||||
Math.round((card._path[0][0] - 600) / card._gridPitch));
|
||||
card._markupClick(eventAt(896, 496, 'click'));
|
||||
await card.updateComplete;
|
||||
result.secondPartitionClickSnapsEndpoint = card._path.length === 0
|
||||
&& card._curSpaceCfg.partitions.length === 2
|
||||
&& close(card._curSpaceCfg.partitions[1].b[0] * 1000, 900)
|
||||
&& close(card._curSpaceCfg.partitions[1].b[1] * 1000, 500);
|
||||
result.originalSegmentStillUnchanged = JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
|
||||
|
||||
const gestureGeometry = JSON.stringify({
|
||||
drafts: card._curSpaceCfg.room_drafts,
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
});
|
||||
card._suppressClick = true;
|
||||
card._markupClick(eventAt(700, 600, 'click'));
|
||||
result.suppressedClickDoesNotCommit = card._path.length === 0;
|
||||
card._suppressClick = false;
|
||||
stage.dispatchEvent(eventAt(700, 600, 'pointerdown', { pointerId: 51, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(760, 640, 'pointermove', { pointerId: 51, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(760, 640, 'pointerup', { pointerId: 51 }));
|
||||
stage.dispatchEvent(eventAt(650, 560, 'pointerdown', { pointerId: 61, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(800, 650, 'pointerdown', { pointerId: 62, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(620, 540, 'pointermove', { pointerId: 61, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(830, 670, 'pointermove', { pointerId: 62, buttons: 1 }));
|
||||
stage.dispatchEvent(eventAt(620, 540, 'pointercancel', { pointerId: 61 }));
|
||||
stage.dispatchEvent(eventAt(830, 670, 'pointerup', { pointerId: 62 }));
|
||||
await card.updateComplete;
|
||||
result.panPinchCancelDoNotCommit = card._path.length === 0
|
||||
&& JSON.stringify({
|
||||
drafts: card._curSpaceCfg.room_drafts,
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
}) === gestureGeometry;
|
||||
|
||||
card._tool = 'select';
|
||||
await update();
|
||||
result.otherPlanToolsHaveNoOverlay = !overlay();
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.viewHasNoOverlay = !overlay();
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await page.emulateMedia({ forcedColors: 'active' });
|
||||
out.forcedColorsStayReadable = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._path = [];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
|
||||
const line = overlay?.querySelector('.plan-snap-line');
|
||||
const node = overlay?.querySelector('.plan-snap-node');
|
||||
const lineStyle = line ? getComputedStyle(line) : null;
|
||||
const nodeStyle = node ? getComputedStyle(node) : null;
|
||||
return matchMedia('(forced-colors: active)').matches
|
||||
&& lineStyle?.stroke !== 'none' && nodeStyle?.stroke !== 'none'
|
||||
&& nodeStyle?.fill !== 'none';
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,163 @@
|
||||
// #117: exact YAML contact/lock references without Entity Registry rows must
|
||||
// survive the immutable render projection without weakening disabled/security rules.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const settle = async () => {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const contactId = 'binary_sensor.hp117_yaml_contact';
|
||||
const lockId = 'lock.hp117_yaml_lock';
|
||||
const space = card._serverCfg.spaces.find((item) => item.id === 'f1');
|
||||
space.openings = [
|
||||
{
|
||||
id: 'hp117-window', type: 'window', x: 0.22, y: 0.14,
|
||||
angle: 0, length: 0.09, contact: contactId,
|
||||
},
|
||||
{
|
||||
id: 'hp117-door', type: 'door', x: 0.40, y: 0.14,
|
||||
angle: 0, length: 0.09, contact: contactId, lock: lockId,
|
||||
},
|
||||
];
|
||||
card._serverCfg = {
|
||||
...card._serverCfg,
|
||||
markers: [
|
||||
{ id: 'hp117-contact-gone', binding: `entity:${contactId}`, removed: true, hidden: true },
|
||||
{ id: 'hp117-lock-gone', binding: `entity:${lockId}`, removed: true, hidden: true },
|
||||
],
|
||||
};
|
||||
card._cfgEpoch++;
|
||||
card._setMode('view');
|
||||
const serviceCalls = [];
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[contactId]: {
|
||||
entity_id: contactId, state: 'off',
|
||||
attributes: { friendly_name: 'YAML opening contact', device_class: 'window' },
|
||||
},
|
||||
[lockId]: {
|
||||
entity_id: lockId, state: 'locked',
|
||||
attributes: { friendly_name: 'YAML opening lock' },
|
||||
},
|
||||
},
|
||||
callService: async (domain, service, data) => {
|
||||
serviceCalls.push({ domain, service, entityId: data.entity_id });
|
||||
},
|
||||
};
|
||||
await settle();
|
||||
|
||||
const rendered = () => card._openingsR;
|
||||
const yamlWindow = () => rendered().find((opening) => opening.id === 'hp117-window');
|
||||
const yamlDoor = () => rendered().find((opening) => opening.id === 'hp117-door');
|
||||
result.noRegistryRowsExist = card.hass.entities?.[contactId] == null
|
||||
&& card.hass.entities?.[lockId] == null;
|
||||
result.pickerOffersExactYamlEntities = card._contactCandidates()
|
||||
.some((item) => item.value === contactId)
|
||||
&& card._lockCandidates().some((item) => item.value === lockId);
|
||||
result.frozenProjectionKeepsYamlStates = card._renderPlanHass.entities?.[contactId] == null
|
||||
&& card._renderPlanHass.entities?.[lockId] == null
|
||||
&& card._renderPlanHass.states?.[contactId]?.state === 'off'
|
||||
&& card._renderPlanHass.states?.[lockId]?.state === 'locked';
|
||||
result.markerTombstonesDoNotBlockOpening = card._renderOpeningEntityAvailable(contactId)
|
||||
&& card._renderOpeningEntityAvailable(lockId)
|
||||
&& !card._planEntityAvailable(contactId)
|
||||
&& !card._planEntityAvailable(lockId);
|
||||
result.closedContactControlsPresentation = card._openingAmt(yamlWindow()) === 0
|
||||
&& card._openingAmt(yamlDoor()) === 0;
|
||||
result.yamlLockBadgeRendersLocked = root().querySelectorAll('.oplock.locked').length === 1;
|
||||
|
||||
const callsBeforePlanTap = serviceCalls.length;
|
||||
root().querySelector('[data-hp="opening"][data-id="hp117-door"] .op-hit')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
root().querySelector('.oplock')?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
await settle();
|
||||
result.planOpeningAndBadgeNeverActuate = serviceCalls.length === callsBeforePlanTap;
|
||||
result.badgeOpensInfoWithBothRows = card._openingInfo?.id === 'hp117-door'
|
||||
&& root().querySelectorAll('.oprow').length === 2
|
||||
&& root().querySelectorAll('.lockact').length === 1;
|
||||
|
||||
window.confirm = () => true;
|
||||
card._lockAction(lockId, 'unlock');
|
||||
result.explicitInfoActionStillWorks = serviceCalls.at(-1)?.domain === 'lock'
|
||||
&& serviceCalls.at(-1)?.service === 'unlock'
|
||||
&& serviceCalls.at(-1)?.entityId === lockId;
|
||||
card._openingInfo = null;
|
||||
await settle();
|
||||
|
||||
card._physicalBodiesR();
|
||||
const frameBefore = card._renderPlanHass;
|
||||
const physicalBefore = card._physicalBodiesCache;
|
||||
const epochBefore = card._cfgEpoch;
|
||||
const configBefore = JSON.stringify(card._serverCfg);
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[contactId]: { ...card.hass.states[contactId], state: 'on' },
|
||||
[lockId]: { ...card.hass.states[lockId], state: 'unlocked' },
|
||||
},
|
||||
};
|
||||
const oldFrameHeldUntilUpdate = card._renderPlanHass === frameBefore
|
||||
&& card._openingAmt(yamlWindow()) === 0;
|
||||
await settle();
|
||||
result.stateTickSwapsOneAtomicFrame = oldFrameHeldUntilUpdate
|
||||
&& card._renderPlanHass !== frameBefore
|
||||
&& card._openingAmt(yamlWindow()) === 1
|
||||
&& card._openingAmt(yamlDoor()) === 1
|
||||
&& root().querySelectorAll('.oplock.unlocked').length === 1;
|
||||
result.stateTickDoesNotRebuildGeometryOrConfig = card._physicalBodiesCache === physicalBefore
|
||||
&& card._cfgEpoch === epochBefore
|
||||
&& JSON.stringify(card._serverCfg) === configBefore;
|
||||
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[contactId]: { ...card.hass.states[contactId], state: 'unknown' },
|
||||
[lockId]: { ...card.hass.states[lockId], state: 'unknown' },
|
||||
},
|
||||
};
|
||||
await settle();
|
||||
result.unknownKeepsExistingTypeSemantics = card._renderOpeningEntityAvailable(contactId)
|
||||
&& card._openingAmt(yamlWindow()) === 0
|
||||
&& card._openingAmt(yamlDoor()) === 1
|
||||
&& root().querySelectorAll('.oplock.unknown').length === 1;
|
||||
|
||||
space.openings = [
|
||||
{
|
||||
id: 'hp117-disabled-window', type: 'window', x: 0.22, y: 0.14,
|
||||
angle: 0, length: 0.09, contact: 'binary_sensor.window',
|
||||
},
|
||||
{
|
||||
id: 'hp117-disabled-lock', type: 'door', x: 0.40, y: 0.14,
|
||||
angle: 0, length: 0.09, lock: 'lock.front_door',
|
||||
},
|
||||
];
|
||||
card._cfgEpoch++;
|
||||
window.__setRegistryDisabled('entity', 'binary_sensor.window', 'user');
|
||||
window.__setRegistryDisabled('entity', 'lock.front_door', 'user');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
await settle();
|
||||
const disabledWindow = card._openingsR.find((opening) => opening.id === 'hp117-disabled-window');
|
||||
result.explicitDisabledRowsRemoveStaleStates = card._renderPlanHass.states?.['binary_sensor.window'] == null
|
||||
&& card._renderPlanHass.states?.['lock.front_door'] == null
|
||||
&& !card._renderOpeningEntityAvailable('binary_sensor.window')
|
||||
&& !card._renderOpeningEntityAvailable('lock.front_door')
|
||||
&& card._openingAmt(disabledWindow) === 0
|
||||
&& root().querySelectorAll('.oplock').length === 0;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -17,12 +17,41 @@ const res = await page.evaluate(async () => {
|
||||
r1.open_to = [r2.id]; r2.open_to = [r1.id];
|
||||
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||
pairsCalls = 0; buildCalls = 0;
|
||||
const devicesBeforeHassTicks = c._devices;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states } };
|
||||
await c.updateComplete;
|
||||
}
|
||||
out.pairsBounded = pairsCalls <= 20;
|
||||
out.modelBuildsPer10Renders = buildCalls;
|
||||
out.devicesStableAcrossHassTicks = c._devices === devicesBeforeHassTicks;
|
||||
|
||||
// #146: the browser-clock fallback repaints only the environment. A 30 s
|
||||
// tick must not rebuild the plan, devices, wall union, or sun-ray geometry.
|
||||
c._serverCfg = {
|
||||
...c._serverCfg,
|
||||
settings: { ...(c._serverCfg?.settings || {}), bg_mode: 'daynight' },
|
||||
};
|
||||
const statesWithoutSun = { ...c.hass.states };
|
||||
delete statesWithoutSun['sun.sun'];
|
||||
c.hass = { ...c.hass, states: statesWithoutSun };
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const clockModel = c._model;
|
||||
const clockDevices = c._devices;
|
||||
const clockWallUnion = c._wallUnionCache;
|
||||
const clockSunRays = c._sunRaysCache;
|
||||
buildCalls = 0;
|
||||
c._dayCycleClockKey = 'force-next-clock-tick';
|
||||
c._dayCycleTick();
|
||||
await c.updateComplete;
|
||||
out.fallbackClockActive = (c.shadowRoot || c.renderRoot)
|
||||
.querySelector('.hp-day-cycle-env')?.dataset.dayCycleSource === 'clock';
|
||||
out.clockTickModelBuilds = buildCalls;
|
||||
out.clockTickKeepsModel = c._model === clockModel;
|
||||
out.clockTickKeepsDevices = c._devices === clockDevices;
|
||||
out.clockTickKeepsWallUnion = c._wallUnionCache === clockWallUnion;
|
||||
out.clockTickKeepsSunRays = c._sunRaysCache === clockSunRays;
|
||||
|
||||
const before = pairsCalls;
|
||||
const r3 = sp.rooms[2] || sp.rooms[0];
|
||||
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
|
||||
@@ -35,5 +64,6 @@ const res = await page.evaluate(async () => {
|
||||
});
|
||||
checkAll(res, {
|
||||
modelBuildsPer10Renders: 0,
|
||||
clockTickModelBuilds: 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** Issue #138: a new room may close along one existing continuous solid wall interval. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const baseSpace = {
|
||||
id: 'autoclose', title: 'Autoclose', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [{
|
||||
id: 'existing', name: 'Existing', area: null,
|
||||
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]],
|
||||
}],
|
||||
walls: [{
|
||||
key: '0.500000,0.300000@1.5708',
|
||||
a: [0.5, 0.1], b: [0.5, 0.5], cm: 40,
|
||||
}],
|
||||
};
|
||||
const reset = async (spacePatch = {}) => {
|
||||
card._serverCfg = { spaces: [{ ...clone(baseSpace), ...clone(spacePatch) }], markers: [], settings: {} };
|
||||
card._layout = {};
|
||||
card._space = 'autoclose';
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._cfgEpoch++;
|
||||
card._planSnapGeometryCache = null;
|
||||
card._path = [];
|
||||
card._activeDraftId = null;
|
||||
card._draftSegmentCms = [];
|
||||
card._closingWallCm = null;
|
||||
card._roomDialog = false;
|
||||
card._wallFaceBatch = null;
|
||||
card._roomEditId = null;
|
||||
card._pendingSplit = null;
|
||||
card._toast = '';
|
||||
card._drawWallField = '15';
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
card._clearPlanSnapHover();
|
||||
await update();
|
||||
};
|
||||
|
||||
await reset();
|
||||
const stage = root().querySelector('.stage');
|
||||
const eventAt = (x, y, extra = {}) => {
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const view = card._viewOr(card._baseVb());
|
||||
return new MouseEvent('click', {
|
||||
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
|
||||
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
|
||||
bubbles: true,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
const click = async (x, y, extra = {}) => {
|
||||
card._markupClick(eventAt(x, y, extra));
|
||||
await update();
|
||||
};
|
||||
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
|
||||
const currentDraft = () => card._curSpaceCfg.room_drafts?.find((draft) =>
|
||||
draft.id === card._activeDraftId);
|
||||
|
||||
// r1 regression guard: two points on the common wall are still only an open line.
|
||||
await click(500, 100);
|
||||
await click(500, 500);
|
||||
result.secondCommonWallPointStaysOpen = card._path.length === 2
|
||||
&& !card._roomDialog && !card._toast
|
||||
&& currentDraft()?.points.length === 2
|
||||
&& currentDraft()?.segments.length === 1;
|
||||
|
||||
// Endpoint-to-endpoint auto-close after the new room has enough vertices.
|
||||
await reset();
|
||||
await click(500, 100);
|
||||
await click(800, 100);
|
||||
await click(800, 500);
|
||||
await click(500, 500);
|
||||
const successfulDraft = clone(currentDraft());
|
||||
result.endpointAutoCloseOpensDialog = card._roomDialog
|
||||
&& card._wallFaceBatch?.candidates.length === 1
|
||||
&& card._path.length === 4
|
||||
&& close(card._path.at(-1)[0], 500)
|
||||
&& close(card._path.at(-1)[1], 500);
|
||||
result.terminalSegmentPersistsBeforeDialog = successfulDraft?.points.length === 4
|
||||
&& successfulDraft?.segments.length === 3
|
||||
&& close(successfulDraft.points.at(-1)[0], 0.5)
|
||||
&& close(successfulDraft.points.at(-1)[1], 0.5)
|
||||
&& successfulDraft.segments.every((segment) => close(segment.cm, 15));
|
||||
result.roomIsNotCommittedBeforeSave = card._curSpaceCfg.rooms.length === 1;
|
||||
|
||||
card._roomDialogCancel();
|
||||
await update();
|
||||
result.cancelKeepsTerminalOpenDraft = !card._roomDialog
|
||||
&& card._path.length === 4
|
||||
&& !card._contourClosed
|
||||
&& JSON.stringify(currentDraft()) === JSON.stringify(successfulDraft)
|
||||
&& close(card._path.at(-1)[0], 500) && close(card._path.at(-1)[1], 500);
|
||||
|
||||
// Save promotes the same draft, keeps the existing shared thickness and creates no partition.
|
||||
await reset();
|
||||
await click(500, 100);
|
||||
await click(800, 100);
|
||||
await click(800, 500);
|
||||
await click(500, 500);
|
||||
card._nameSel = 'Adjacent';
|
||||
card._saveRoom();
|
||||
await update();
|
||||
const sharedWall = (card._curSpaceCfg.walls || []).find((wall) => wall.cm === 40
|
||||
&& wall.a && wall.b
|
||||
&& close(wall.a[0], 0.5) && close(wall.b[0], 0.5)
|
||||
&& close(Math.min(wall.a[1], wall.b[1]), 0.1)
|
||||
&& close(Math.max(wall.a[1], wall.b[1]), 0.5));
|
||||
result.savePromotesDraftWithoutPartition = card._curSpaceCfg.rooms.length === 2
|
||||
&& card._curSpaceCfg.rooms.some((room) => room.name === 'Adjacent')
|
||||
&& !card._curSpaceCfg.room_drafts?.length
|
||||
&& !card._curSpaceCfg.partitions?.length
|
||||
&& card._path.length === 0;
|
||||
result.sharedWallKeepsNeighbourThickness = !!sharedWall
|
||||
&& (card._curSpaceCfg.walls || []).filter((wall) => wall.cm === 15).length >= 3;
|
||||
|
||||
// #185: an architectural opening cuts masonry, not the structural wall axis.
|
||||
await reset({ openings: [{
|
||||
id: 'door', type: 'door', x: 0.5, y: 0.3, angle: 90, length: 0.1,
|
||||
}] });
|
||||
await click(500, 100);
|
||||
await click(800, 100);
|
||||
await click(800, 500);
|
||||
await click(500, 500);
|
||||
result.openingKeepsStructuralAutoClose = card._roomDialog
|
||||
&& card._wallFaceBatch?.candidates.length === 1
|
||||
&& card._path.length === 4
|
||||
&& currentDraft()?.points.length === 4;
|
||||
|
||||
// #173: a proper X is a derived graph node. The two bounded faces are
|
||||
// offered, while the terminal draft remains the only persisted mutation.
|
||||
await reset();
|
||||
await click(500, 100);
|
||||
await click(400, 300);
|
||||
await click(700, 300);
|
||||
await click(500, 500);
|
||||
result.xIntersectionOffersFacesWithoutPartialRooms = card._roomDialog
|
||||
&& card._wallFaceBatch?.candidates.length === 2
|
||||
&& card._path.length === 4
|
||||
&& currentDraft()?.segments.length === 3
|
||||
&& card._curSpaceCfg.rooms.length === 1;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -84,6 +84,6 @@ checkAll(res, {
|
||||
"atticSquare": true,
|
||||
// New spaces keep the product default (Glow on), but store it independently
|
||||
// from the ordinary data fill.
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "bg_mode": "daynight", "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -112,6 +112,8 @@ const res = await page.evaluate(async () => {
|
||||
|
||||
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
|
||||
@@ -6,6 +6,11 @@ const res = await page.evaluate(async () => {
|
||||
// комната без зоны на f1
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, rooms: [...s.rooms, { id: 'rc', name: 'Cupboard', area: null, poly: [[0.05,0.62],[0.2,0.62],[0.2,0.9],[0.05,0.9]] }],
|
||||
})).map((s) => s.id !== 'garden' ? s : ({
|
||||
...s, rooms: [...s.rooms,
|
||||
{ id: 'garden-shed-a', name: 'Shed A', area: null, poly: [[0.60,0.55],[0.80,0.55],[0.80,0.75],[0.60,0.75]] },
|
||||
{ id: 'garden-shed-b', name: 'Shed B', area: null, poly: [[0.25,0.55],[0.45,0.55],[0.45,0.75],[0.25,0.75]] },
|
||||
],
|
||||
})) };
|
||||
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
|
||||
// список комнат в диалоге содержит подзону
|
||||
@@ -29,6 +34,50 @@ const res = await page.evaluate(async () => {
|
||||
c._openMarkerDialog(dev);
|
||||
out.reopenRoom = c._markerDialog?.room;
|
||||
c._markerDialog = null;
|
||||
|
||||
// Реальное HA-устройство с registry Area вручную переносится в комнату без
|
||||
// Area другого пространства. Registry metadata не должна вернуть его в f1.
|
||||
const source = c._devices.find((d) => d.id === 'd_light1');
|
||||
c._openMarkerDialog(source); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, room: 'garden#@garden-shed-a' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const haMarker = c._serverCfg.markers.find((x) => x.binding === 'device:d_light1');
|
||||
const moved = c._devices.find((d) => d.id === haMarker?.id);
|
||||
const crossPos = c._layout[haMarker?.id];
|
||||
out.haMarkerSaved = !!haMarker
|
||||
&& haMarker.space === 'garden' && haMarker.area === null && haMarker.room_id === 'garden-shed-a';
|
||||
out.haRuntimePlacement = !!moved
|
||||
&& moved.space === 'garden' && moved.area === '' && moved.marker?.room_id === 'garden-shed-a';
|
||||
out.crossSpaceCentered = !!crossPos && crossPos.s === 'garden'
|
||||
&& Math.abs(crossPos.x - 0.70) < 0.03 && Math.abs(crossPos.y - 0.65) < 0.03;
|
||||
|
||||
c._openMarkerDialog(moved); await c.updateComplete;
|
||||
out.haReopenRoom = c._markerDialog?.room === 'garden#@garden-shed-a';
|
||||
c._markerDialog = null;
|
||||
|
||||
// Защитный негативный кейс: transient runtime-пара с room_id из другого
|
||||
// пространства не роняет диалог и не переписывает сохранённый marker.
|
||||
const persistedBeforeInvalidOpen = JSON.stringify(haMarker);
|
||||
c._openMarkerDialog({ ...moved, space: 'f1' }); await c.updateComplete;
|
||||
const invalidSelect = c.shadowRoot?.querySelector('#marker-room');
|
||||
out.invalidPairPlaceholder = invalidSelect?.value === '';
|
||||
out.invalidPairReadOnly = JSON.stringify(
|
||||
c._serverCfg.markers.find((x) => x.binding === 'device:d_light1'),
|
||||
) === persistedBeforeInvalidOpen;
|
||||
c._markerDialog = null; await c.updateComplete;
|
||||
|
||||
// Смена комнаты внутри того же пространства сохраняет уже закреплённую
|
||||
// позицию, хотя room_id меняется.
|
||||
c._openMarkerDialog(moved); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, room: 'garden#@garden-shed-b' };
|
||||
await c._saveMarker(); await c.updateComplete;
|
||||
const sameSpaceMarker = c._serverCfg.markers.find((x) => x.binding === 'device:d_light1');
|
||||
const sameSpacePos = c._layout[sameSpaceMarker?.id];
|
||||
const sameSpaceDevice = c._devices.find((d) => d.id === sameSpaceMarker?.id);
|
||||
out.sameSpaceRoomSaved = sameSpaceMarker?.room_id === 'garden-shed-b'
|
||||
&& sameSpaceDevice?.space === 'garden' && sameSpaceDevice?.area === '';
|
||||
out.sameSpacePositionKept = !!sameSpacePos
|
||||
&& sameSpacePos.s === crossPos.s && sameSpacePos.x === crossPos.x && sameSpacePos.y === crossPos.y;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Sun on the plan (docs/SUN.md): compass gating, window wedges on all four
|
||||
// walls, direction follows north_deg, night is empty, wedges clip to rooms,
|
||||
// day/night background vs static, per-space inheritance, weather independence,
|
||||
// four-phase background vs static, per-space inheritance, weather independence,
|
||||
// memoisation across hass ticks, editors stay clean.
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
@@ -19,7 +19,7 @@ const res = await page.evaluate(async () => {
|
||||
const setSun = async (azimuth, elevation) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation },
|
||||
attributes: { azimuth, elevation, rising: azimuth <= 180 },
|
||||
} } };
|
||||
await upd();
|
||||
};
|
||||
@@ -51,12 +51,12 @@ const res = await page.evaluate(async () => {
|
||||
];
|
||||
touchCfg();
|
||||
|
||||
// 1) sun present + rays enabled, but NO north_deg anywhere → dead silence
|
||||
// 1) sun present + rays enabled, but NO north_deg anywhere → no window rays
|
||||
cfg().settings = { ...(cfg().settings || {}), sun_rays: true };
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.silentWithoutNorth = domPolys().length === 0;
|
||||
out.noDaynightWithoutNorth = !sr().querySelector('.stage.daynight');
|
||||
out.noBackgroundWhenStatic = !sr().querySelector('.stage.daycycle');
|
||||
|
||||
// 2) compass set → morning east sun lights ONLY the east window
|
||||
cfg().settings.north_deg = 0;
|
||||
@@ -82,11 +82,12 @@ const res = await page.evaluate(async () => {
|
||||
await setSun(90, -10);
|
||||
out.nightEmpty = domPolys().length === 0;
|
||||
|
||||
// 6) rotated compass: the same morning east sun now lights the NORTH window
|
||||
// 6) N points right: the same morning east sun is down on the canvas and
|
||||
// therefore lights the SOUTH window
|
||||
cfg().settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
|
||||
out.rotatedCompass = JSON.stringify(litIds()) === '["wS"]';
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
|
||||
@@ -99,21 +100,21 @@ const res = await page.evaluate(async () => {
|
||||
await setSun(91, 5);
|
||||
out.memoFollowsSun = c._sunRaysCache !== cacheBefore;
|
||||
|
||||
// 8) day/night background: daynight breathes, static does not
|
||||
// 8) four-phase background: daynight paints environment, static does not
|
||||
await setSun(180, 60);
|
||||
out.staticBgAtNoon = !stageStyle().includes('background:#ffffff');
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
await setSun(180, 60);
|
||||
out.daynightNoonBg = stageStyle().includes('background:#ffffff');
|
||||
out.daynightClass = !!sr().querySelector('.stage.daynight');
|
||||
out.daynightNoonBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'day';
|
||||
out.daynightClass = !!sr().querySelector('.stage.daycycle');
|
||||
await setSun(180, -20);
|
||||
out.daynightNightBg = stageStyle().includes('background:#070c14');
|
||||
out.nightPlanDimmed = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
out.daynightNightBg = sr().querySelector('.hp-day-cycle-bg.active')?.dataset.dayCycleLayer === 'night';
|
||||
out.nightPlanUnchanged = !(sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.');
|
||||
cfg().settings.bg_mode = 'static';
|
||||
touchCfg();
|
||||
await setSun(180, -20);
|
||||
out.staticNightKeepsTheme = !stageStyle().includes('background:#070c14');
|
||||
out.staticNightKeepsTheme = !sr().querySelector('.hp-day-cycle-env');
|
||||
delete cfg().settings.bg_mode;
|
||||
touchCfg();
|
||||
|
||||
@@ -127,7 +128,7 @@ const res = await page.evaluate(async () => {
|
||||
sp.settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
|
||||
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wS"]';
|
||||
delete sp.settings.north_deg;
|
||||
touchCfg();
|
||||
|
||||
@@ -153,13 +154,13 @@ const res = await page.evaluate(async () => {
|
||||
cfg().settings.bg_mode = 'daynight';
|
||||
touchCfg();
|
||||
|
||||
// 12) editors stay clean: no wedges, no day/night canvas
|
||||
// 12) editors stay clean: no wedges, no day-cycle environment
|
||||
await setSun(90, 5);
|
||||
c._setMode('plan');
|
||||
await upd();
|
||||
await settleMode();
|
||||
out.editorNoRays = domPolys().length === 0;
|
||||
out.editorNoDaynight = !sr().querySelector('.stage.daynight');
|
||||
out.editorNoDaynight = !sr().querySelector('.stage.daycycle');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
await settleMode();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Owner 2026-08-04: «цвет фона не меняется сам с течением времени суток, только
|
||||
// после обновления страницы». The day/night sky must follow sun.sun on a plain
|
||||
// hass tick — no reload, no requestUpdate() from the outside — and the COMPUTED
|
||||
// background of the stage (what the eye sees, not the style attribute) must
|
||||
// actually arrive at the sky colour (docs/SUN.md).
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
// Four-phase Sun background (#146): plain hass ticks update one constant
|
||||
// environment tree, never dim the plan, and clock fallback owns a visible-only
|
||||
// 30-second lifecycle. Window rays retain their separate north/sun gates.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
@@ -11,82 +9,77 @@ const res = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((s) => s.id === 'f1').openings = [
|
||||
cfg.settings = { ...(cfg.settings || {}), bg_mode: 'daynight', sun_rays: true };
|
||||
cfg.spaces.find((space) => space.id === 'f1').openings = [
|
||||
{ id: 'wS', type: 'window', x: 0.30, y: 0.86, angle: 0, length: 0.08 },
|
||||
];
|
||||
c._cfgEpoch++;
|
||||
// NO requestUpdate anywhere below: production only ever assigns `hass`.
|
||||
const setSun = async (az, el) => {
|
||||
|
||||
// No external requestUpdate: production advances through the hass setter.
|
||||
const setSun = async (azimuth, elevation, rising) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: el > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth: az, elevation: el },
|
||||
entity_id: 'sun.sun', state: elevation > 0 ? 'above_horizon' : 'below_horizon',
|
||||
attributes: { azimuth, elevation, rising },
|
||||
} } };
|
||||
await c.updateComplete;
|
||||
// one paint so the sky lands on screen, not only in the attribute
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
};
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const comp = () => getComputedStyle(stage()).backgroundColor;
|
||||
const dim = () => (sr().querySelector('.zoomwrap').getAttribute('style') || '');
|
||||
const polys = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
const hexToRgb = (h) => 'rgb(' + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(', ') + ')';
|
||||
const env = () => sr().querySelector('.hp-day-cycle-env');
|
||||
const active = () => sr().querySelector('.hp-day-cycle-bg.active');
|
||||
const zoomStyle = () => sr().querySelector('.zoomwrap').getAttribute('style') || '';
|
||||
const rayCount = () => sr().querySelectorAll('.sunlayer polygon').length;
|
||||
|
||||
await setSun(180, 40);
|
||||
out.dayComputedWhite = comp() === 'rgb(255, 255, 255)';
|
||||
out.dayNoDim = !dim().includes('brightness(') || dim().includes('brightness(1.000)');
|
||||
out.dayRays = polys() > 0;
|
||||
// Background needs neither compass nor window-ray geometry.
|
||||
delete cfg.settings.north_deg;
|
||||
await setSun(90, -2, true);
|
||||
out.dawnOnPlainTick = stage().classList.contains('phase-dawn')
|
||||
&& env()?.dataset.dayCycleSource === 'sun'
|
||||
&& active()?.dataset.dayCycleLayer === 'dawn';
|
||||
out.constantFourLayers = sr().querySelectorAll('.hp-day-cycle-bg').length === 4;
|
||||
out.backgroundIndependentOfNorth = !!stage().classList.contains('daycycle');
|
||||
out.raysStillNeedNorth = rayCount() === 0;
|
||||
|
||||
// the whole point: a NEW hass, nothing else. No reload, no requestUpdate.
|
||||
await setSun(180, -10);
|
||||
out.nightComputedDark = comp() === 'rgb(10, 16, 25)';
|
||||
out.nightDimmed = dim().includes('brightness(0.900');
|
||||
out.nightNoRays = polys() === 0;
|
||||
cfg.settings.north_deg = 0;
|
||||
c._cfgEpoch++;
|
||||
await setSun(180, 40, false);
|
||||
out.dayOnPlainTick = stage().classList.contains('phase-day')
|
||||
&& active()?.getAttribute('style').includes('#dce9ef');
|
||||
out.dayRays = rayCount() > 0;
|
||||
out.dayPlanUnfiltered = !zoomStyle().includes('brightness(0.');
|
||||
|
||||
// ...and back, again on a plain tick
|
||||
await setSun(180, 40);
|
||||
out.backToDayComputed = comp() === 'rgb(255, 255, 255)';
|
||||
out.backToDayRays = polys() > 0;
|
||||
await setSun(180, 0, false);
|
||||
out.duskOnPlainTick = stage().classList.contains('phase-dusk')
|
||||
&& active()?.dataset.dayCycleLayer === 'dusk';
|
||||
await setSun(180, -6, false);
|
||||
out.exactMinusSixIsNight = stage().classList.contains('phase-night');
|
||||
out.nightLightHidden = env()?.style.getPropertyValue('--hp-day-cycle-sun-opacity') === '0.000';
|
||||
out.nightPlanUnfiltered = !zoomStyle().includes('brightness(0.');
|
||||
out.nightNoRays = rayCount() === 0;
|
||||
|
||||
// a small step still moves the sky (0.1° granularity of the model)...
|
||||
await setSun(180, 20);
|
||||
const twenty = comp();
|
||||
await setSun(180, 12);
|
||||
out.smallStepMovesSky = comp() !== twenty;
|
||||
await setSun(180, 6, true);
|
||||
out.exactPlusSixIsDay = stage().classList.contains('phase-day');
|
||||
out.phaseTransitionIs1100ms = getComputedStyle(active()).transitionDuration === '1.1s';
|
||||
|
||||
// ...but a step the size of a REAL sun update (~1° per 4 minutes) must still
|
||||
// GLIDE: the catch-up jump is for a card that was not watching, never for the
|
||||
// day/night breathing itself (docs/SUN.md).
|
||||
await setSun(180, 12.6);
|
||||
const target = stage().getAttribute('style').split('background:')[1];
|
||||
out.realStepNoSnapClass = !sr().querySelector('.stage.skysnap');
|
||||
out.realStepStillGlides = comp() !== hexToRgb(target); // 45 s transition, not a jump
|
||||
// One invalid required field switches the whole environment atomically to
|
||||
// browser-local time and arms the fallback timer only while visible.
|
||||
await setSun(180, 6, undefined);
|
||||
out.invalidSampleUsesClock = env()?.dataset.dayCycleSource === 'clock';
|
||||
out.clockTimerArmed = c._dayCycleTimer !== 0;
|
||||
|
||||
// A quick tab switch is not a suspended renderer: keep the current painted
|
||||
// sky and hover in place. The old visibility handler cleared both on every
|
||||
// return, producing a one-frame whole-plan flash even after two seconds.
|
||||
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
const room = c._spaceModel().rooms[0];
|
||||
c._hoverRoom = { space: c._space, room };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const skyBeforeQuickReturn = c._skyElev;
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
out.hiddenStopsClock = c._dayCycleTimer === 0;
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
await c.updateComplete;
|
||||
out.quickReturnKeepsSky = c._skyElev === skyBeforeQuickReturn;
|
||||
out.quickReturnKeepsHover = c._hoverRoom?.room === room;
|
||||
out.quickReturnHasNoSnapFrame = !stage().classList.contains('skysnap')
|
||||
&& !stage().classList.contains('hpresume');
|
||||
out.visibleRestartsClock = c._dayCycleTimer !== 0;
|
||||
out.visibilityKeepsHover = c._hoverRoom?.room === room;
|
||||
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
|
||||
else delete document.visibilityState;
|
||||
|
||||
// the tab came back from the background: the sky catches up at once
|
||||
await setSun(180, 25); // 12° of sun happened while we were not painting
|
||||
out.returnFromHiddenSnaps = comp() === hexToRgb(stage().getAttribute('style').split('background:')[1]);
|
||||
|
||||
return out;
|
||||
});
|
||||
await finish(browser, checkAll(res));
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// #103: Toggle confirmation explains the current snapshot and the exact
|
||||
// nextEffect while execution still resolves the current intent again.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 390, height: 760 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const calls = [];
|
||||
const coverState = (state, entityId = 'cover.gate', deviceClass = 'curtain') => ({
|
||||
entity_id: entityId,
|
||||
state,
|
||||
attributes: {
|
||||
friendly_name: entityId === 'cover.gate'
|
||||
? 'Very long living-room curtain name that must wrap on a phone'
|
||||
: 'Other curtain',
|
||||
device_class: deviceClass,
|
||||
supported_features: 11,
|
||||
},
|
||||
});
|
||||
const setCover = async (state, extraStates = {}, deviceClass = 'curtain') => {
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: { ...card.hass.states, 'cover.gate': coverState(state, 'cover.gate', deviceClass), ...extraStates },
|
||||
services: {
|
||||
...card.hass.services,
|
||||
cover: {
|
||||
...card.hass.services?.cover,
|
||||
open_cover: {}, close_cover: {}, stop_cover: {}, toggle: {},
|
||||
},
|
||||
},
|
||||
// The demo fallback returns raw states; #103 must still localize the
|
||||
// known vocabulary instead of exposing those tokens in confirmation.
|
||||
formatEntityState: (entity) => entity.state,
|
||||
callService: async (domain, service, data) => {
|
||||
calls.push([domain, service, data]);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
};
|
||||
const setLanguage = async (language) => {
|
||||
card._config = { ...card._config, language };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
};
|
||||
const rebuildGate = async () => {
|
||||
card._serverCfg = {
|
||||
...card._serverCfg,
|
||||
markers: [{
|
||||
id: 'm_gate', binding: 'device:d_gate', tap_action: 'cover',
|
||||
tap_confirm: true, display: 'icon_ripple',
|
||||
}],
|
||||
};
|
||||
card._cfgEpoch++;
|
||||
card._regSignature = '';
|
||||
card._maybeRebuildDevices();
|
||||
card._space = 'garden';
|
||||
card._setMode('view');
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
};
|
||||
const gate = () => card._devices.find((item) => item.bindingRef === 'd_gate');
|
||||
const tap = async () => {
|
||||
card._clickDevice({ stopPropagation() {} }, gate());
|
||||
await card.updateComplete;
|
||||
return card._tapConfirm;
|
||||
};
|
||||
const renderedLines = () => [...root().querySelectorAll('.tapconfirm-line')]
|
||||
.map((node) => node.textContent.trim());
|
||||
|
||||
await setLanguage('en');
|
||||
await setCover('closed');
|
||||
await rebuildGate();
|
||||
|
||||
const english = await tap();
|
||||
result.englishSnapshotIsStructured = english?.kind === 'toggle'
|
||||
&& english.initialIntent?.nextEffect === 'open'
|
||||
&& english.deviceId === gate()?.id;
|
||||
result.englishCurrentExpected = JSON.stringify(renderedLines()) === JSON.stringify([
|
||||
'Current state: Closed', 'After switching: Open',
|
||||
]);
|
||||
const groupLines = card._toggleConfirmationLines({
|
||||
origin: 'explicit-toggle', kind: 'group', semantics: 'group-power',
|
||||
targets: [
|
||||
{ entityId: 'switch.one', name: 'One', state: 'on', via: 'control-entity' },
|
||||
{ entityId: 'light.two', name: 'Two', state: 'off', via: 'control-entity' },
|
||||
],
|
||||
skippedTargets: [{
|
||||
ref: 'switch.missing', entityId: 'switch.missing', name: 'Missing', reason: 'unavailable',
|
||||
}],
|
||||
noneReason: null, nextEffect: 'turn-off',
|
||||
command: {
|
||||
domain: 'homeassistant', service: 'turn_off',
|
||||
data: { entity_id: ['switch.one', 'light.two'] },
|
||||
},
|
||||
});
|
||||
const virtualLines = card._toggleConfirmationLines({
|
||||
origin: 'explicit-toggle', kind: 'single', semantics: 'power',
|
||||
targets: [{ entityId: '', name: 'Virtual lamp', state: 'on', via: 'virtual-light' }],
|
||||
skippedTargets: [], noneReason: null, nextEffect: 'turn-off', command: null,
|
||||
operation: { kind: 'virtual-light', markerId: 'virtual-lamp' },
|
||||
});
|
||||
result.englishGroupAndVirtualCopy = JSON.stringify(groupLines) === JSON.stringify([
|
||||
'Current state: on 1 of 2', 'After switching: all are off', 'Unavailable: 1',
|
||||
]) && JSON.stringify(virtualLines) === JSON.stringify([
|
||||
'Current state: On', 'After switching: Off',
|
||||
]);
|
||||
const body = root().querySelector('.tapconfirm-body');
|
||||
const dialog = root().querySelector('hp-dialog');
|
||||
const titleText = dialog?.shadowRoot?.querySelector('.title-text');
|
||||
const footer = root().querySelector('hp-dialog [slot="footer"]');
|
||||
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
||||
result.accessibleDomOrder = dialog?.title === english.text
|
||||
&& body?.children[0]?.getAttribute('data-line') === '0'
|
||||
&& body?.children[1]?.getAttribute('data-line') === '1'
|
||||
&& buttons.length === 2;
|
||||
result.narrowDialogDoesNotScrollHorizontally = !!body
|
||||
&& body.scrollWidth <= body.clientWidth + 1
|
||||
&& !!titleText && titleText.scrollWidth <= titleText.clientWidth + 1
|
||||
&& buttons.every((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.left >= 0 && rect.right <= innerWidth + 1;
|
||||
});
|
||||
|
||||
// Same target, changed state: execute the newly resolved direction.
|
||||
calls.length = 0;
|
||||
await setCover('open');
|
||||
english.exec();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
result.sameTargetUsesCurrentDirection = calls.length === 1
|
||||
&& calls[0][0] === 'cover' && calls[0][1] === 'close_cover';
|
||||
|
||||
// Different operation target: preserve #94's cancellation/toast contract.
|
||||
await setCover('closed');
|
||||
const changed = await tap();
|
||||
const beforeChange = gate();
|
||||
const other = coverState('closed', 'cover.other');
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states, 'cover.other': other } };
|
||||
card._devices = card._devices.map((item) => item.id === beforeChange.id ? {
|
||||
...item,
|
||||
primary: 'cover.other', entities: ['cover.other'], allEntities: ['cover.other'],
|
||||
bindingKind: 'entity', bindingRef: 'cover.other',
|
||||
marker: { ...item.marker, binding: 'entity:cover.other' },
|
||||
} : item);
|
||||
calls.length = 0;
|
||||
changed.exec();
|
||||
await card.updateComplete;
|
||||
result.changedTargetCancels = calls.length === 0
|
||||
&& card._toast === card._t('toast.tap_target_changed');
|
||||
|
||||
await setLanguage('ru');
|
||||
await setCover('closed');
|
||||
await rebuildGate();
|
||||
await tap();
|
||||
result.russianCurrentExpected = JSON.stringify(renderedLines()) === JSON.stringify([
|
||||
'Текущее состояние: Закрыто', 'После переключения: Открыто',
|
||||
]);
|
||||
|
||||
// A secure/no-operation resolver result must not open a confirmation.
|
||||
card._tapConfirm = null;
|
||||
await setCover('closed', {}, 'garage');
|
||||
await tap();
|
||||
result.noOperationDoesNotOpen = card._tapConfirm == null && calls.length === 0;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,133 @@
|
||||
// #178: production-bundle contract for the exact Toggle-entity selector.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1180, height: 1000 });
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const base = card._devices.find((device) =>
|
||||
device.bindingKind === 'device' && device.primary?.startsWith('light.'));
|
||||
const primary = base.primary;
|
||||
const secondary = 'switch.issue178_child_lock';
|
||||
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[secondary]: {
|
||||
entity_id: secondary,
|
||||
state: 'off',
|
||||
attributes: { friendly_name: 'Child lock' },
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
...card.hass.entities,
|
||||
[secondary]: {
|
||||
entity_id: secondary,
|
||||
device_id: base.bindingRef,
|
||||
platform: 'issue178',
|
||||
disabled_by: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
await card.updateComplete;
|
||||
|
||||
// Keep the browser smoke independent from registry loading order while the
|
||||
// production resolver, dialog and persistence helpers remain unmodified.
|
||||
const realPreview = card._markerPreviewDevice.bind(card);
|
||||
card._markerPreviewDevice = (draft) => draft.devId === base.id ? {
|
||||
...base,
|
||||
tapAction: draft.tapActionTouched ? draft.tapAction : 'toggle',
|
||||
entities: [primary, secondary],
|
||||
marker: {
|
||||
...(base.marker || {}),
|
||||
id: base.id,
|
||||
binding: `device:${base.bindingRef}`,
|
||||
tap_action: 'toggle',
|
||||
toggle_entity: draft.toggleEntity || null,
|
||||
},
|
||||
} : realPreview(draft);
|
||||
|
||||
card._openMarkerDialog({
|
||||
...base,
|
||||
tapAction: 'toggle',
|
||||
entities: [primary, secondary],
|
||||
marker: {
|
||||
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
|
||||
tap_action: 'toggle', toggle_entity: primary,
|
||||
},
|
||||
});
|
||||
await card.updateComplete;
|
||||
let select = root().querySelector('#marker-toggle-entity');
|
||||
if (!select) throw new Error(JSON.stringify({
|
||||
dialog: card._markerDialog,
|
||||
effectiveTapAction: card._effectiveMarkerTapAction(card._markerDialog),
|
||||
preview: card._markerPreviewDevice(card._markerDialog),
|
||||
selectIds: [...root().querySelectorAll('select')].map((item) => item.id),
|
||||
}));
|
||||
out.selectorForMultiple = !!select && select.options.length === 3;
|
||||
out.savedSelectionProjected = select?.value === primary;
|
||||
|
||||
select.value = secondary;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await card.updateComplete;
|
||||
select = root().querySelector('#marker-toggle-entity');
|
||||
const hint = root().querySelector('#marker-toggle-hint')?.textContent || '';
|
||||
out.selectionUpdatesDraft = card._markerDialog.toggleEntity === secondary
|
||||
&& select?.value === secondary;
|
||||
out.selectionUpdatesHintBeforeSave = hint.includes(secondary)
|
||||
&& card._markerDialog.tapHintAnnouncement.includes(secondary);
|
||||
out.writerPersistsSelection = card._markerToggleEntityFields(card._markerDialog)
|
||||
.toggle_entity === secondary;
|
||||
|
||||
// Re-open the persisted shape, then simulate a temporarily missing entity.
|
||||
card._markerDialog = null;
|
||||
card._openMarkerDialog({
|
||||
...base,
|
||||
tapAction: 'toggle',
|
||||
entities: [primary, secondary],
|
||||
marker: {
|
||||
...(base.marker || {}), id: base.id, binding: `device:${base.bindingRef}`,
|
||||
tap_action: 'toggle', toggle_entity: secondary,
|
||||
},
|
||||
});
|
||||
await card.updateComplete;
|
||||
out.reopenRestoresSelection = root().querySelector('#marker-toggle-entity')?.value === secondary;
|
||||
|
||||
card._markerDialog = {
|
||||
...card._markerDialog,
|
||||
toggleEntity: 'switch.issue178_removed',
|
||||
toggleEntityTouched: false,
|
||||
originalHasToggleEntity: true,
|
||||
originalToggleEntity: 'switch.issue178_removed',
|
||||
};
|
||||
await card.updateComplete;
|
||||
const warning = root().querySelector('.markertoggleentity [role="status"]');
|
||||
out.staleWarnsAndShowsAuto = root().querySelector('#marker-toggle-entity')?.value === ''
|
||||
&& warning?.textContent?.includes('switch.issue178_removed');
|
||||
out.staleLiteralSurvives = card._markerToggleEntityFields(card._markerDialog)
|
||||
.toggle_entity === 'switch.issue178_removed';
|
||||
|
||||
card._markerDialog = null;
|
||||
card._markerPreviewDevice = realPreview;
|
||||
const single = card._devices.find((device) =>
|
||||
device.bindingKind === 'device' && device.entities.filter((eid) =>
|
||||
eid.startsWith('light.') || eid.startsWith('switch.')).length === 1);
|
||||
card._openMarkerDialog({
|
||||
...single,
|
||||
tapAction: 'toggle',
|
||||
marker: {
|
||||
...(single.marker || {}), id: single.id, binding: `device:${single.bindingRef}`,
|
||||
tap_action: 'toggle',
|
||||
},
|
||||
});
|
||||
await card.updateComplete;
|
||||
out.singleEntityHidesSelector = !root().querySelector('#marker-toggle-entity');
|
||||
card._markerDialog = null;
|
||||
await card.updateComplete;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result, {});
|
||||
await finish(browser, result);
|
||||
@@ -0,0 +1,158 @@
|
||||
/** Issue #173: one Walls chain, face proposal and atomic finish/cancel. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1100, height: 820 }, 1);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => { card.requestUpdate(); await card.updateComplete; };
|
||||
const space = () => card._serverCfg.spaces[0];
|
||||
const resetGeometry = async (rooms = []) => {
|
||||
const current = space();
|
||||
current.rooms = JSON.parse(JSON.stringify(rooms));
|
||||
delete current.room_drafts;
|
||||
delete current.partitions;
|
||||
delete current.walls;
|
||||
delete current.open_spans;
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._path = [];
|
||||
card._activeDraftId = null;
|
||||
card._draftSegmentCms = [];
|
||||
card._wallFaceBatch = null;
|
||||
card._roomDialog = false;
|
||||
card._tool = 'draw';
|
||||
await update();
|
||||
};
|
||||
const draw = async (points) => {
|
||||
card._tool = 'draw';
|
||||
card._path = [[...points[0]]];
|
||||
card._activeDraftId = null;
|
||||
card._draftSegmentCms = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const before = card._path.map((point) => [...point]);
|
||||
card._path = [...card._path, [...points[i]]];
|
||||
card._persistActiveDraftSegment();
|
||||
card._offerWallFaces(before);
|
||||
await update();
|
||||
}
|
||||
};
|
||||
|
||||
card._serverCfg = {
|
||||
spaces: [{
|
||||
id: 'walls', title: 'Walls', cell_cm: 5, view_box: [0, 0, 1, 0.7],
|
||||
rooms: [], openings: [], room_drafts: [], partitions: [], wall_columns: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._space = 'walls';
|
||||
card._layout = {};
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._setMode('plan');
|
||||
card._tool = 'draw';
|
||||
await update();
|
||||
|
||||
const toolbarLabels = [...root().querySelectorAll('.planbar .editbar-tools > button, .wallsgroup > button')]
|
||||
.map((button) => (button.textContent || '').trim());
|
||||
out.oneWallsButton = toolbarLabels.filter((label) => ['Walls', 'Стены'].includes(label)).length === 1;
|
||||
out.partitionButtonRemoved = !toolbarLabels.some((label) => ['Partition', 'Перегородка'].includes(label));
|
||||
out.splitRemains = toolbarLabels.some((label) => ['Split', 'Разделить'].includes(label));
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[100, 100], [300, 100], [300, 250]]);
|
||||
out.openChainIsCrashSafeDraft = space().room_drafts?.length === 1
|
||||
&& space().room_drafts[0].segments.length === 2;
|
||||
const cms = space().room_drafts[0].segments.map((segment) => segment.cm);
|
||||
card._activateMarkupTool('select');
|
||||
await update();
|
||||
out.toolChangeFinishesPartitions = !space().room_drafts
|
||||
&& space().partitions?.length === 2
|
||||
&& space().partitions.every((partition, index) => partition.cm === cms[index]);
|
||||
out.finishedChainDoesNotResume = !card._activeDraftId && card._path.length === 0;
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[100, 100], [300, 100], [300, 300], [100, 300], [100, 100]]);
|
||||
out.closedFaceOpensDialog = card._roomDialog && card._wallFaceBatch?.candidates.length === 1;
|
||||
out.terminalSegmentPersistedBeforeDecision = space().room_drafts?.[0]?.segments.length === 4;
|
||||
card._nameSel = 'Graph room';
|
||||
card._saveRoom();
|
||||
await update();
|
||||
out.acceptCreatesRoomAtomically = space().rooms?.length === 1
|
||||
&& space().rooms[0].name === 'Graph room'
|
||||
&& !space().room_drafts && !(space().partitions?.length);
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[500, 100], [700, 100], [700, 300], [500, 300], [500, 100]]);
|
||||
const draftBeforeCancel = JSON.stringify(space().room_drafts);
|
||||
card._roomDialogCancel();
|
||||
await update();
|
||||
out.cancelKeepsWholeTerminalDraft = !card._roomDialog && !card._wallFaceBatch
|
||||
&& JSON.stringify(space().room_drafts) === draftBeforeCancel
|
||||
&& card._path.length === 5;
|
||||
card._onKey(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
|
||||
}));
|
||||
await update();
|
||||
out.escapeUndoContractStillRemovesLastSegment = space().room_drafts?.[0]?.segments.length === 3
|
||||
&& card._path.length === 4;
|
||||
|
||||
await resetGeometry();
|
||||
await draw([[100, 450], [300, 450], [300, 650], [100, 650], [100, 450]]);
|
||||
card._onKey(new KeyboardEvent('keydown', {
|
||||
key: 'z', code: 'KeyZ', ctrlKey: true, bubbles: true,
|
||||
}));
|
||||
await update();
|
||||
out.ctrlZInQueueRemovesTerminalPoint = !card._roomDialog && !card._wallFaceBatch
|
||||
&& card._path.length === 4 && space().room_drafts?.[0]?.segments.length === 3;
|
||||
|
||||
await resetGeometry();
|
||||
space().partitions = [
|
||||
{ id: 'top', a: [0.1, 0.1], b: [0.5, 0.1], cm: 18 },
|
||||
{ id: 'right', a: [0.5, 0.1], b: [0.5, 0.3], cm: 18 },
|
||||
{ id: 'bottom', a: [0.5, 0.3], b: [0.1, 0.3], cm: 18 },
|
||||
{ id: 'left', a: [0.1, 0.3], b: [0.1, 0.1], cm: 18 },
|
||||
];
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
await draw([[260, 100], [260, 300]]);
|
||||
out.multiFaceTClosureOrdered = card._wallFaceBatch?.candidates.length === 2
|
||||
&& card._wallFaceBatch.candidates[0].area < card._wallFaceBatch.candidates[1].area;
|
||||
card._nameSel = 'Small face';
|
||||
card._saveRoom();
|
||||
await update();
|
||||
out.firstQueueDecisionIsBuffered = card._wallFaceBatch?.index === 1
|
||||
&& space().rooms.length === 0 && space().room_drafts?.length === 1;
|
||||
card._keepClosedAsPartitions();
|
||||
await update();
|
||||
out.mixedQueueAppliesOnce = space().rooms.length === 1
|
||||
&& space().rooms[0].name === 'Small face'
|
||||
&& !space().room_drafts && space().partitions?.length === 4;
|
||||
|
||||
const parent = {
|
||||
id: 'parent', name: 'Parent', area: 'area-parent', open_to: ['neighbour'],
|
||||
settings: { name_scale: 1.2 },
|
||||
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]],
|
||||
};
|
||||
await resetGeometry([parent]);
|
||||
await draw([[200, 100], [200, 500]]);
|
||||
out.cleanSplitUsesOneCandidate = card._roomDialog
|
||||
&& card._wallFaceBatch?.candidates.length === 1
|
||||
&& !!card._wallFaceBatch.candidates[0].split;
|
||||
card._nameSel = 'Child';
|
||||
card._saveRoom();
|
||||
await update();
|
||||
const retained = space().rooms.find((room) => room.id === 'parent');
|
||||
out.cleanSplitRetainsParentMetadata = space().rooms.length === 2
|
||||
&& retained?.name === 'Parent' && retained?.area === 'area-parent'
|
||||
&& retained?.settings?.name_scale === 1.2;
|
||||
out.cleanSplitConsumesDivider = !(space().partitions?.length) && !space().room_drafts;
|
||||
out.wallFaceCacheIsBounded = card._wallFaceGraphCache?.length > 0
|
||||
&& card._wallFaceGraphCache.length <= 4;
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(result));
|
||||
@@ -0,0 +1,155 @@
|
||||
/** Issue #141: joined independent walls share one production geometry surface. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => {
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
};
|
||||
const entry = (a, b, cm) => ({
|
||||
// The smoke needs an exact persisted span; the renderer resolves the key
|
||||
// from these endpoints before it falls back to the legacy bucket.
|
||||
key: `smoke-${a.join('-')}-${b.join('-')}`, a: [...a], b: [...b], cm,
|
||||
});
|
||||
const a = [0.05, 0.05], tr = [0.95, 0.05], br = [0.95, 0.95], bl = [0.05, 0.95];
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'junctions', title: 'Junctions', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
|
||||
walls: [entry(a, tr, 0), entry(tr, br, 0), entry(br, bl, 0), entry(bl, a, 0)],
|
||||
partitions: [
|
||||
{ id: 'l-horizontal', a: [0.10, 0.30], b: [0.40, 0.30], cm: 10 },
|
||||
{ id: 'l-vertical', a: [0.40, 0.30], b: [0.40, 0.60], cm: 20 },
|
||||
{ id: 'oblique-a', a: [0.58, 0.22], b: [0.76, 0.38], cm: 22 },
|
||||
{ id: 'oblique-b', a: [0.76, 0.38], b: [0.62, 0.53], cm: 12 },
|
||||
{ id: 't-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
|
||||
{ id: 't-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
|
||||
{ id: 'room-branch', a: [0.30, 0.82], b: [0.30, 0.95], cm: 18 },
|
||||
],
|
||||
room_drafts: [{
|
||||
id: 'saved-draft', points: [[0.12, 0.56], [0.28, 0.56], [0.28, 0.64]],
|
||||
segments: [{ cm: 12 }, { cm: 18 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = {};
|
||||
card._space = 'junctions';
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
await update();
|
||||
|
||||
const sourceBefore = JSON.stringify(card._serverCfg.spaces[0]);
|
||||
const space = card._spaceModel();
|
||||
const bodies = card._physicalBodiesR(space);
|
||||
const raw = card._rawPhysicalBodiesR(space);
|
||||
const frame = card._physicalBodiesCache;
|
||||
result.computedPatchesExist = frame?.patches.length >= 4 && bodies.length > raw.length;
|
||||
result.rawIdentityCountStaysPerSegment = raw.length === 9
|
||||
&& card._curSpaceCfg.partitions.length === 7
|
||||
&& card._curSpaceCfg.room_drafts[0].segments.length === 2;
|
||||
|
||||
const wallBody = root().querySelector('.wallbody');
|
||||
const missingCorner = new DOMPoint(404, 298);
|
||||
result.savedRightAngleToothIsFilled = !!wallBody?.isPointInFill(missingCorner);
|
||||
const planD = wallBody?.getAttribute('d') || '';
|
||||
|
||||
const insideRing = (point, ring) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const a0 = ring[i], b0 = ring[j];
|
||||
const crosses = ((a0[1] > point[1]) !== (b0[1] > point[1]))
|
||||
&& point[0] < ((b0[0] - a0[0]) * (point[1] - a0[1]))
|
||||
/ ((b0[1] - a0[1]) || 1e-12) + a0[0];
|
||||
if (crosses) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const insideGeometry = (point, geometry) => (geometry || []).some((polygon) =>
|
||||
polygon?.length && insideRing(point, polygon[0])
|
||||
&& !polygon.slice(1).some((hole) => insideRing(point, hole)));
|
||||
const room = space.rooms[0];
|
||||
const clean = card._cleanFloor(room, room.poly, space);
|
||||
result.cleanFloorUsesJoinedCorner = !insideGeometry([404, 298], clean.geom);
|
||||
const light = card._lightBarriers(
|
||||
space, [{ r: room, poly: room.poly }], card._physicalBodiesR(space),
|
||||
);
|
||||
result.lightUsesJoinedCorner = insideGeometry([404, 298], light.masonryGeometry);
|
||||
|
||||
card._tool = 'draw';
|
||||
card._activeDraftId = null;
|
||||
card._path = [[120, 780], [380, 780]];
|
||||
card._draftSegmentCms = [12];
|
||||
card._drawWallField = '24';
|
||||
card._cursorPt = [380, 600];
|
||||
await update();
|
||||
const liveD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
|
||||
card._path = [[120, 780], [380, 780], [380, 600]];
|
||||
card._draftSegmentCms = [12, 24];
|
||||
card._cursorPt = null;
|
||||
await update();
|
||||
const committedPreviewD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
|
||||
result.rubberBandAndCommittedPreviewMatch = !!liveD && committedPreviewD === liveD;
|
||||
|
||||
card._path = [[500, 540]];
|
||||
card._draftSegmentCms = [];
|
||||
card._drawWallField = '20';
|
||||
card._cursorPt = [500, 700];
|
||||
await update();
|
||||
result.lineTargetGetsLocalJoinPatch = root().querySelectorAll('.drawwall-preview').length === 2;
|
||||
result.previewDoesNotWriteOrSplitTarget = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
|
||||
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card._tool = 'select';
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.planViewParity = !!planD && root().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=junctions');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof card._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
card._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await update();
|
||||
result.isoUsesJoinedFootprint = !!root().querySelector('.iso-walls .iso-wall-top')
|
||||
&& card._isoSource().build().walls.flat(2).length > 0;
|
||||
card._setProjection('flat');
|
||||
await update();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
const baseCall = card.hass.callWS.bind(card.hass);
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'junctions', show_button: false });
|
||||
staticCard.hass = { ...card.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: structuredClone(cfg), rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
return baseCall(message);
|
||||
} };
|
||||
document.body.appendChild(staticCard);
|
||||
const started = Date.now();
|
||||
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await staticCard.updateComplete;
|
||||
result.staticUsesSameJoinedPath = staticCard.renderRoot
|
||||
?.querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
staticCard.remove();
|
||||
|
||||
result.renderNeverRewritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* #150: a production-scale Split must preserve the exact 10 cm → 0 cm
|
||||
* exterior breakpoint in every consumer of canonical wall geometry.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.renderRoot;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const settle = async () => {
|
||||
await card.updateComplete;
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
await frame();
|
||||
};
|
||||
const containsPath = (path, x, y) => !!path?.isPointInFill(new DOMPoint(x, y));
|
||||
const containsD = (d, x, y) => {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d || '');
|
||||
path.setAttribute('fill-rule', 'evenodd');
|
||||
svg.appendChild(path);
|
||||
document.body.appendChild(svg);
|
||||
const inside = containsPath(path, x, y);
|
||||
svg.remove();
|
||||
return inside;
|
||||
};
|
||||
const pointInRing = ([x, y], ring) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const a = ring[i], b = ring[j];
|
||||
if ((a[1] > y) !== (b[1] > y)
|
||||
&& x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) =>
|
||||
polygon?.length && pointInRing(point, polygon[0])
|
||||
&& !polygon.slice(1).some((hole) => pointInRing(point, hole)));
|
||||
|
||||
const space = card._serverCfg.spaces.find((item) => item.id === card._space);
|
||||
space.rooms = [{
|
||||
id: 'split-source', name: 'Source', area: null,
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
|
||||
}];
|
||||
delete space.walls;
|
||||
delete space.openings;
|
||||
delete space.open_spans;
|
||||
delete space.partitions;
|
||||
delete space.room_drafts;
|
||||
delete space.wall_columns;
|
||||
space.settings = { ...(space.settings || {}), show_borders: true };
|
||||
card._cfgEpoch++;
|
||||
card._regSignature = '';
|
||||
card._setMode('plan');
|
||||
card.requestUpdate();
|
||||
await settle();
|
||||
|
||||
// Exercise the real Plan handlers: select room, choose both wall endpoints,
|
||||
// confirm the child, then apply thickness to all walls of the left child.
|
||||
card._tool = 'split';
|
||||
card._splitClick([300, 300]);
|
||||
card._splitClick([500, 100]);
|
||||
card._splitClick([500, 900]);
|
||||
out.splitDialog = !!card._pendingSplit && !!card._roomDialog;
|
||||
card._nameSel = 'Right';
|
||||
card._saveRoom();
|
||||
await settle();
|
||||
|
||||
const rooms = card._spaceModel().rooms;
|
||||
const left = rooms.reduce((best, room) => {
|
||||
const meanX = room.poly.reduce((sum, point) => sum + point[0], 0) / room.poly.length;
|
||||
return !best || meanX < best.meanX ? { room, meanX } : best;
|
||||
}, null)?.room;
|
||||
out.splitCommitted = rooms.length === 2 && !!left;
|
||||
|
||||
card._tool = 'wallthick';
|
||||
card._wallThickClick([300, 100]);
|
||||
out.leftWallSelected = card._wallDialog?.roomId === left?.id;
|
||||
card._wallDialog = { ...card._wallDialog, value: '10' };
|
||||
card._wallThickApply(true);
|
||||
await settle();
|
||||
|
||||
out.savedIntervals = card._intervalCm([100, 100, 500, 100]) === 10
|
||||
&& card._intervalCm([500, 100, 900, 100]) === 0
|
||||
&& card._intervalCm([500, 100, 500, 900]) === 10;
|
||||
const half = (10 / card._cellCm) * card._gridPitch / 2;
|
||||
const planPath = root().querySelector('[data-hp="wall"]');
|
||||
const planD = planPath?.getAttribute('d') || '';
|
||||
out.planFullDepth = containsPath(planPath, 300, 100 - half * 0.75)
|
||||
&& containsPath(planPath, 300, 100 + half * 0.75);
|
||||
out.planZeroSideClear = !containsPath(planPath, 700, 96)
|
||||
&& !containsPath(planPath, 700, 104);
|
||||
const canonical = card._wallUnionGeometry();
|
||||
out.paperZeroSideClear = !!canonical?.paperD && !containsD(canonical.paperD, 700, 96);
|
||||
|
||||
card._setMode('view');
|
||||
await settle();
|
||||
const viewPath = root().querySelector('[data-hp="wall"]');
|
||||
const viewD = viewPath?.getAttribute('d') || '';
|
||||
out.viewMatchesPlan = !!planD && viewD === planD
|
||||
&& !containsPath(viewPath, 700, 96) && containsPath(viewPath, 300, 96);
|
||||
|
||||
const model = card._spaceModel();
|
||||
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
|
||||
const barriers = card._lightBarriers(model, polys, card._physicalBodiesR(model));
|
||||
out.lightUsesSteppedMasonry = pointInGeometry([300, 96], barriers.masonryGeometry)
|
||||
&& !pointInGeometry([700, 96], barriers.masonryGeometry);
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const staticCfg = JSON.parse(JSON.stringify(card._serverCfg));
|
||||
const baseCall = card.hass.callWS.bind(card.hass);
|
||||
const staticHass = { ...card.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: staticCfg, rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: card._layout || {}, rev: 1 };
|
||||
return baseCall(message);
|
||||
} };
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: card._space, show_button: false });
|
||||
staticCard.hass = staticHass;
|
||||
document.body.appendChild(staticCard);
|
||||
const started = Date.now();
|
||||
while (!staticCard.renderRoot?.querySelector('[data-hp="wall"]') && Date.now() - started < 6000)
|
||||
await new Promise((done) => setTimeout(done, 60));
|
||||
await staticCard.updateComplete;
|
||||
const staticPath = staticCard.renderRoot?.querySelector('[data-hp="wall"]');
|
||||
out.staticMatchesPlan = staticPath?.getAttribute('d') === planD
|
||||
&& !containsPath(staticPath, 700, 96) && containsPath(staticPath, 300, 96);
|
||||
staticCard.remove();
|
||||
|
||||
const active = Object.freeze(['iso']);
|
||||
card._onLabsSnapshot({ active, space: '' });
|
||||
window.__hpLabs = active;
|
||||
card._setProjection('iso');
|
||||
await settle();
|
||||
const isoWalls = card._isoSource().build().walls;
|
||||
out.hiddenIsoMatchesCanonical = !!root().querySelector('[data-hp="iso-walls"]')
|
||||
&& pointInGeometry([300, 96], isoWalls) && !pointInGeometry([700, 96], isoWalls);
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Issue #172: a slightly angled zero-depth Split stays free of masonry. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sp = () => c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
const pitch = 1 / 240;
|
||||
const wallKey = (a, b) => {
|
||||
const q = (value) => Math.round(value / pitch) * pitch;
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
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;
|
||||
angle = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
|
||||
};
|
||||
const entry = (a, b, cm) => ({ key: wallKey(a, b), a: [...a], b: [...b], cm });
|
||||
const update = async () => {
|
||||
c._cfgEpoch++;
|
||||
c._wallUnionCache = null;
|
||||
c._lightBarrierCache = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const samePoint = (a, b, epsilon = 0.02) => (
|
||||
Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon
|
||||
);
|
||||
const sharedEdge = (rooms) => {
|
||||
for (let ai = 0; ai < rooms[0].poly.length; ai++) {
|
||||
const a0 = rooms[0].poly[ai], a1 = rooms[0].poly[(ai + 1) % rooms[0].poly.length];
|
||||
for (let bi = 0; bi < rooms[1].poly.length; bi++) {
|
||||
const b0 = rooms[1].poly[bi], b1 = rooms[1].poly[(bi + 1) % rooms[1].poly.length];
|
||||
if (samePoint(a0, b1) && samePoint(a1, b0)) return [a0[0], a0[1], a1[0], a1[1]];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const pointInRing = (point, ring) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const a = ring[i], b = ring[j];
|
||||
if ((a[1] > point[1]) !== (b[1] > point[1])
|
||||
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0])
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) => (
|
||||
polygon?.[0] && pointInRing(point, polygon[0])
|
||||
&& !polygon.slice(1).some((hole) => pointInRing(point, hole))
|
||||
));
|
||||
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], br = [0.90, 0.80];
|
||||
const notchBottom = [0.60, 0.80], notch = [0.60, 0.40], bl = [0.10, 0.40];
|
||||
const outer = [
|
||||
entry(a, tr, 15), entry(tr, br, 15), entry(br, notchBottom, 15),
|
||||
entry(notchBottom, notch, 15), entry(notch, bl, 15), entry(bl, a, 15),
|
||||
];
|
||||
const space = sp();
|
||||
space.settings = { ...(space.settings || {}), show_borders: true };
|
||||
space.rooms = [{
|
||||
id: 'zero-divider-source', name: 'Zero divider source', area: null,
|
||||
poly: [a, tr, br, notchBottom, notch, bl],
|
||||
}];
|
||||
space.walls = outer;
|
||||
delete space.open_spans;
|
||||
delete space.openings;
|
||||
delete space.partitions;
|
||||
delete space.room_drafts;
|
||||
delete space.wall_columns;
|
||||
c._setMode('plan');
|
||||
c._tool = 'split';
|
||||
await update();
|
||||
|
||||
c._splitClick([700, 250]);
|
||||
c._splitClick([600, 400]);
|
||||
c._splitClick([900, 405]);
|
||||
out.realSplitReachedDialog = !!c._pendingSplit && !!c._roomDialog;
|
||||
c._nameSel = 'Zero divider child';
|
||||
c._commitRoom();
|
||||
await update();
|
||||
|
||||
const rooms = c._spaceModel().rooms;
|
||||
const divider = rooms.length === 2 ? sharedEdge(rooms) : null;
|
||||
out.realSplitSaved = rooms.length === 2 && !c._pendingSplit;
|
||||
out.dividerFound = !!divider;
|
||||
out.anglePreserved = !!divider && Math.abs(divider[3] - divider[1]) > 1;
|
||||
out.dividerStaysZero = !!divider && c._intervalCm(divider) === 0;
|
||||
|
||||
const samples = [];
|
||||
if (divider) {
|
||||
const [x0, y0, x1, y1] = divider;
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const nx = -dy / length, ny = dx / length;
|
||||
for (const t of [0.2, 0.35, 0.5, 0.65, 0.8]) {
|
||||
for (const side of [-1, 1])
|
||||
samples.push([x0 + dx * t + nx * side * 2, y0 + dy * t + ny * side * 2]);
|
||||
}
|
||||
}
|
||||
const wall = sr().querySelector('.wallbody');
|
||||
out.planHasNoTaper = !!wall && samples.length > 0
|
||||
&& samples.every((point) => !wall.isPointInFill(new DOMPoint(...point)));
|
||||
const planD = wall?.getAttribute('d') || '';
|
||||
out.planUsesCanonicalBody = !!planD && c._wallUnionGeometry()?.d === planD;
|
||||
|
||||
const lightSpace = c._spaceModel();
|
||||
const lightPolys = lightSpace.rooms
|
||||
.filter((room) => Array.isArray(room.poly))
|
||||
.map((room) => ({ r: room, poly: room.poly }));
|
||||
const lightPhysical = c._physicalBodiesR(lightSpace);
|
||||
const masonry = c._lightBarriers(lightSpace, lightPolys, lightPhysical).masonryGeometry;
|
||||
out.lightHasNoTaper = samples.every((point) => !pointInGeometry(point, masonry));
|
||||
|
||||
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
|
||||
c._setMode('view');
|
||||
await update();
|
||||
out.planViewParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
|
||||
const kioskBefore = c._config.kiosk;
|
||||
c._config.kiosk = true;
|
||||
await update();
|
||||
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
c._config.kiosk = kioskBefore;
|
||||
|
||||
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
|
||||
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
|
||||
&& c._isoSource().build().walls.flat(2).length > 0;
|
||||
c._setProjection('flat');
|
||||
await update();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = structuredClone(c._serverCfg);
|
||||
const baseCall = c.hass.callWS.bind(c.hass);
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
|
||||
staticCard.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
|
||||
return baseCall(message);
|
||||
} };
|
||||
document.body.appendChild(staticCard);
|
||||
const started = Date.now();
|
||||
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await staticCard.updateComplete;
|
||||
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
staticCard.remove();
|
||||
|
||||
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
@@ -1,6 +1,6 @@
|
||||
# House Plan architecture
|
||||
|
||||
Updated: 2026-08-11 (v1.61.0-beta.6 light-transport rewrite). The repository = a HACS integration (category **Integration**)
|
||||
Updated: 2026-08-19 (#113 optional space-model lifecycle). The repository = a HACS integration (category **Integration**)
|
||||
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
|
||||
|
||||
## Layout
|
||||
@@ -15,6 +15,7 @@ houseplan-card/
|
||||
│ ├─ floating-surface-controller.ts # shared Popover/fallback portal DOM lifecycle
|
||||
│ ├─ editor-secondary.ts # context tray model, groups, focus/dismiss lifecycle and stable template
|
||||
│ ├─ editor-secondary.styles.ts # styles owned by the context tray/submenu surface
|
||||
│ ├─ space-model-selection.ts # active-or-first and exact optional space selectors
|
||||
│ ├─ render/opening-tunnels.ts # immutable SVG projection of resolved tunnel geometry/fills
|
||||
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
|
||||
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
|
||||
@@ -92,7 +93,13 @@ the same profiler available between stable promotions.
|
||||
commit actions use two explicit wrapping groups: destructive actions stay
|
||||
left, while Cancel/Save move together to a right-aligned second line when
|
||||
translated labels do not fit.
|
||||
6. **One transient-surface contract.** `hp-dialog` owns a scoped LIFO registry
|
||||
6. **Open passages are negative architecture.** `OpeningCfg.type=passage`
|
||||
shares placement, wall-cut and tunnel geometry with other openings but has
|
||||
no visible leaf, state binding or isometric panel. Backend semantic
|
||||
validation is change-aware: existing broken records remain readable, while
|
||||
new writes/imports are canonical. Static wall fingerprints include passage
|
||||
cuts only, preserving the historical output of doors/windows/gates.
|
||||
7. **One transient-surface contract.** `hp-dialog` owns a scoped LIFO registry
|
||||
for explanatory/help and colour-picker surfaces. Escape and toast close the
|
||||
upper transient surface before the dialog, and a new transient surface
|
||||
replaces the previous one only inside the same dialog. `hp-help` and
|
||||
@@ -104,6 +111,18 @@ the same profiler available between stable promotions.
|
||||
affordance exists only when both its localized body and complete accessible
|
||||
label are non-empty; the card factory and `hp-help` enforce this independently,
|
||||
so incomplete content cannot leave a dead focus target or a layout gap.
|
||||
8. **One four-phase environment resolver.** `resolveDayCycle()` in `src/sun.ts`
|
||||
atomically chooses a strict real `sun.sun` sample or browser-local clock
|
||||
fallback and returns only phase/source/light tokens. `src/day-cycle-render.ts`
|
||||
owns the constant four-layer DOM and exact palette used by full View, kiosk,
|
||||
and `houseplan-space-card`; no surface copies thresholds or formulas. The
|
||||
environment is a pointer-inert sibling behind the plan. The only
|
||||
phase-dependent effect touching the SVG is a zero-offset filter on the one
|
||||
grouped `.hp-paperg` footprint; the content tree has no brightness, tint,
|
||||
opacity, or blend changes. Full and static card lifecycles arm a 30-second
|
||||
timer only during clock fallback while visible, catch up on visibility return,
|
||||
and dispose it on disconnect. Window-ray geometry remains a separate
|
||||
north-gated consumer of `sun.sun`.
|
||||
|
||||
## Coordinate system
|
||||
|
||||
@@ -119,6 +138,23 @@ the same profiler available between stable promotions.
|
||||
|
||||
## Card data model (runtime)
|
||||
|
||||
`SpaceModel` is absent when the authoritative configuration has no spaces.
|
||||
`_spaceModel()` therefore returns `SpaceModel | undefined`: it preserves the
|
||||
legacy active-or-first selection for rendering and current navigation, but it
|
||||
never invents a dummy space. Commands carrying a persisted or otherwise stable
|
||||
space id use the exact `_spaceModelById()` selector; a stale id aborts before
|
||||
config, layout, file or service side effects instead of mutating the first
|
||||
space.
|
||||
|
||||
The first update that observes an authoritative empty `spaces` array runs one
|
||||
space-bound lifecycle cleanup. It releases tracked pointer capture, cancels
|
||||
pan/pinch/drag/resize/vacuum and geometry gestures, clears draft/history and
|
||||
space dialogs, cancels the debounced config write and returns the card to View.
|
||||
The global empty-state Create/import flows remain available. Recreating a space
|
||||
re-arms cleanup so a later WS transition back to empty is handled identically.
|
||||
Pure render/geometry helpers may return an empty result while the model is
|
||||
absent; mutation entry points must guard explicitly.
|
||||
|
||||
`DevItem`: id (device_id), name, model, area, floor, icon, entities[], primary
|
||||
(the first resolved state entity for actions requiring one target), temp,
|
||||
members[] (light group), link/linkPrimary (Z2M group). Marker state consumes
|
||||
@@ -311,12 +347,22 @@ quotas, nothing is ever deleted for being old (docs/SCOPE.md).
|
||||
Room-boundary walls remain *derived* from room outlines (`roomEdges`, deduped by
|
||||
`segKey`), so deleting a room keeps the boundaries its neighbours still
|
||||
contribute. Three explicitly typed exceptions are stored per space:
|
||||
`room_drafts` for persisted unfinished room outlines, `partitions` for
|
||||
one-segment independent walls and `wall_columns` for square/circular columns.
|
||||
`room_drafts` for crash-safe unfinished Walls chains, `partitions` for finished
|
||||
independent wall segments and `wall_columns` for square/circular columns.
|
||||
They do not create a room or HA area and never split a room implicitly. Their
|
||||
physical bodies are unioned with room walls for rendering and light occlusion,
|
||||
and subtracted from clean room floor area. Openings (doors, windows and gates)
|
||||
still belong only to derived room walls and never cut an independent object.
|
||||
and subtracted from clean room floor area. A finished partition may explicitly
|
||||
host a door, window, gate or passage; unfinished drafts and columns may not.
|
||||
|
||||
Independent linear objects have two deliberate projections. Raw flat-capped
|
||||
quads preserve source identity for hit/selection/drag/properties/delete/history
|
||||
and furniture magnet behaviour. `physicalBodySet()` also derives exact
|
||||
endpoint↔endpoint and endpoint↔line topology, adds bounded mitre/bevel patches
|
||||
without persisted nodes or segment splits, and exposes the joined geometry to
|
||||
presentation and physics. Degree-one caps remain flat; an interior X crossing
|
||||
is only a boolean overlap. The full card caches this structural frame by
|
||||
space/config geometry, while static cards use a weak server-snapshot cache;
|
||||
cursor and HA state updates do not repeat the saved O(N²) node search.
|
||||
|
||||
Rooms may not overlap
|
||||
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
|
||||
@@ -334,7 +380,20 @@ before the shell is restored. Consequently a Split edge ending at an exterior
|
||||
vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
|
||||
an interior join/nested-room representation, and atomic quads provide a safe
|
||||
physical interval when an acute child ring cannot be subtracted. Paper and
|
||||
masonry paths are emitted by that same geometry pass. The full card retains the
|
||||
masonry paths are emitted by that same geometry pass. Computed independent
|
||||
junction patches enter through the same physical union. A partition-hosted
|
||||
opening is subtracted from its explicit raw body before that union. It also
|
||||
cuts a derived room wall only when the wall is exactly collinear and covers
|
||||
the complete hosted interval; crossing or nearby bodies remain opaque and room
|
||||
exterior authority remains intact.
|
||||
Before the exterior offset is built, each saved atomic endpoint splits its
|
||||
containing collinear union edge. Offset changes are explicit butt steps at that
|
||||
endpoint, including nonzero-to-zero transitions. The topology tolerance starts
|
||||
in render units and is divided by the current edge length before it is compared
|
||||
with or used to de-duplicate normalized `t` fractions; this keeps the result
|
||||
scale-independent and prevents one interval's depth from leaking into its
|
||||
neighbour.
|
||||
The full card retains the
|
||||
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
|
||||
cache guarded by a structural geometry fingerprint. This is computed render
|
||||
state only: it never rewrites rooms or wall entries, and an HA state tick does
|
||||
@@ -367,7 +426,7 @@ uses the Stage 1 latched Flat fallback. Details and fixed ratios are recorded in
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/column/merge/split/resize/opening/
|
||||
boundary/wallthick/delroom), `_path` (the current outline,
|
||||
vertices on the GRID_N=240 grid). Clicks on the stage → `_svgPoint`→`_snap`. The outline is closed
|
||||
= a click on the first vertex → area select (hass.areas) + name → room {poly}. Polygon rooms and
|
||||
@@ -390,16 +449,32 @@ viewBox, so zoom never changes the effective target. The first point is a
|
||||
transient gesture only: Esc, Undo/Redo, navigation, external config adoption,
|
||||
pointer cancellation and multi-touch discard it without touching history.
|
||||
|
||||
Every completed segment of an unfinished room contour is persisted in
|
||||
`room_drafts`, including the thickness selected when that segment was placed.
|
||||
Switching tools keeps it; the same editing session resumes automatically, and
|
||||
after reload either endpoint can be selected to continue. Closing converts the
|
||||
draft into a room, while the room dialog's secondary action converts its edges
|
||||
to independent partitions. `partition` creates exactly one wall per gesture;
|
||||
`column` creates a square object whose size comes from the current Thickness
|
||||
field. Double click edits physical-object properties, pointer drag moves the
|
||||
whole object on the grid, and Delete removes only the selected object. The
|
||||
legacy root `space.segments` array is still stripped on every save.
|
||||
Every completed Walls segment is persisted in `room_drafts`, including the
|
||||
thickness selected when that segment was placed. Changing Plan tool, editor or
|
||||
floor explicitly finishes an open chain by converting it to ordinary
|
||||
`partitions` in one history/save transaction; re-selecting Walls is a no-op.
|
||||
Pan, pinch, pointer cancellation and suppressed clicks never finish a chain or
|
||||
append a segment. A finished open chain is ordinary masonry and is not resumed
|
||||
as a draft. Reload recovery may resume only a still-active persisted draft.
|
||||
|
||||
`src/wall-face-graph.ts` derives an immutable planar graph from solid room edges
|
||||
after opening cuts, partitions, inactive drafts and the active chain. A sweep
|
||||
broadphase atomizes endpoint, T, X and collinear intersections; deterministic
|
||||
half-edge traversal extracts bounded canonical faces. The click handler diffs
|
||||
the graph before/after the latest segment and offers only newly created faces
|
||||
that contain an atom of that segment, ordered by area and canonical key. Exact
|
||||
or partial overlap with a room is rejected while legal nesting is preserved.
|
||||
A clean single-room divider reuses `splitRoomPath`, offering only the smaller
|
||||
child while the larger child retains the original room identity and metadata.
|
||||
|
||||
Room answers are buffered in `_wallFaceBatch`. Create/Keep-as-walls advance the
|
||||
queue without mutating geometry; Cancel/Esc restores the terminal draft. The
|
||||
last answer revalidates every face and capacity limit, then commits all accepted
|
||||
rooms, the split result and every unconsumed active atom in one Undo/Redo and
|
||||
config transaction. Existing saved source geometry is never atomized or
|
||||
rewritten merely because it participated in a face. `column` still creates a
|
||||
physical object whose size comes from the current Thickness field. The legacy
|
||||
root `space.segments` array is stripped on every save.
|
||||
|
||||
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
|
||||
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
|
||||
@@ -435,16 +510,17 @@ navigation, focus restoration and outside-dismiss consumption), but no current
|
||||
tools are grouped without a separate product decision. The change is UI-only:
|
||||
plan/config models and geometry commands are unchanged.
|
||||
|
||||
## Doors, windows & gates (v1.23.0+)
|
||||
## Doors, windows, gates & passages (v1.23.0+)
|
||||
|
||||
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle, a length and a
|
||||
wall, while markers are free points whose positions live in the layout store. Model:
|
||||
`{id, type: door|window|gate, x, y, angle, length, contact?, lock?, invert?, flip_h?, flip_v?}`
|
||||
(normalized coords; `length` normalized by plan width). Placement snaps onto the nearest
|
||||
**derived** wall via `snapToWall` (logic.ts) — the angle is normalized to [-90, 90) because two
|
||||
rooms share a wall with opposite edge directions, and without that a drag across segment
|
||||
boundaries flips the hinge. The opening then keeps **absolute coordinates**, so editing, merging
|
||||
or deleting rooms never breaks it.
|
||||
`space.openings[]` — plan geometry, **not** markers: an opening needs an angle,
|
||||
a length and one wall, while markers are free points whose positions live in
|
||||
the layout store. Model:
|
||||
`{id, type: door|window|gate|passage, x, y, angle, length, host?, contact?, lock?, invert?, flip_h?, flip_v?}`.
|
||||
Room-wall openings omit `host` and retain the absolute-coordinate association.
|
||||
An independent-wall opening stores
|
||||
`host:{kind:'partition',id,t}`; the stable id and normalized position `t` are
|
||||
authoritative, while `x/y/angle` are an atomically refreshed compatibility
|
||||
projection. No explicit host ever falls back to a nearest wall.
|
||||
|
||||
Rendering (after easy-floorplan, MIT): SVG symbol at the origin (jambs + hinged leaf + a
|
||||
quarter-circle arc revealed via `stroke-dashoffset`), translated/rotated onto the wall; windows
|
||||
@@ -461,8 +537,11 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
|
||||
Contact and lock are exact HA references owned by the opening, not aliases of
|
||||
standalone markers. Their candidate/action path follows HA binding status while
|
||||
their render path follows the frozen active-registry projection; neither path
|
||||
consults marker tombstones. Plan-level consumers keep the tombstone policy
|
||||
described above.
|
||||
consults marker tombstones. For that projection, the presence of an exact state
|
||||
is sufficient: registry-less YAML entities have no row, while explicit
|
||||
disabled/orphan rows have already been stripped together with their states.
|
||||
The render helper must never receive raw live hass. Plan-level consumers keep
|
||||
the tombstone policy described above.
|
||||
|
||||
For a wall with thickness, one `OpeningWallIndex` resolves the atomic wall
|
||||
interval and adjacent room on each side of the centreline. Opening symbols,
|
||||
@@ -482,6 +561,15 @@ room both halves; shared openings use a local-coordinate hard stop at `y=0`.
|
||||
Virtual spans, unfinished drafts and zero-thickness walls are ignored; legacy
|
||||
spans are clipped per atomic body.
|
||||
|
||||
The same resolved host drives placement, symbol face, full-depth partition cut,
|
||||
static/hidden-isometric rendering, Glow and edit operations. Rigid host drag
|
||||
keeps `t` and updates every materialized projection in one history command.
|
||||
Deleting a host with openings requires an explicit cascade dialog; an invalid
|
||||
host fails dark and is visible only as a rebind diagnostic in Plan. Structural
|
||||
room-face topology deliberately keeps every valid wall axis continuous through
|
||||
all opening types (#185); only `open_spans` and absent walls are connectivity
|
||||
gaps.
|
||||
|
||||
## Integration WS API
|
||||
|
||||
| Command | Parameters | Response |
|
||||
@@ -504,7 +592,7 @@ spans are clipped per atomic body.
|
||||
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
|
||||
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
|
||||
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
|
||||
| `houseplan/export/create` | `kind`, `space_id?`, `card_version` | consistent versioned JSON document + safe filename |
|
||||
| `houseplan/export/create` | `kind`, `space_id?`, `plan_only?`, `card_version` | consistent versioned JSON document + safe filename; plan-only is valid only for one space |
|
||||
| `houseplan/import/revalidate` | preview `token`, `duplicate_policy?` | refreshed bounded preview and current expected revisions |
|
||||
| `houseplan/import/apply` | token, both expected revisions, content confirmation | crash-resumable paired config/layout commit; full import gets one-deep undo |
|
||||
|
||||
@@ -535,6 +623,14 @@ versions, and retains the parsed candidate only in memory for ten minutes. Its
|
||||
opaque token is bound to the HA user, normalized-candidate digest and the exact
|
||||
config/layout revisions. Parsed candidates are capped globally as well as per
|
||||
user.
|
||||
|
||||
Plan-only export is a server-owned, fail-closed projection rather than a
|
||||
client-side scrub. It removes every marker and all device layout, preserves
|
||||
only canonical room-label placements, and copies one space through explicit
|
||||
geometry/presentation allowlists. The parser recomputes that projection and
|
||||
its placement manifest before showing a plan-only preview, so manually adding
|
||||
a private field while keeping `transfer.plan_only: true` is rejected.
|
||||
|
||||
The browser never parses imported configuration. Full import and maintenance
|
||||
share the `optimize_pending` crash-recovery intent and the one-deep backup slot;
|
||||
the backup carries `kind: optimize|import`, while every layout-store writer
|
||||
@@ -895,6 +991,11 @@ hash falls back to the default.
|
||||
service catalog is a second fail-closed guard. A click resolves the current
|
||||
marker by id rather than using a retained #73 visual snapshot; the snapshot
|
||||
remains valid only for read-only presentation.
|
||||
Optional `marker.toggle_entity` is an exact own `light.*`/`switch.*` override
|
||||
layered before the legacy own-role resolver. Its absence leaves legacy
|
||||
single/group membership unchanged; an active explicit choice also joins an
|
||||
explicit controls group, while stale values fall back without being erased.
|
||||
It is deliberately independent from visual `marker.light_entity`.
|
||||
- **Resolved device state** (2026-08-06): HA provides states per entity, not
|
||||
one state per device. `resolvedDeviceStateEntities` therefore starts from
|
||||
uncategorised registry entities, resolves one functional role (whole-device
|
||||
|
||||
@@ -542,3 +542,56 @@ the ±`CANVAS_LIMIT` guard. Rigid partition drag clamps one shared delta against
|
||||
both endpoints, so it cannot deform the segment or let its far endpoint cross
|
||||
the backend boundary. Hit areas and drag thresholds are expressed in CSS
|
||||
pixels, therefore selection remains usable at every zoom.
|
||||
|
||||
## Architectural connection overlay
|
||||
|
||||
When **Walls** is active in the Plan editor, a derived
|
||||
pointer-transparent SVG layer exposes the centre axes of completed room walls,
|
||||
saved inactive outlines and independent partitions. It is painted after their
|
||||
physical wall bodies, but before interactive editor chrome. Columns, decor,
|
||||
devices, the active wall chain and its live preview are not candidates. Door,
|
||||
window, gate and intentionally open-span intervals are cut from presentation
|
||||
axes; a cut boundary does not become a new endpoint.
|
||||
|
||||
The layer and hit resolver share one immutable geometry snapshot. Original
|
||||
segment endpoints are deduplicated and drawn at a physical radius of 5 cm.
|
||||
Inside a 12 CSS px hit zone, an endpoint wins over every line and grows to
|
||||
10 cm. Otherwise the nearest solid line receives one 10 cm dynamic node: the
|
||||
raw pointer is projected onto that line, then quantized by the grid step along
|
||||
the line from its stable start. This keeps diagonal connections wall-bound even
|
||||
when neither resulting coordinate is a global grid multiple. The same resolver
|
||||
runs again on click, so hover is only a preview and never authoritative.
|
||||
|
||||
Endpoint and line candidates override the normal grid and Shift/45° result.
|
||||
Outside the hit zone, §9.3–9.4 remain unchanged. A line connection adds only the
|
||||
new segment endpoint; it does not split or rewrite the existing wall. The
|
||||
current anchor is excluded to prevent zero-length segments. The static geometry
|
||||
is cached by structural editor state; pointer movement changes at most the
|
||||
single active candidate and never writes config, layout or storage.
|
||||
|
||||
## Planar wall faces
|
||||
|
||||
Every completed Walls segment is first persisted in the active `room_drafts`
|
||||
chain. On the click path only, an immutable planar graph is built from structural
|
||||
room edges, independent partitions, inactive drafts and the active chain both
|
||||
before and after the latest segment. Unlike the presentation/snap snapshot, this
|
||||
face graph ignores door/window/gate/passage cuts but still applies `open_spans`.
|
||||
Endpoint, T, X and
|
||||
collinear-overlap junctions atomize that computed graph without rewriting any
|
||||
saved wall. A deterministic half-edge walk extracts bounded faces; canonical
|
||||
identity ignores winding, cyclic start and derived collinear subdivision.
|
||||
|
||||
Only faces added by the latest segment and containing one of its atoms are
|
||||
offered. They are ordered by area and then canonical key. Existing exact or
|
||||
partially overlapping rooms are excluded, nested rooms remain legal, and any
|
||||
physical gap created by an `open_span` or absent wall remains a gap. A door,
|
||||
window, gate or passage is a property of a wall and preserves connectivity. A clean divider across
|
||||
one room reuses the Split contract: the larger side keeps the room identity,
|
||||
metadata and device binding, and only the smaller side is offered.
|
||||
|
||||
The active draft remains persisted while the resulting room dialogs are open.
|
||||
Create/Keep-as-walls answers are buffered; Cancel/Esc discards all answers and
|
||||
restores the terminal draft. The final answer revalidates the whole batch and
|
||||
applies accepted rooms plus every unconsumed active atom as one history/config
|
||||
transaction. Graph construction never runs on pointermove, Home Assistant state
|
||||
updates or ordinary rendering.
|
||||
|
||||
@@ -1,6 +1,155 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v1.65.0-beta.2 — 2026-08-18
|
||||
|
||||
- **Toggle state** confirmation now shows the current state and the exact
|
||||
expected result before acting. Groups show their active/total count and
|
||||
unavailable targets separately, while confirmation still re-resolves the
|
||||
live state and cancels if the target set changed
|
||||
([#103](https://github.com/Matysh/houseplan-card/issues/103)).
|
||||
- Composite Home Assistant devices with several light/switch entities now let
|
||||
you choose the exact entity operated by **Toggle state**. The dialog previews
|
||||
the selected target immediately, preserves missing choices with a warning,
|
||||
and can include the selected own entity in an explicitly configured group
|
||||
without changing existing plans ([#178](https://github.com/Matysh/houseplan-card/issues/178)).
|
||||
- A virtual Always-light linked to a real relay now follows that relay instead
|
||||
of keeping an independent manual state. Tapping either marker operates the
|
||||
real Home Assistant device, while Glow, Light fill, room counts and both
|
||||
markers update together; removing the final link restores the preserved
|
||||
manual state ([#174](https://github.com/Matysh/houseplan-card/issues/174)).
|
||||
- The Plan editor now has one **Walls** tool instead of separate Room outline
|
||||
and Partition tools. An open chain becomes ordinary walls when the tool,
|
||||
editor or floor changes; closing endpoint/T/X geometry offers every newly
|
||||
formed room in stable area order, with clean room splits and atomic
|
||||
Create/Keep/Cancel decisions. Ctrl/Cmd+click no longer adds an extra point
|
||||
while the chain is still too short to close
|
||||
([#173](https://github.com/Matysh/houseplan-card/issues/173)).
|
||||
- Doors, windows, gates and open passages can now be placed in finished
|
||||
independent Walls segments. They cut the full wall depth, move with their
|
||||
host, keep the same Home Assistant state/actions as room-wall openings and
|
||||
are removed only through an explicit cascade confirmation. Openings no
|
||||
longer break the structural wall axis, so drawing a closed contour still
|
||||
offers the room even when one of its walls already contains an opening.
|
||||
Space backups preserve the opening-to-wall binding when imported, while the
|
||||
editor's visual snap guide keeps its physical gap across the opening
|
||||
([#132](https://github.com/Matysh/houseplan-card/issues/132),
|
||||
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
|
||||
- Collinear exterior walls now change thickness exactly at their saved
|
||||
breakpoint. After splitting a room, a 10 cm wall therefore keeps its full
|
||||
depth up to the divider without leaking onto an adjacent zero-thickness
|
||||
facade; Plan, View, static, hidden isometric and light geometry share the
|
||||
corrected outline ([#150](https://github.com/Matysh/houseplan-card/issues/150)).
|
||||
- A zero-thickness Split divider no longer borrows a triangular wedge from an
|
||||
adjoining thick wall when the cut is slightly angled. The divider stays
|
||||
visually open along its full length while the real wall ends in a local cap,
|
||||
with the same geometry in Plan, View, Static, hidden isometric and lighting
|
||||
([#172](https://github.com/Matysh/houseplan-card/issues/172)).
|
||||
- Home Assistant devices and individual entities manually assigned to a room
|
||||
without an HA Area now stay in that exact room instead of returning to their
|
||||
registry Area. Existing saved assignments recover automatically, and
|
||||
editing a room within the same space keeps the marker position
|
||||
([#170](https://github.com/Matysh/houseplan-card/issues/170)).
|
||||
- Plan editor now supports **Open passage**, a 90 cm architectural opening
|
||||
with no leaf, arc or Home Assistant binding. It cuts masonry and continues
|
||||
the room floor in Full and Static views, passes interior light, and remains
|
||||
a full-height zero-panel cut in the hidden isometric experiment
|
||||
([#157](https://github.com/Matysh/houseplan-card/issues/157)).
|
||||
|
||||
## v1.65.0-beta.1 — 2026-08-17
|
||||
|
||||
- Current-space export can now create a **Plan only** JSON template with rooms,
|
||||
walls, openings, decor, backdrop, room-label positions and scale, while
|
||||
removing devices and structural Home Assistant bindings. Import preview identifies
|
||||
the template before adding it as a new space
|
||||
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
|
||||
- Composite appliances such as washing machines now use an explicit Home
|
||||
Assistant Status/Run state/Job state to show the yellow working marker during
|
||||
an active cycle. Power-on alone remains neutral, Power-off still fades stale
|
||||
activity, and ordinary lone relays keep their existing behaviour
|
||||
([#164](https://github.com/Matysh/houseplan-card/issues/164)).
|
||||
- Window sunlight now combines the Home Assistant azimuth with the literal
|
||||
direction of the compass N arrow, so a rotated real north lights the
|
||||
physically correct side of the plan. Users who mirrored the compass to work
|
||||
around the previous bug should point it back to the real north
|
||||
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
- Door, window and gate contacts and locks now keep working when they are live
|
||||
YAML entities without a `unique_id` and therefore have no Entity Registry
|
||||
row. The picker, View animation, lock badge and opening info card now follow
|
||||
the same exact reference, while disabled, orphaned and missing entities
|
||||
remain unavailable ([#117](https://github.com/Matysh/houseplan-card/issues/117)).
|
||||
|
||||
## v1.64.0-beta.3 — 2026-08-14
|
||||
|
||||
- Large plans no longer recompute an unused physical-wall union on every floor
|
||||
switch, and the Plan snap target before the first click updates without
|
||||
rerendering the complete card, removing the `v1.64.0` release performance
|
||||
regressions while preserving the same geometry and snap behaviour
|
||||
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.64.0 — 2026-08-14
|
||||
|
||||
- **Follows the Sun** now gives the plan a four-phase dawn/day/dusk/night
|
||||
environment with moving soft light while preserving the plan's live fills,
|
||||
Glow, devices, decor, vacuum, hover and window rays
|
||||
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
|
||||
- Room outline and Partition drawing now exposes exact wall snap nodes and
|
||||
centre lines, closes valid contours along an existing room wall, and forms
|
||||
seamless thick corner and T-junctions in flat, static and hidden isometric
|
||||
geometry ([#137](https://github.com/Matysh/houseplan-card/issues/137),
|
||||
[#138](https://github.com/Matysh/houseplan-card/issues/138),
|
||||
[#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- Read-only View and kiosk cards now paint a complete first frame even when
|
||||
Home Assistant refuses live-sync event subscriptions
|
||||
([#131](https://github.com/Matysh/houseplan-card/issues/131)).
|
||||
- Virtual markers configured as an always-on light source can now be toggled
|
||||
without a Home Assistant helper, with persistent shared state across full
|
||||
and static cards ([#107](https://github.com/Matysh/houseplan-card/issues/107)).
|
||||
- Opening and physical-object property dialogs keep their footer actions on one
|
||||
desktop line while retaining responsive narrow-screen wrapping
|
||||
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
|
||||
- Large plans no longer recompute an unused physical-wall union on every floor
|
||||
switch, and the Plan snap target before the first click updates without
|
||||
rerendering the complete card
|
||||
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.64.0-beta.2 — 2026-08-14
|
||||
|
||||
- **Follows the Sun** now gives the plan a four-phase dawn/day/dusk/night
|
||||
environment with a moving soft light and alpha-aware outer outline. It uses
|
||||
`sun.sun` when its sample is complete, falls back to local browser time, and
|
||||
no longer needs the compass; the plan, Glow, fills, devices, decor, vacuum,
|
||||
hover and window rays remain visually unchanged across phases. Full View,
|
||||
kiosk and static space cards share the result. New installations and spaces
|
||||
enable it explicitly, while a one-time migration and portable import/export
|
||||
preserve every existing plan's prior background choice. Existing zoom
|
||||
percentage badges remain visible above the plan at magnification
|
||||
([#146](https://github.com/Matysh/houseplan-card/issues/146)).
|
||||
- Opening and physical-object property dialogs now use the existing 500 px
|
||||
desktop shell, keeping Delete, Cancel and Save on one line with localization
|
||||
headroom while preserving responsive wrapping on narrow screens
|
||||
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
|
||||
- A new room outline that starts and ends on one uninterrupted solid interval
|
||||
of an existing room wall now closes automatically and opens the normal room
|
||||
dialog. Endpoints and wall-bound T-connection points work alike; openings,
|
||||
intentional gaps, different edges and a second point without enough sides do
|
||||
not trigger the shortcut ([#138](https://github.com/Matysh/houseplan-card/issues/138)).
|
||||
- Connected Room outline and Partition segments now form one seamless thick
|
||||
wall immediately, including the live rubber-band. Right and oblique corners
|
||||
use bounded mitre/bevel joins, T-connections have no visible butt face, each
|
||||
segment keeps its own thickness, and single-segment thickness hover and free
|
||||
ends remain visible and flat consistently in
|
||||
Plan, View, static cards, hidden isometric, clean-floor and light geometry
|
||||
([#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- Room outline and Partition drawing now shows wall centre lines and exact
|
||||
endpoints above existing walls. The enlarged target previews whether the
|
||||
next click will join an endpoint or create a wall-bound T-connection, while
|
||||
door/window/gate and intentional open-span gaps remain excluded
|
||||
([#137](https://github.com/Matysh/houseplan-card/issues/137)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.64.0-beta.1 — 2026-08-14
|
||||
|
||||
|
||||