Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a984d479fd | ||
|
|
0d58f77ced | ||
|
|
c27c1e44f3 | ||
|
|
508d38ca71 | ||
|
|
1c3404fdd0 | ||
|
|
3e335b8808 | ||
|
|
e2bb90b59b | ||
|
|
a80fa1fa5e | ||
|
|
0e53b1b0d6 | ||
|
|
fa015907d4 | ||
|
|
5d04e9b7c2 | ||
|
|
89789d8fa6 | ||
|
|
6846ffb828 | ||
|
|
c8755b7c51 | ||
|
|
fe15d863ce | ||
|
|
299da593d5 | ||
|
|
32e3a79dcd | ||
|
|
ffb10843c9 | ||
|
|
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 |
@@ -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
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
|
||||
- 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,11 +60,12 @@ 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 идут только там, где менялось
|
||||
@@ -71,7 +82,7 @@ jobs:
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with: { fetch-depth: 0 }
|
||||
- id: classify
|
||||
env:
|
||||
@@ -110,7 +121,7 @@ jobs:
|
||||
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:
|
||||
@@ -121,7 +132,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -130,8 +141,8 @@ jobs:
|
||||
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
|
||||
@@ -152,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
|
||||
@@ -179,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
|
||||
@@ -190,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
|
||||
@@ -212,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
|
||||
@@ -224,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
|
||||
@@ -242,7 +253,7 @@ 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
|
||||
@@ -252,12 +263,12 @@ jobs:
|
||||
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)
|
||||
|
||||
@@ -359,8 +359,12 @@ fingerprint is a hard failure, not a warning.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
an unfinished Validate for the same branch. Jobs: `provenance`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`.
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.3"
|
||||
VERSION = "1.65.0-beta.3"
|
||||
|
||||
# Portable backup format. This is deliberately independent from the Home
|
||||
# Assistant Store version above: storage migrations and files exported by a
|
||||
|
||||
@@ -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):
|
||||
@@ -139,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 []}
|
||||
@@ -254,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": {}}
|
||||
)
|
||||
@@ -283,36 +448,41 @@ def create_export(
|
||||
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 = {
|
||||
@@ -327,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
|
||||
@@ -416,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:
|
||||
@@ -490,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:
|
||||
@@ -621,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 []
|
||||
@@ -675,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)
|
||||
@@ -760,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)
|
||||
@@ -919,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,
|
||||
@@ -964,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"),
|
||||
@@ -1043,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",
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.64.0-beta.3"
|
||||
"version": "1.65.0-beta.3"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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()) {
|
||||
@@ -65,6 +66,7 @@ try {
|
||||
const measuredSample = iteration - warmups;
|
||||
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) => {
|
||||
@@ -122,6 +124,7 @@ try {
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
@@ -175,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'
|
||||
@@ -232,6 +242,7 @@ try {
|
||||
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) => ({
|
||||
@@ -284,13 +295,39 @@ try {
|
||||
=== 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;
|
||||
@@ -410,7 +447,7 @@ try {
|
||||
return result;
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
|
||||
@@ -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,42 +1,45 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 22,
|
||||
"acceptedAt": "2026-08-14T16:03:22.017Z",
|
||||
"sourceFingerprint": "24117bcfb4c94d5421e0304612089743615233eab3911f065e8cfa003f48fa7d",
|
||||
"matrixVersion": 26,
|
||||
"acceptedAt": "2026-08-19T02:08:44.141Z",
|
||||
"sourceFingerprint": "9cf7a16bbc4f0bde8ec29d7b58b52e0f616fc14601a183ea1df347477321a5da",
|
||||
"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",
|
||||
"day-cycle-dawn-dark": "a573083c4ee21993cf2e482b60418a30e388a14117d056a2b0ea559a0c928039",
|
||||
"day-cycle-day-dark": "6f24cd1d8669c9f3451c06ca5a5fd499d8f1ae2698a34f4a63ffee2e9e6b4330",
|
||||
"day-cycle-dusk-dark": "80029577c25f8759090ee2550fe530a35c189806dd6fa26e887da4f5d1cf54a3",
|
||||
"day-cycle-night-dark": "d855785914d3e11198d3e4671c1fbcad15104c95954bc1ed9b7366aa51aac65e",
|
||||
"geometry-plan-editor-dark": "16364738754515e81e6d0ae13c0358db8c7f9d26c2f34dfde15f5855c6af13fa",
|
||||
"plan-snap-endpoint-light": "c5f63ca2ca2706a062a2fc97e25670768662810bd7e0b251f0a9cfdbaf60c758",
|
||||
"plan-snap-line-gaps-dark": "44808a816e62416b9c2c39a6e06cd4a8631860e178f246772ce8c7a46f98edd0",
|
||||
"wall-junctions-plan-preview-light": "9e3a07da3e3ae1b2a95299f92b9500f347f0bfbd20d87429505b30769ee627c2",
|
||||
"wall-junctions-plan-t-dark": "a4a958f20ed5f4b8d4e6bca9ebd1b289186c0cb48b48897a1624b49014f3ae5d",
|
||||
"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": "1812cbf5f68a0431efed7823990020bd910f98e8e78c8e658f3012829395931a",
|
||||
"wall-junctions-view-dark": "73a64c65c8e77aa767bb7f401d68c61df5749e65e75273c85b0d6c72cb430766",
|
||||
"isometric-wall-junctions-dark": "cb1e28f484b304ecda3e35f66e0c0429016d10a5a3022dcc9315a92eb537300f",
|
||||
"opening-placement-door-thick-wall-dark": "395c03bbf5d968e83664fd6621f0ac25902e718022f2e92ffbcddb8ce629cf9c",
|
||||
"geometry-devices-editor-dark": "a9e4846ce5453400b87e6ad3d575882bc23a07b59dc3eecb612ed872b0c871ec",
|
||||
"geometry-decor-editor-dark": "435b36096bbb2996d56ff0af262ddebff4a727edd841b0fad9b8d4f507b987ac",
|
||||
"tray-wide-selection-en": "06b0df980fd79e8bfc2957878966a11ae9d1a61e80ee870094dc60eae4de26bb",
|
||||
"tray-wide-tool-ru": "e5a6b2057acd9af2c5417bf413778395112eb1ebecd784251c68c74a16b9dc5f",
|
||||
"tray-medium-group-en": "5115910bc0f359ce91f361794a51ae1ae6a493203941d09166b412b696eda775",
|
||||
"tray-medium-selection-ru": "4e5f235be8ed6296e136641d172e727a6a6b7a9d061f0928a96ccc1103c19f1f",
|
||||
"tray-narrow-palette-en": "88b9846e4b451ed95b7ae7d2c3183a2ea191d7668768992a1364c6a7a53eb0c6",
|
||||
"tray-narrow-tool-ru": "c4130715b3cb31c68619dfc706a3aa308e86edf272bfae6833b20666764ece2b",
|
||||
"geometry-diagonal-45-opening-dark": "01206d25631c8fd09fa077932fbb5c1ee115b65ba76b38e6f7b09315dbbf6002",
|
||||
"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": "ccf11295b104cecd4bf1e1bc95417003e2c40cfba29b5555ac9ba021b90d16a6",
|
||||
"geometry-diagonal-45-opening-dark": "dd93866af62313806a4444b707943c693e2b7709367156ebdc3b80168233bbe8",
|
||||
"openings-thick-wall-dark": "5aa0b3d26894bef9ab9fca25c31bbef2f13f2c410f5f6d3f61c8d608ceb929f8",
|
||||
"openings-filled-tunnel-dark": "167d92c11e6a8b3ff0f31177ac5905f8db4b5fb03ee78b4965c40bc45aeee50f",
|
||||
"openings-hidden-view-dark": "c85cc04d1d8622b98215e2bb83f5bb233a7cfb0ac684c912475ef7bc44245897",
|
||||
@@ -55,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",
|
||||
"device-help-popover-light-ru": "f1bf21d62a5dd349aa57b746069c5aef58d7a26b0b9d9e0c233fde0c1d56d7eb",
|
||||
"decor-color-popover-mobile-ru": "46d4c2e4dd20c3a38e90efe3db59b3e878bdbcf273fbc1aa23de4b230723fa6e",
|
||||
"backup-full-preview-desktop-en": "cc42a621f55f043b272014b9c32127823ca3573520e502966c71642027f7fdaf",
|
||||
"backup-space-preview-mobile-ru": "16d06859ea6aac6cbed586f0d6918da972e9c95206fffd771fe43b6084f79877"
|
||||
"device-dialog-desktop-en": "343b79ef61e1fb71906bce79db50d4d1d73b2af09ca86ed550fd91c7e2b03164",
|
||||
"device-dialog-mobile-ru": "c90be98e65c37963fd4443e413848ca68a412c9ee568d648aba76bf9232ee59d",
|
||||
"toggle-entity-dialog-desktop-en": "013e2d01382435d995e8deece19e07e9bcced4fe80952cbab765e642b3d641d6",
|
||||
"toggle-entity-dialog-mobile-ru": "7d3aecd318c6c0774dd3ce1c21c0fafb1ae2be23cebed6f548fbf8bd11ab2f62",
|
||||
"device-help-popover-light-ru": "3f14b5f159d4911f01f3a5d8f9db579bbc5da617a46653fe2d98c1e612a3f05c",
|
||||
"decor-color-popover-mobile-ru": "c662aeb49b4143e4b2fde6c1331d208c35afb33034211d1a0c938f2985bc5d41",
|
||||
"backup-full-preview-desktop-en": "6f0cfecf587b73f38088d414f489b67cc68ed71c37c96d4e307e66f6d97bda9f",
|
||||
"backup-plan-only-export-desktop-en": "1e1c8a9cc5383394b91e24af8342d92103a5e85b3e5c9b53c45ab0e9b0d57da4",
|
||||
"backup-space-preview-mobile-ru": "a4719cbe29b378ef7baa63bb7ff23e201b2943025008d939f2c08cee63bb9038"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 160 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: 336 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 |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 336 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: 201 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: 100 KiB |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 358 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 335 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) => ({
|
||||
@@ -120,7 +150,8 @@ export function prepareGoldenFixture(scenario) {
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|
||||
|| typeof scenario.northDeg === 'number') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
@@ -129,6 +160,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
...(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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -313,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;
|
||||
@@ -343,6 +384,7 @@ 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);
|
||||
@@ -538,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;
|
||||
@@ -557,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 = {
|
||||
@@ -564,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' }]
|
||||
|
||||
@@ -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 = 22;
|
||||
export const GOLDEN_MATRIX_VERSION = 27;
|
||||
|
||||
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,27 +18,40 @@ 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 } } },
|
||||
@@ -78,7 +95,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
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
|
||||
@@ -161,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',
|
||||
@@ -230,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 },
|
||||
@@ -237,9 +269,15 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'decor-color-popover-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'decor-color', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 390, height: 760 }, ...page },
|
||||
{ id: 'decor-color-popover-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'decor-color', language: 'en', theme: 'light',
|
||||
viewport: { width: 760, height: 820 }, ...page },
|
||||
{ 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,10 +4,11 @@
|
||||
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.
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"planSnapGeometry": 1
|
||||
"planSnapGeometry": 1,
|
||||
"wallFaceGraph": 4
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
@@ -89,7 +90,8 @@
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"planSnapGeometry": 0
|
||||
"planSnapGeometry": 0,
|
||||
"wallFaceGraph": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
|
||||
@@ -40,22 +40,31 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_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', '_planSnapGeometryCache', '_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',
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true },
|
||||
);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const settleMode = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
|
||||
while (card._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await card.updateComplete;
|
||||
};
|
||||
|
||||
[...root().querySelectorAll('.modetab')][2]?.click();
|
||||
await settleMode();
|
||||
card._decorTool = 'line';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const picker = root().querySelector('.editor-secondary hp-color-opacity');
|
||||
const trigger = picker?.shadowRoot?.querySelector('.trigger');
|
||||
const events = [];
|
||||
picker?.addEventListener('hp-color-opacity-change', (event) => events.push(event.detail));
|
||||
trigger?.click();
|
||||
await picker?.updateComplete;
|
||||
await frame();
|
||||
const surface = () => picker?.shadowRoot?.querySelector('.picker');
|
||||
const ranges = () => [...(surface()?.querySelectorAll('input[type="range"]') || [])];
|
||||
|
||||
out.oneSurfaceNoNativePicker = !!surface()
|
||||
&& surface().getAttribute('role') === 'dialog'
|
||||
&& !surface().querySelector('input[type="color"]');
|
||||
out.allDimensionsVisible = !!surface()?.querySelector('.sv-field')
|
||||
&& ranges().length === 4
|
||||
&& !!surface()?.querySelector('input[type="text"]')
|
||||
&& !!surface()?.querySelector('input[type="number"]');
|
||||
out.englishLabels = surface()?.getAttribute('aria-label') === 'Color picker'
|
||||
&& ranges().map((input) => input.getAttribute('aria-label')).join('|')
|
||||
=== 'Hue|Saturation|Brightness|Opacity';
|
||||
const rect = surface()?.getBoundingClientRect();
|
||||
out.narrowViewportFits = !!rect && rect.left >= 0 && rect.right <= innerWidth
|
||||
&& rect.top >= 0 && rect.bottom <= innerHeight
|
||||
&& surface().scrollWidth <= surface().clientWidth;
|
||||
|
||||
const hue = ranges()[0];
|
||||
const hueStyle = getComputedStyle(hue);
|
||||
out.hueTrackContract = hue?.classList.contains('hue-range')
|
||||
&& hue.min === '0' && hue.max === '359' && hue.step === '1'
|
||||
&& hueStyle.getPropertyValue('--hp-picker-hue-track').includes('linear-gradient')
|
||||
&& hue.getBoundingClientRect().height >= 40;
|
||||
const thumbShadow = hueStyle.getPropertyValue('--hp-picker-hue-thumb-shadow');
|
||||
out.hueThumbContrastContract = thumbShadow.includes('0 0 0 2px')
|
||||
&& thumbShadow.includes('0 0 0 3px');
|
||||
ranges()[1].value = '100';
|
||||
ranges()[1].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
ranges()[2].value = '100';
|
||||
ranges()[2].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
hue.value = '120';
|
||||
hue.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await picker.updateComplete;
|
||||
out.hueUpdatesSharedDraft = picker.color === '#00ff00'
|
||||
&& card._decorStyle.color === '#00ff00';
|
||||
|
||||
const beforeShift = Number(ranges()[0].value);
|
||||
ranges()[0].dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight', shiftKey: true, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await picker.updateComplete;
|
||||
out.shiftArrowUsesTenStep = Math.round(picker._hue) === beforeShift + 10;
|
||||
|
||||
const opacity = ranges().at(-1);
|
||||
opacity.value = '37';
|
||||
opacity.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await picker.updateComplete;
|
||||
out.opacitySharesTheSameSurface = picker.opacity === 0.37
|
||||
&& card._decorStyle.opacity === 0.37;
|
||||
|
||||
let hex = surface().querySelector('input[type="text"]');
|
||||
hex.value = '#0af';
|
||||
hex.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
await picker.updateComplete;
|
||||
out.shortHexNormalizesAndEmits = picker.color === '#00aaff'
|
||||
&& events.at(-1)?.color === '#00aaff';
|
||||
|
||||
const eventCount = events.length;
|
||||
hex = surface().querySelector('input[type="text"]');
|
||||
hex.value = '#12';
|
||||
hex.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
hex.dispatchEvent(new FocusEvent('blur'));
|
||||
await picker.updateComplete;
|
||||
hex = surface().querySelector('input[type="text"]');
|
||||
out.invalidHexNeverEmits = events.length === eventCount
|
||||
&& hex.value === '#00aaff' && hex.getAttribute('aria-invalid') === 'true';
|
||||
|
||||
const field = surface().querySelector('.sv-field');
|
||||
const fieldRect = field.getBoundingClientRect();
|
||||
const eventsBeforePointer = events.length;
|
||||
field.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
pointerId: 41, pointerType: 'touch', clientX: fieldRect.left + fieldRect.width * .25,
|
||||
clientY: fieldRect.top + fieldRect.height * .25, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
field.dispatchEvent(new PointerEvent('pointermove', {
|
||||
pointerId: 41, pointerType: 'touch', clientX: fieldRect.right + 20,
|
||||
clientY: fieldRect.bottom + 20, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
field.dispatchEvent(new PointerEvent('pointercancel', {
|
||||
pointerId: 41, pointerType: 'touch', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await frame();
|
||||
out.pointerCancelAddsNoEvent = events.length === eventsBeforePointer + 1
|
||||
&& events.at(-1)?.color === picker.color
|
||||
&& picker._activePointerId === null;
|
||||
|
||||
picker.showOpacity = false;
|
||||
await picker.updateComplete;
|
||||
out.colorOnlyKeepsColorControls = ranges().length === 3
|
||||
&& !surface().querySelector('input[type="number"]');
|
||||
picker.showOpacity = true;
|
||||
await picker.updateComplete;
|
||||
|
||||
card._config = { ...card._config, language: 'ru' };
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await picker.updateComplete;
|
||||
out.cardLanguageOwnsCopy = surface()?.getAttribute('aria-label') === 'Выбор цвета'
|
||||
&& surface()?.querySelector('input[type="text"]')?.getAttribute('aria-label') === 'Цвет HEX';
|
||||
|
||||
const escapeControl = surface()?.querySelector('input[type="range"]');
|
||||
escapeControl?.focus();
|
||||
escapeControl?.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await picker.updateComplete;
|
||||
await frame();
|
||||
out.escapeClosesFirstAndRefocuses = trigger?.getAttribute('aria-expanded') === 'false'
|
||||
&& picker.shadowRoot.activeElement === trigger;
|
||||
|
||||
picker.disabled = true;
|
||||
await picker.updateComplete;
|
||||
trigger?.click();
|
||||
await picker.updateComplete;
|
||||
out.disabledDoesNotOpen = trigger?.getAttribute('aria-expanded') === 'false';
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Issue #187: Glow source fallback uses light-policy bodies when boolean masonry fails. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 760 }, 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;
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
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;
|
||||
};
|
||||
|
||||
const spaceId = card._space;
|
||||
const lightDevice = card._devices.find((device) => device.space === spaceId
|
||||
&& device.entities.some((entityId) => entityId.startsWith('light.')));
|
||||
const sourceEid = lightDevice?.entities.find((entityId) => entityId.startsWith('light.'));
|
||||
if (!lightDevice || !sourceEid) return {
|
||||
fixtureHasLightSource: false,
|
||||
windowFallbackBodyIsOpaque: false,
|
||||
windowSourceSuppressedOnBooleanFailure: false,
|
||||
interiorPassageRemainsTransparent: false,
|
||||
};
|
||||
result.fixtureHasLightSource = true;
|
||||
|
||||
const centre = [500, 500];
|
||||
const opening = (type) => ({
|
||||
id: 'hosted-opening', type, x: 0.5, y: 0.5, angle: 0, length: 0.12,
|
||||
host: { kind: 'partition', id: 'host', t: 0.5 },
|
||||
});
|
||||
const currentState = card.hass.states[sourceEid];
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
states: {
|
||||
...card.hass.states,
|
||||
[sourceEid]: { ...currentState, state: 'on' },
|
||||
},
|
||||
};
|
||||
card._layout = {
|
||||
...card._layout,
|
||||
[lightDevice.id]: { s: spaceId, x: 0.5, y: 0.5 },
|
||||
};
|
||||
const replaceSpace = (type) => {
|
||||
card._serverCfg = {
|
||||
...card._serverCfg,
|
||||
spaces: card._serverCfg.spaces.map((space) => space.id !== spaceId ? space : ({
|
||||
...space,
|
||||
rooms: [{
|
||||
id: 'glow-room', name: 'Glow room', area: null,
|
||||
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
|
||||
}],
|
||||
settings: { ...(space.settings || {}), fill_mode: 'glow' },
|
||||
partitions: [{ id: 'host', a: [0.25, 0.5], b: [0.75, 0.5], cm: 15 }],
|
||||
openings: [opening(type)],
|
||||
room_drafts: [], wall_columns: [],
|
||||
})),
|
||||
};
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
card._lightPhysicalBodiesCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card._renderDeviceSnapshot = null;
|
||||
card._glowClipCache.clear();
|
||||
};
|
||||
|
||||
// Simulate wallBodiesGeometry() returning null after a clipping exception.
|
||||
// The remainder of the real _lightBarriers path still computes the exact
|
||||
// light-policy body set, then only its boolean geometry is made unavailable.
|
||||
const realLightBarriers = card._lightBarriers.bind(card);
|
||||
card._lightBarriers = (...args) => {
|
||||
const barriers = realLightBarriers(...args);
|
||||
if (result.windowFallbackBodyIsOpaque === undefined) {
|
||||
result.windowFallbackBodyIsOpaque = barriers.opaqueBodies
|
||||
.some((body) => pointIn(centre, body));
|
||||
}
|
||||
return { ...barriers, masonryGeometry: [] };
|
||||
};
|
||||
|
||||
replaceSpace('window');
|
||||
await update();
|
||||
const selector = `[data-glow-source="${CSS.escape(sourceEid)}"] .glow-pool`;
|
||||
result.windowSourceSuppressedOnBooleanFailure = !root().querySelector(selector);
|
||||
|
||||
replaceSpace('passage');
|
||||
await update();
|
||||
result.interiorPassageRemainsTransparent = !!root().querySelector(selector);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -158,6 +158,9 @@ const res = await page.evaluate(async () => {
|
||||
const pickerButton = picker?.shadowRoot?.querySelector('.trigger');
|
||||
pickerButton?.click();
|
||||
await frame();
|
||||
const colorOnlySurface = picker?.shadowRoot?.querySelector('.picker');
|
||||
out.glowColorOnlyHasNoAlpha = colorOnlySurface?.querySelectorAll('input[type="range"]').length === 3
|
||||
&& !colorOnlySurface?.querySelector('input[type="number"]');
|
||||
const glowHelpButton = helpButton('marker.glow_mode.help');
|
||||
const focusables = root().querySelector('hp-dialog')?._focusableElements?.() || [];
|
||||
out.shadowFocusables = focusables.includes(glowHelpButton) && focusables.includes(pickerButton);
|
||||
|
||||
@@ -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));
|
||||
@@ -17,7 +17,11 @@ const out = await page.evaluate(async () => {
|
||||
{ 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 }],
|
||||
openings: [
|
||||
{ id: 'door', type: 'door', x: 0.3, y: 0.1, angle: 0, length: 0.1 },
|
||||
{ id: 'partition-door', type: 'door', x: 0.84, y: 0.6, angle: 0, length: 0.05,
|
||||
host: { kind: 'partition', id: 'base-partition', t: 0.8 } },
|
||||
],
|
||||
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 }] }],
|
||||
@@ -63,7 +67,7 @@ const out = await page.evaluate(async () => {
|
||||
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.oneLinePerSolidInterval = overlay()?.querySelectorAll('.plan-snap-line').length === 13;
|
||||
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));
|
||||
@@ -81,6 +85,7 @@ const out = await page.evaluate(async () => {
|
||||
+line.getAttribute('x2'), +line.getAttribute('y2'),
|
||||
]);
|
||||
result.openingGapHasNoLine = !lines().some((line) => crosses(...line, 300, 100));
|
||||
result.partitionOpeningGapHasNoLine = !lines().some((line) => crosses(...line, 840, 600));
|
||||
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');
|
||||
@@ -148,6 +153,9 @@ const out = await page.evaluate(async () => {
|
||||
stage.dispatchEvent(eventAt(300, 100));
|
||||
await card.updateComplete;
|
||||
result.openingGapDoesNotActivateSnap = !active();
|
||||
stage.dispatchEvent(eventAt(840, 600));
|
||||
await card.updateComplete;
|
||||
result.partitionOpeningGapDoesNotActivateSnap = !active();
|
||||
stage.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true, pointerId: 41 }));
|
||||
await card.updateComplete;
|
||||
result.pointerLeaveClearsActiveNode = !active();
|
||||
|
||||
@@ -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);
|
||||
@@ -33,6 +33,7 @@ const out = await page.evaluate(async () => {
|
||||
card._draftSegmentCms = [];
|
||||
card._closingWallCm = null;
|
||||
card._roomDialog = false;
|
||||
card._wallFaceBatch = null;
|
||||
card._roomEditId = null;
|
||||
card._pendingSplit = null;
|
||||
card._toast = '';
|
||||
@@ -79,9 +80,10 @@ const out = await page.evaluate(async () => {
|
||||
await click(500, 500);
|
||||
const successfulDraft = clone(currentDraft());
|
||||
result.endpointAutoCloseOpensDialog = card._roomDialog
|
||||
&& card._path.length === 5
|
||||
&& close(card._path[0][0], card._path.at(-1)[0])
|
||||
&& close(card._path[0][1], card._path.at(-1)[1]);
|
||||
&& 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)
|
||||
@@ -119,7 +121,7 @@ const out = await page.evaluate(async () => {
|
||||
result.sharedWallKeepsNeighbourThickness = !!sharedWall
|
||||
&& (card._curSpaceCfg.walls || []).filter((wall) => wall.cm === 15).length >= 3;
|
||||
|
||||
// A canonical cut splits the room wall, so the same endpoint pair no longer closes.
|
||||
// #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,
|
||||
}] });
|
||||
@@ -127,21 +129,23 @@ const out = await page.evaluate(async () => {
|
||||
await click(800, 100);
|
||||
await click(800, 500);
|
||||
await click(500, 500);
|
||||
result.openingCutPreventsAutoClose = !card._roomDialog
|
||||
result.openingKeepsStructuralAutoClose = card._roomDialog
|
||||
&& card._wallFaceBatch?.candidates.length === 1
|
||||
&& card._path.length === 4
|
||||
&& currentDraft()?.points.length === 4;
|
||||
|
||||
// An eligible but self-intersecting prospective ring is consumed without partial write.
|
||||
// #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);
|
||||
const invalidDraftBefore = clone(currentDraft());
|
||||
await click(500, 500);
|
||||
result.invalidCloseHasNoPartialWrite = !card._roomDialog
|
||||
&& card._path.length === 3
|
||||
&& JSON.stringify(currentDraft()) === JSON.stringify(invalidDraftBefore)
|
||||
&& card._toast === card._t('toast.contour_cannot_close');
|
||||
result.xIntersectionOffersFacesWithoutPartialRooms = card._roomDialog
|
||||
&& card._wallFaceBatch?.candidates.length === 2
|
||||
&& card._path.length === 4
|
||||
&& currentDraft()?.segments.length === 3
|
||||
&& card._curSpaceCfg.rooms.length === 1;
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -120,6 +120,8 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,7 +111,7 @@ 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.
|
||||
7. **One four-phase environment resolver.** `resolveDayCycle()` in `src/sun.ts`
|
||||
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,
|
||||
@@ -131,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
|
||||
@@ -323,12 +347,12 @@ 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
|
||||
@@ -357,8 +381,18 @@ 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. Computed independent
|
||||
junction patches enter as extras only after room opening cuts, so an opening
|
||||
cannot cut a coincident partition and room exterior authority remains intact.
|
||||
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
|
||||
@@ -392,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
|
||||
@@ -415,26 +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.
|
||||
|
||||
Adjacent-room auto-close reuses the immutable architectural snap snapshot and
|
||||
requires the first and prospective terminal points to belong to one completed
|
||||
room's same solid segment after opening/open-span cuts. It runs only after the
|
||||
draft has two edges and after explicit first-point/Ctrl closure handling. The
|
||||
prospective polygon is checked for range, self-intersection and room overlap
|
||||
before `_path` or `room_drafts` changes; success persists the terminal segment
|
||||
and then opens the ordinary room dialog. Cancel therefore returns to the open
|
||||
draft at that terminal point, while Save lets the normal room/wall
|
||||
normalization preserve an existing shared wall's thickness.
|
||||
`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
|
||||
@@ -470,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
|
||||
@@ -496,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,
|
||||
@@ -517,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 |
|
||||
@@ -539,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 |
|
||||
|
||||
@@ -570,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
|
||||
@@ -930,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
|
||||
|
||||
@@ -545,13 +545,13 @@ pixels, therefore selection remains usable at every zoom.
|
||||
|
||||
## Architectural connection overlay
|
||||
|
||||
When **Room outline** or **Partition** is active in the Plan editor, a derived
|
||||
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 outline and its live preview are not candidates. Door,
|
||||
window, gate and intentionally open-span intervals are cut from room axes; a
|
||||
cut boundary does not become a new endpoint.
|
||||
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.
|
||||
@@ -565,17 +565,33 @@ 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, while the first
|
||||
point of a valid room outline remains an explicit closure target. 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.
|
||||
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.
|
||||
|
||||
For **Room outline**, the same completed-room provenance also enables adjacent
|
||||
auto-close. Once the draft already has two edges, a click that places its next
|
||||
point on the same uninterrupted solid room-wall segment as the first point is
|
||||
validated as the prospective polygon before any mutation. A valid click
|
||||
persists that terminal draft segment and opens the standard room dialog with
|
||||
the existing wall as the closing edge. A cut, a different edge, a saved draft
|
||||
or an independent partition is ineligible; an eligible but invalid polygon is
|
||||
reported without appending a point or segment. Explicit first-point and
|
||||
Ctrl/Cmd closure retain priority.
|
||||
## 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,104 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v1.65.0-beta.3 — 2026-08-19
|
||||
|
||||
- The House Plan color picker now draws the full spectrum directly on the Hue
|
||||
track, so the target colour family is visible before moving the slider. Mouse,
|
||||
touch and keyboard values remain unchanged, and a contrasting ring keeps the
|
||||
slider visible over every hue
|
||||
([#192](https://github.com/Matysh/houseplan-card/issues/192)).
|
||||
- Editor color controls now open a single House Plan picker with saturation,
|
||||
brightness, hue, exact HEX and opacity together. The nested browser color
|
||||
dialog is gone, while existing room, space, decor and Glow values keep their
|
||||
saved format ([#57](https://github.com/Matysh/houseplan-card/issues/57)).
|
||||
- **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)).
|
||||
- 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)).
|
||||
- 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)).
|
||||
- Glow now remains fail-dark if wall boolean geometry cannot be built: a light
|
||||
source inside a partition-hosted window or exterior opening stays suppressed,
|
||||
while valid interior passages remain transparent
|
||||
([#187](https://github.com/Matysh/houseplan-card/issues/187)).
|
||||
- The Plan editor's snap guide now leaves a real gap across openings hosted by
|
||||
independent Walls segments, so hover and clicks inside a door, window, gate
|
||||
or passage no longer snap to wall material that is not there
|
||||
([#189](https://github.com/Matysh/houseplan-card/issues/189)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
## v1.65.0-beta.2 — 2026-08-18
|
||||
|
||||
- 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)).
|
||||
- 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.
|
||||
|
||||
## v1.64.0-beta.3 — 2026-08-14
|
||||
|
||||
@@ -32,6 +130,10 @@
|
||||
- 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
|
||||
|
||||
@@ -6,7 +6,112 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## Unreleased
|
||||
## v1.65.0-beta.3 — 2026-08-19
|
||||
|
||||
- Палитра House Plan теперь показывает полный спектр прямо на шкале «Оттенок»,
|
||||
поэтому нужную группу цветов видно ещё до движения ползунка. Значения и
|
||||
управление мышью, касанием и клавиатурой не изменились, а контрастный контур
|
||||
сохраняет видимость ползунка на любом оттенке
|
||||
([#192](https://github.com/Matysh/houseplan-card/issues/192)).
|
||||
- Выбор цвета в редакторах теперь сразу открывает единую палитру House Plan с
|
||||
насыщенностью, яркостью, оттенком, точным HEX и прозрачностью. Вложенный
|
||||
системный диалог браузера больше не нужен, а сохранённый формат цветов комнат,
|
||||
пространств, декора и Glow не меняется
|
||||
([#57](https://github.com/Matysh/houseplan-card/issues/57)).
|
||||
- Подтверждение действия **«Переключить состояние»** теперь показывает текущее
|
||||
состояние и точный ожидаемый результат. Для группы отдельно видны число
|
||||
включённых целей и недоступные цели; перед выполнением состояние по-прежнему
|
||||
вычисляется заново, а смена набора целей отменяет команду
|
||||
([#103](https://github.com/Matysh/houseplan-card/issues/103)).
|
||||
- Дверь, окно, ворота и открытый проём теперь можно поставить в законченный
|
||||
независимый отрезок «Стен». Проём прорезает его на всю глубину, движется
|
||||
вместе со своей стеной, сохраняет обычные состояния и действия Home
|
||||
Assistant, а при удалении стены удаляется только после отдельного
|
||||
подтверждения. Проём больше не разрывает структурную ось стены: замкнутый
|
||||
контур предлагает создать комнату, даже если в одной из стен уже есть проём.
|
||||
Импорт резервной копии этажа сохраняет привязку проёма к стене, а визуальная
|
||||
направляющая привязки по-прежнему показывает физический разрыв в месте проёма
|
||||
([#132](https://github.com/Matysh/houseplan-card/issues/132),
|
||||
[#185](https://github.com/Matysh/houseplan-card/issues/185)).
|
||||
- Датчики и замки дверей, окон и ворот теперь работают и для живых
|
||||
YAML-сущностей без `unique_id`, у которых поэтому нет строки в Entity
|
||||
Registry. Список выбора, анимация в Просмотре, значок замка и карточка проёма
|
||||
используют одну точную ссылку; отключённые, потерянные и отсутствующие
|
||||
сущности по-прежнему недоступны
|
||||
([#117](https://github.com/Matysh/houseplan-card/issues/117)).
|
||||
- Glow теперь остаётся непрозрачным при сбое построения объединённой геометрии
|
||||
стен: источник света внутри окна или внешнего проёма в независимой стене
|
||||
подавляется, а корректный внутренний проход по-прежнему пропускает свет
|
||||
([#187](https://github.com/Matysh/houseplan-card/issues/187)).
|
||||
- Визуальная направляющая привязки в редакторе Плана теперь сохраняет реальный
|
||||
разрыв в проёме независимого отрезка «Стены»: ховер и клик внутри двери,
|
||||
окна, ворот или прохода больше не привязываются к отсутствующей кладке
|
||||
([#189](https://github.com/Matysh/houseplan-card/issues/189)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.65.0-beta.2 — 2026-08-18
|
||||
|
||||
- У составных устройств Home Assistant с несколькими сущностями света/реле
|
||||
теперь можно выбрать точную сущность для действия **«Переключить состояние»**.
|
||||
Диалог сразу показывает новую цель, сохраняет пропавший выбор с
|
||||
предупреждением и может включить выбранную собственную сущность в явно
|
||||
настроенную группу без изменения старых планов
|
||||
([#178](https://github.com/Matysh/houseplan-card/issues/178)).
|
||||
- Виртуальный источник с ролью **«Всегда»**, связанный с реальным реле, теперь
|
||||
следует состоянию реле вместо отдельного ручного состояния. Нажатие на любой
|
||||
из двух маркеров управляет реальным устройством Home Assistant, а Glow,
|
||||
заливка «Свет», статистика комнаты и оба маркера обновляются вместе; после
|
||||
снятия последней связи возвращается сохранённое ручное состояние
|
||||
([#174](https://github.com/Matysh/houseplan-card/issues/174)).
|
||||
- В редакторе Плана теперь один инструмент **«Стены»** вместо отдельных
|
||||
«Контура комнаты» и «Перегородки». Незамкнутая цепочка становится обычными
|
||||
стенами при смене инструмента, редактора или этажа; замыкание через конечные,
|
||||
T- и X-точки предлагает все новые комнаты по возрастанию площади, а
|
||||
Create/«Оставить стенами»/Cancel применяются атомарно. Ctrl/Cmd+клик не
|
||||
добавляет лишнюю точку, пока для замыкания ещё недостаточно рёбер
|
||||
([#173](https://github.com/Matysh/houseplan-card/issues/173)).
|
||||
- Коллинеарные внешние стены теперь меняют толщину точно в сохранённой точке
|
||||
перехода. После разделения комнаты стена 10 см сохраняет полную толщину до
|
||||
перегородки и не продолжается по соседнему фасаду с нулевой толщиной; План,
|
||||
View, статическая и скрытая изометрическая геометрия, а также свет используют
|
||||
один исправленный контур
|
||||
([#150](https://github.com/Matysh/houseplan-card/issues/150)).
|
||||
- Нулевой разделитель после «Разделить» больше не получает треугольный клин от
|
||||
примыкающей толстой стены, если разрез нарисован под небольшим углом.
|
||||
Разделитель остаётся визуально открытым по всей длине, а настоящая стена
|
||||
заканчивается локальным торцом одинаково в Плане, View, Static, скрытой
|
||||
изометрии и световой геометрии
|
||||
([#172](https://github.com/Matysh/houseplan-card/issues/172)).
|
||||
- Устройства и отдельные сущности Home Assistant, вручную назначенные комнате
|
||||
без HA-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону
|
||||
из реестра. Уже сохранённые назначения восстанавливаются автоматически, а
|
||||
смена комнаты внутри одного пространства не двигает маркер
|
||||
([#170](https://github.com/Matysh/houseplan-card/issues/170)).
|
||||
- В редакторе плана появился **«Открытый проём»** — архитектурный проём
|
||||
шириной 90 см по умолчанию, без створки, дуги и привязки Home Assistant. Он
|
||||
вырезает кладку и продолжает пол в полном и Static-видах, пропускает свет
|
||||
между комнатами и остаётся полноразмерным разрывом без панелей в скрытой
|
||||
изометрии ([#157](https://github.com/Matysh/houseplan-card/issues/157)).
|
||||
|
||||
## v1.65.0-beta.1 — 2026-08-17
|
||||
|
||||
- Экспорт текущего пространства теперь может создать JSON-шаблон **«Только
|
||||
планировка»** с комнатами, стенами, проёмами, декором, подложкой, позициями и
|
||||
масштабом подписей комнат, но без устройств и структурных привязок Home
|
||||
Assistant.
|
||||
Предпросмотр помечает такой файл до добавления новым пространством
|
||||
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
|
||||
- Составная техника, например стиральная машина, теперь использует явный
|
||||
Status/Run state/Job state Home Assistant и получает жёлтую подложку во время
|
||||
активного цикла. Одного Power=`on` по-прежнему недостаточно, Power=`off`
|
||||
подавляет устаревший активный статус, а обычные одиночные реле сохраняют
|
||||
прежнее поведение ([#164](https://github.com/Matysh/houseplan-card/issues/164)).
|
||||
- Оконные солнечные лучи теперь правильно складывают азимут Home Assistant с
|
||||
буквальным направлением стрелки N и освещают физически верную сторону плана
|
||||
при повёрнутом севере. Если раньше компас был выставлен зеркально для обхода
|
||||
ошибки, после обновления верните стрелку к реальному северу
|
||||
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.64.0-beta.3 — 2026-08-14
|
||||
|
||||
@@ -40,6 +145,10 @@
|
||||
- Диалоги свойств проёма и физического объекта сохраняют кнопки футера в одной
|
||||
строке на настольном экране и адаптивный перенос на узком
|
||||
([#140](https://github.com/Matysh/houseplan-card/issues/140)).
|
||||
- Большие планы больше не пересчитывают неиспользуемое объединение физических
|
||||
стен при каждом переключении этажа, а цель привязки в Плане до первого клика
|
||||
обновляется без полной перерисовки карточки
|
||||
([#156](https://github.com/Matysh/houseplan-card/issues/156)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## v1.64.0-beta.2 — 2026-08-14
|
||||
|
||||
@@ -48,6 +48,44 @@ Unknown future fields remain outside this report and continue to follow the
|
||||
backend's forward-compatibility policy. Absence from the report is therefore
|
||||
not permission to delete a field.
|
||||
|
||||
## Open-passage opening type (#157)
|
||||
|
||||
`space.openings[].type` additionally accepts the literal `passage`. Its
|
||||
canonical record contains only `id`, `type`, `x`, `y`, `angle`, `length` and
|
||||
unknown extension siblings. The door-only keys `contact`, `lock`, `invert`,
|
||||
`flip_h` and `flip_v` are inapplicable; their presence is invalid even when the
|
||||
value is `null` or `false`.
|
||||
|
||||
New/changed records and every full/space import are validated fail-closed with
|
||||
`invalid_passage_fields`. An already stored broken passage may survive an
|
||||
unrelated write unchanged so legacy data cannot lock the whole plan; removing
|
||||
bad keys is allowed, while adding or changing one is rejected. An explicit UI
|
||||
save of a passage deletes all five known keys and preserves unknown siblings.
|
||||
Stale binding values remain inert at runtime and create no entity subscription.
|
||||
|
||||
Older v1.64.x frontends do not know the literal. Downgrade is read-only
|
||||
best-effort: do not edit an open passage with an old frontend, because its
|
||||
fallback may show or rewrite it as a door. Before a permanent rollback, convert
|
||||
saved passages deliberately in a current version; automatic conversion is not
|
||||
performed because it would invent a leaf and binding semantics.
|
||||
|
||||
## Independent-wall opening host (#132)
|
||||
|
||||
`space.openings[].host` is an optional discriminated object
|
||||
`{kind:'partition', id:string, t:number}`. Its absence preserves the historical
|
||||
room-wall association. When present, the referenced partition in the same
|
||||
space and normalized `t` are authoritative; the legacy `x/y/angle` siblings
|
||||
remain a materialized compatibility projection for older readers. Full export,
|
||||
plan-only export, merge and optimization preserve the host object.
|
||||
|
||||
Current writes validate the reference, fit and non-overlap. They also reject a
|
||||
stale writer that keeps an existing opening but silently drops its host; this
|
||||
prevents a downgrade from converting it into a nearby room-wall opening. Old
|
||||
frontends may display only the materialized projection, so opening or editing a
|
||||
hosted opening with an old bundle is unsupported. A missing/invalid host is not
|
||||
re-associated automatically: current renderers fail dark and Plan offers an
|
||||
explicit rebind.
|
||||
|
||||
## Four-phase background default and transfer (#146)
|
||||
|
||||
The schema remains `settings.bg_mode: static | daynight` globally and per
|
||||
@@ -69,6 +107,23 @@ that fallback:
|
||||
Import preview and apply therefore operate on the same normalized candidate.
|
||||
Explicit `static` and `daynight` survive same-instance and foreign transfer.
|
||||
|
||||
## Additive plan-only space transfer (#167)
|
||||
|
||||
`houseplan/export/create` accepts `plan_only: true` only for a one-space
|
||||
export. The resulting version-1 envelope adds `transfer.plan_only: true`,
|
||||
contains no markers and retains only canonical `rl_<room_id>` room-label
|
||||
layout. Normal full/space exports never write `plan_only: false`, so their
|
||||
existing document shape and lossless compatibility remain unchanged; an
|
||||
absent field still means an ordinary export.
|
||||
|
||||
Plan-only data is a fail-closed allowlist projection of supported geometry,
|
||||
presentation and content references. Known Area, temperature/humidity,
|
||||
opening and decor bindings are removed and recognized live-text references are
|
||||
frozen as `—`. Import rejects a true flag on a full export, non-boolean values,
|
||||
or any document whose projected config, layout, placement or content owner no
|
||||
longer satisfies that privacy contract. There is no persisted config/layout
|
||||
migration: the new field exists only in the portable envelope.
|
||||
|
||||
## Legacy device tap action
|
||||
|
||||
The historical marker token `tap_action: cover` remains accepted indefinitely.
|
||||
@@ -143,6 +198,18 @@ and temporarily fall back to the normal deterministic selection; merely
|
||||
opening or saving another field does not erase them. Older frontends ignore the
|
||||
unknown field and may erase it only if they reconstruct that marker.
|
||||
|
||||
`marker.toggle_entity` optionally stores the exact own `light.*`/`switch.*`
|
||||
operated by Toggle. Absence/null keeps the historical single-target resolver
|
||||
and external-only controls groups bit-for-bit. A present active choice is exact:
|
||||
temporary missing/unavailable/secure state never retargets it to a sibling. A
|
||||
choice no longer belonging to the marker remains stored, warns in the dialog
|
||||
and temporarily uses the historical fallback. New/changed values are
|
||||
domain-bounded by lossless delta validation; an untouched future literal can
|
||||
round-trip. Full and space transfer copy the entity id literally, while a
|
||||
duplicate marker virtualised during space import drops the HA-dependent field.
|
||||
Older frontends ignore it and may erase it if they reconstruct the marker.
|
||||
`light_entity`, `toggle_entity` and `tap_target` are independent.
|
||||
|
||||
`marker.controls[]` additionally accepts `marker:<marker_id>` links to forced
|
||||
plan sources. Runtime and old frontends continue to filter those strings out of
|
||||
HA service calls. New writes validate target existence, forced-source role,
|
||||
|
||||
@@ -87,7 +87,9 @@
|
||||
| `marker:<id>` stateful | Состояние ведущей сущности цели | Ведущая сущность цели, с дедупликацией | Позиция, комната, цвет и радиус принадлежат marker-цели |
|
||||
| `marker:<id>` passive, один controller | Его реальные targets, иначе собственная ведущая entity | Сам `marker:*` никогда не отправляется в HA | Пассивная лампа следует controller и светит в своей позиции |
|
||||
| Passive, несколько controllers | OR всех активных driver entities | По каждому действию — только его реальные targets | Один источник и один голос комнаты, без дублей |
|
||||
| Passive без сохранённых links | Всегда `on` | Нет собственного вызова | Постоянный Glow и `1 из 1` |
|
||||
| Exact `virtual` + «Всегда» + Toggle, есть links | OR всех активных driver entities; ручной bit временно не участвует | Клик controller — его группа; клик лампы — deduplicated union всех её drivers | HA state едино управляет Glow, заливкой, статистикой и обоими marker |
|
||||
| Exact `virtual` + «Всегда» + Toggle, links отсутствуют | Operational Store #107; отсутствие записи = `on` | Клик лампы меняет только operational state, без HA service | Ручное состояние общее для full/static и сохраняется после restart |
|
||||
| Passive без сохранённых links, не exact-режим #107 | Всегда `on` | Нет собственного вызова | Постоянный Glow и `1 из 1` |
|
||||
| Links есть, но все drivers скрыты/disabled/удалены | `off` / dormant | Нет вызова по битой цели | Ссылка сохраняется, пятна нет |
|
||||
| Прямая entity + `marker:` на тот же stateful source | Одно effective состояние | Один service target | Один источник и один голос |
|
||||
| Target переведён из «Всегда» в «Авто без источника»/«Никогда» | Dormant | Нет marker-service | Link сохраняется и оживает при возврате «Всегда» |
|
||||
|
||||
@@ -153,8 +153,10 @@ does not enter the structural fallback latch.
|
||||
|
||||
`src/iso-openings.ts` mirrors the existing opening-symbol transform algebra:
|
||||
door has one jamb-hinged leaf, gate has two leaves with the established
|
||||
0–10° exterior-face turn, and window has two light neutral casements. Heights
|
||||
are fixed presentation ratios of `ISO_WALL_HEIGHT`; there is no schema field.
|
||||
0–10° exterior-face turn, and window has two light neutral casements. A saved
|
||||
`passage` keeps the same full-height masonry cut but has zero leaves/panels and
|
||||
therefore no panel or leaf shadow. Heights are fixed presentation ratios of
|
||||
`ISO_WALL_HEIGHT`; there is no schema field.
|
||||
Panels/shadows are pointer- and ARIA-inert. Existing lock badges/cards and HA
|
||||
actions remain the only interactive opening surface.
|
||||
|
||||
|
||||
@@ -34,24 +34,35 @@ plan geometry and shares it between every lamp in the space.
|
||||
|
||||
**Transparent**
|
||||
|
||||
- doorways and gates — cut through the masonry, so an opening is a real
|
||||
- doorways, gates and saved `passage` openings — cut through the masonry, so an opening is a real
|
||||
gap between two jamb faces and a thick wall's returns narrow the beam;
|
||||
- virtual (open) boundaries, which are not walls to begin with.
|
||||
|
||||
**Deliberately opaque, although the plan draws an opening there**
|
||||
|
||||
- windows: an indoor lamp must not wash the street;
|
||||
- a door with no floor behind it. An opening is transparent only where BOTH
|
||||
- a door, gate or `passage` with no floor behind it. An opening is transparent only where BOTH
|
||||
sides are floor; otherwise a front door glows halfway — up to the centreline,
|
||||
where the room polygon ends — and the plan shows a lit doorway to nowhere.
|
||||
|
||||
The classifier is an explicit `door | gate | passage` allowlist. Any unknown
|
||||
future opening type remains opaque until its physical semantics are reviewed;
|
||||
it never inherits transparency merely by not being a window.
|
||||
|
||||
So the light's masonry is cut by passages only and differs on purpose from the
|
||||
drawn one.
|
||||
|
||||
The rule is identical for a passage hosted by a finished independent wall.
|
||||
Only its explicit partition body (plus an exactly collinear covering room wall)
|
||||
is cut. Windows remain opaque to indoor Glow, one-sided door/gate/passage cuts
|
||||
remain opaque, and an invalid host fails dark. A window hosted by an independent
|
||||
wall never becomes a sunlight source; sunlight still belongs to exterior room
|
||||
windows.
|
||||
|
||||
Source placement follows the same geometry, fail-dark. If the source centre is
|
||||
inside an opaque wall body, a window tunnel, or an exterior door/gate opening,
|
||||
the source produces no Glow at all. It does not light the indoor half of the
|
||||
opening. An interior door/gate passage remains a real hole and is therefore a
|
||||
opening. An interior door/gate/saved-passage opening remains a real hole and is therefore a
|
||||
valid source position. This is an intentional placement rule, not a temporary
|
||||
availability state: move the source marker onto the clean room floor to make it
|
||||
emit Glow again ([#92](https://github.com/Matysh/houseplan-card/issues/92)).
|
||||
@@ -142,17 +153,23 @@ source even when the marker has no controllable HA entity. That last case is a
|
||||
- its position, room, colour, brightness and radius belong to the target lamp,
|
||||
not to the switch which drives it.
|
||||
|
||||
There is one explicit manual-authority exception. An active marker with a
|
||||
`virtual` binding, `is_light: true` (Always) and `tap_action: toggle` reads its
|
||||
on/off value from the integration's revisioned operational store. Absence is
|
||||
`on`. While the triple remains active, that value overrides incoming controller
|
||||
OR for the marker source and therefore reaches every consumer of
|
||||
`resolvedLightSources()` — Glow, room fill/counts, device presentation, preview
|
||||
and both card types. Saved controls remain lossless but a tap on this exact
|
||||
marker performs the operational toggle, never an HA service call. Leaving the
|
||||
triple restores the normal controller rules and clears any stored off bit;
|
||||
hiding alone does neither. The operational revision is part of the resolver
|
||||
cache key, so an event changes the projection without an HA state tick.
|
||||
There is one explicit manual mode. An active marker with a `virtual` binding,
|
||||
`is_light: true` (Always) and `tap_action: toggle` reads its on/off value from
|
||||
the integration's revisioned operational store only while it has no valid
|
||||
incoming controller link. Absence is `on`; a tap performs the operational
|
||||
toggle, never an HA service call. Saved outgoing controls remain lossless and
|
||||
do not override that unlinked manual state.
|
||||
|
||||
With one or more incoming links, the same exact marker enters linked mode.
|
||||
Incoming controller drivers become the sole state authority for every consumer
|
||||
of `resolvedLightSources()` — Glow, room fill/counts, device presentation,
|
||||
preview and both card types. Tapping the source sends one ordinary HA group
|
||||
operation to the deduplicated union of all incoming drivers; tapping a
|
||||
controller still operates only that controller's effective driver group. No
|
||||
operational toggle or optimistic visual flip occurs. Adding a link preserves
|
||||
the stored manual bit, and removing the final link exposes that exact value
|
||||
again. The operational revision remains part of the resolver cache key, but it
|
||||
cannot change a linked source without a driver-state change.
|
||||
|
||||
The controller picker can therefore link a smart relay to a virtual marker for
|
||||
a dumb physical lamp. Multiple controllers use OR. A direct entity reference
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<!-- release: v1.64.0-beta.3 -->
|
||||
<!-- release: v1.65.0-beta.3 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- Устранены задержки на больших планах при переключении этажей и наведении на узлы привязки до первого клика без изменения геометрии и поведения привязки.
|
||||
- Двери, окна, ворота и открытые проёмы теперь можно размещать в независимых отрезках «Стен»; структурное замыкание комнат и физические разрывы привязки остаются согласованными.
|
||||
- Выбор цвета и прозрачности открывается одной палитрой House Plan, а подтверждение «Переключить состояние» показывает текущее и ожидаемое состояние.
|
||||
- YAML-сущности проёмов без `unique_id` теперь корректно работают в Просмотре, карточке проёма и действиях замка.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Large-plan delays during floor switches and snap-target hover before the first click are fixed without changing geometry or snap behaviour.
|
||||
- Doors, windows, gates and open passages can now be placed in independent Walls segments, while structural room closure and physical snap gaps stay consistent.
|
||||
- Color and opacity now open in one House Plan picker, while **Toggle state** confirmation shows the current and expected state.
|
||||
- Opening YAML entities without a `unique_id` now work correctly in View, the opening card and lock actions.
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.64.0-beta.3/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.64.0-beta.3/docs/CHANGELOG.md)
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.65.0-beta.3/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.65.0-beta.3/docs/CHANGELOG.md)
|
||||
|
||||