mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-08 07:29:13 +00:00
Compare commits
193
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f623903e40 | ||
|
|
469789dc42 | ||
|
|
4df7d73b3b | ||
|
|
0601e822ae | ||
|
|
f556c1e2b6 | ||
|
|
d45cc2660e | ||
|
|
7597554c58 | ||
|
|
cf4a28f8d3 | ||
|
|
1a6bd39ac0 | ||
|
|
92c3daccc7 | ||
|
|
13cc59b923 | ||
|
|
8972e44317 | ||
|
|
310dd03311 | ||
|
|
562780400c | ||
|
|
d0d9548cb8 | ||
|
|
398c7222c9 | ||
|
|
8a71cbc85e | ||
|
|
02b1804525 | ||
|
|
27cf8b5ea9 | ||
|
|
64b7f2bfec | ||
|
|
4e525bd99a | ||
|
|
859c7a224c | ||
|
|
883376a043 | ||
|
|
a02939cbb8 | ||
|
|
dd753f79d8 | ||
|
|
9738fc6ee3 | ||
|
|
c267e9079a | ||
|
|
334e0c2378 | ||
|
|
e10bffb4de | ||
|
|
02f12245d5 | ||
|
|
6c00fe6978 | ||
|
|
0dcf4d98a9 | ||
|
|
ef95ffd033 | ||
|
|
48a37b8985 | ||
|
|
42f74dac29 | ||
|
|
563b921ccb | ||
|
|
a16510b6be | ||
|
|
9ca3b649e2 | ||
|
|
06f93d0850 | ||
|
|
f45003fdb5 | ||
|
|
748b2f67fa | ||
|
|
b13cc29134 | ||
|
|
be7a5287e0 | ||
|
|
3c8f9af604 | ||
|
|
897aa1dcc3 | ||
|
|
52f2ad7463 | ||
|
|
a5e7d79c69 | ||
|
|
7ea49c4c26 | ||
|
|
a80aefa028 | ||
|
|
1a0024ea2b | ||
|
|
2e0f1a17a4 | ||
|
|
98f3ab0c00 | ||
|
|
98108151c1 | ||
|
|
b8a39efaa9 | ||
|
|
dd5e1b809b | ||
|
|
c46ccc506e | ||
|
|
418b10e59b | ||
|
|
107491d7ad | ||
|
|
9c86e62093 | ||
|
|
8f11c9e90a | ||
|
|
4789c3f636 | ||
|
|
5e4cbf5bbf | ||
|
|
3149a3d7f4 | ||
|
|
2f03167c30 | ||
|
|
e785b88a23 | ||
|
|
2003959700 | ||
|
|
19153ce4b1 | ||
|
|
a55085831f | ||
|
|
60e03bd342 | ||
|
|
11db092982 | ||
|
|
a92cf7b645 | ||
|
|
6636328402 | ||
|
|
7661133f12 | ||
|
|
bafb6bce1c | ||
|
|
f01e627292 | ||
|
|
fd95054999 | ||
|
|
083a491fb8 | ||
|
|
3df9933efe | ||
|
|
588ea8b27b | ||
|
|
20b69d872c | ||
|
|
a07f7493e2 | ||
|
|
9628640a50 | ||
|
|
42960896d4 | ||
|
|
a106b71762 | ||
|
|
df69718cec | ||
|
|
150b5a166b | ||
|
|
aca88acf4d | ||
|
|
34a428711e | ||
|
|
11ed68e4ba | ||
|
|
d7b6bdfbf2 | ||
|
|
5a1b0c20d4 | ||
|
|
ff4c69ac28 | ||
|
|
bf771c320b | ||
|
|
805b435a04 | ||
|
|
2c38e546e5 | ||
|
|
7fb609e48a | ||
|
|
53d3c79065 | ||
|
|
c0fe5dd22c | ||
|
|
6a9fdee0be | ||
|
|
59527ee04a | ||
|
|
6b8a51a257 | ||
|
|
114c171ac3 | ||
|
|
15c12b752c | ||
|
|
cb7faab3e2 | ||
|
|
283200c789 | ||
|
|
9906f68173 | ||
|
|
87ff8bd80d | ||
|
|
f6f0b96f6e | ||
|
|
5fddc75514 | ||
|
|
b2e98af692 | ||
|
|
4dc0aed4a8 | ||
|
|
8f72115a49 | ||
|
|
f19087bfc1 | ||
|
|
bcfb6e1123 | ||
|
|
9c5b6aca39 | ||
|
|
8b8190b6d5 | ||
|
|
6e42b91bc3 | ||
|
|
8e4343ee2e | ||
|
|
5ecee099c3 | ||
|
|
e4b4b0e6cd | ||
|
|
de6cc44b89 | ||
|
|
f9c56c28e4 | ||
|
|
3a476697f1 | ||
|
|
34349dfdbb | ||
|
|
b44bb82a3a | ||
|
|
0bf68d6dd2 | ||
|
|
8c9c0089a4 | ||
|
|
cb97274ea7 | ||
|
|
32334a173a | ||
|
|
42b8004af3 | ||
|
|
0615c38e5e | ||
|
|
1ef639461e | ||
|
|
2561d80a1a | ||
|
|
d6703ac0c2 | ||
|
|
2cb58dbab6 | ||
|
|
083d4d6e33 | ||
|
|
7fbc97a296 | ||
|
|
e10e482a63 | ||
|
|
ec9462a457 | ||
|
|
ef47d4c5a1 | ||
|
|
bda25d26c2 | ||
|
|
e49083d6ec | ||
|
|
8cff278c78 | ||
|
|
a1a404e419 | ||
|
|
7dc5a69ec2 | ||
|
|
2e1661b23d | ||
|
|
8c726d753e | ||
|
|
f398b50037 | ||
|
|
16b4197409 | ||
|
|
16f5a30f62 | ||
|
|
3bfd3f4161 | ||
|
|
1d2d23f104 | ||
|
|
b83dd2021f | ||
|
|
7812a26f18 | ||
|
|
8a6f523b1a | ||
|
|
ab9dc1fb46 | ||
|
|
705889cc37 | ||
|
|
e12c04568a | ||
|
|
d50efcb56e | ||
|
|
25f463a933 | ||
|
|
63b6b48d03 | ||
|
|
323fa70f96 | ||
|
|
3e98ac4dde | ||
|
|
3ee8663bfa | ||
|
|
7015a2c454 | ||
|
|
aa62145621 | ||
|
|
602305417b | ||
|
|
97e6504e9c | ||
|
|
88bdd513ec | ||
|
|
b94112b8c4 | ||
|
|
3c091e7ef8 | ||
|
|
9951e6ba10 | ||
|
|
d4d48bb040 | ||
|
|
d787c02687 | ||
|
|
b9145d3e85 | ||
|
|
6651d854d0 | ||
|
|
e8e5718ecd | ||
|
|
b3f5aefa20 | ||
|
|
de2029b7f9 | ||
|
|
370df0708c | ||
|
|
bcc68f0da2 | ||
|
|
574ae94840 | ||
|
|
90e8ea4685 | ||
|
|
ee351dcb84 | ||
|
|
2b841d94e4 | ||
|
|
c175a2ab1a | ||
|
|
020739ebca | ||
|
|
ffdd69e277 | ||
|
|
319ee70010 | ||
|
|
5c893c7ff3 | ||
|
|
077e9c1b1b | ||
|
|
0471482c2e | ||
|
|
db887cd7f0 |
@@ -214,7 +214,10 @@ jobs:
|
||||
# перезапуск не лечит: причина и ответ git без токена — в журнале и в
|
||||
# сводке шага.
|
||||
push_err="$RUNNER_TEMP/beta-derived-push.stderr"
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
|
||||
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
|
||||
server="${GITHUB_SERVER_URL:-https://github.com}"
|
||||
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
|
||||
if ! git push -q "$push_url" HEAD:dev 2> "$push_err"; then
|
||||
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
|
||||
--stage=beta-derived --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
if [ "$kind" = "stale" ]; then
|
||||
|
||||
@@ -584,8 +584,11 @@ jobs:
|
||||
# прогона со своей причиной. #730: причина и ответ git без токена —
|
||||
# ещё и в сводке шага (--summary; устаревший lease её не пишет).
|
||||
push_err="$RUNNER_TEMP/rebase-push.stderr"
|
||||
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
|
||||
server="${GITHUB_SERVER_URL:-https://github.com}"
|
||||
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
|
||||
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
|
||||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"$push_url" \
|
||||
"HEAD:refs/heads/$BRANCH" 2> "$push_err"; then
|
||||
refusal="$RUNNER_TEMP/push-refusal.md"
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
|
||||
@@ -1693,7 +1696,10 @@ jobs:
|
||||
# сразу, причина и ответ git без токена — в журнале и в сводке шага;
|
||||
# метка не меняется.
|
||||
push_err="$RUNNER_TEMP/review-doc-push.stderr"
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
|
||||
server="${GITHUB_SERVER_URL:-https://github.com}"
|
||||
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
|
||||
if ! git push -q "$push_url" \
|
||||
"HEAD:$target" 2> "$push_err"; then
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
|
||||
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
@@ -1713,7 +1719,7 @@ jobs:
|
||||
# После ребейза набор путей другой — проверяется заново. Форс здесь
|
||||
# запрещён и не появляется: ветка двигается только вперёд.
|
||||
git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs"
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
if ! git push -q "$push_url" \
|
||||
"HEAD:$target" 2> "$push_err"; then
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
|
||||
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
@@ -1925,9 +1931,9 @@ jobs:
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
# #696: show/ship сливаются по лёгкому Validate на кандидате.
|
||||
MUTANTS: ${{ needs.prepare.outputs.mutants }}
|
||||
# #749: из снимка dev — и суждение о Validate кандидата (ci-proof.mjs
|
||||
# с validate.yml снимка), и reviews-index.mjs, который скрипт зовёт
|
||||
# по пути от себя.
|
||||
# #749/#811: policy, Git writes and CI proof stay in the dev snapshot.
|
||||
# Only the accepted candidate's index generator runs from an exported
|
||||
# credential-free tree; the trusted controller commits its output.
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
|
||||
@@ -341,6 +341,9 @@ jobs:
|
||||
fi
|
||||
msg="$RUNNER_TEMP/ship-review-commit.txt"
|
||||
push_err="$RUNNER_TEMP/ship-review-push.stderr"
|
||||
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
|
||||
server="${GITHUB_SERVER_URL:-https://github.com}"
|
||||
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
|
||||
for attempt in 1 2 3; do
|
||||
git fetch -q origin dev
|
||||
git reset -q --hard origin/dev
|
||||
@@ -375,7 +378,7 @@ jobs:
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F "$msg"
|
||||
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
|
||||
if git push -q "$push_url" HEAD:dev 2> "$push_err"; then
|
||||
if [ "$MODE" = "nightly" ]; then
|
||||
echo "### Ночное пакетное ревью ship" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
|
||||
@@ -309,6 +309,9 @@ jobs:
|
||||
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
|
||||
msg="$RUNNER_TEMP/release-review-commit.txt"
|
||||
push_err="$RUNNER_TEMP/release-review-push.stderr"
|
||||
# #766: хост push — сервер раннера (GITHUB_SERVER_URL), не зашитый github.com.
|
||||
server="${GITHUB_SERVER_URL:-https://github.com}"
|
||||
push_url="${server%%://*}://x-access-token:$TOKEN@${server#*://}/${{ github.repository }}"
|
||||
for attempt in 1 2 3; do
|
||||
git fetch -q origin dev
|
||||
git reset -q --hard origin/dev
|
||||
@@ -337,7 +340,7 @@ jobs:
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F "$msg"
|
||||
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
|
||||
if git push -q "$push_url" HEAD:dev 2> "$push_err"; then
|
||||
echo "### Независимое ревью $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Итог: $counts — \`$DOC\` в dev. Выпуск не блокируется." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: $counts"
|
||||
|
||||
@@ -179,8 +179,11 @@ instead of ending the session.** The label starts the pipeline by itself. Poll
|
||||
with `node scripts/wait-verdict.mjs --issue NN [--sha <tip>]` (#496): it watches
|
||||
the label and the pipeline's comments every 90 s, at most 110 times, prints only
|
||||
on a change and exits 0 on a new label, 3 on an event that needs a hand, 4 on
|
||||
timeout. Watch the **label**, not the comment. Do not wait while `blocked` is
|
||||
set.
|
||||
timeout. A failed merge after a green verdict (outcomes of
|
||||
`merge-candidate.mjs`, #768) exits 3 while the label still reads `S7`, and its
|
||||
reason is printed when the label has already moved; «diff changed on rebase» is
|
||||
printed and the wait goes on — the new review round starts by itself. Watch the
|
||||
**label**, not the comment. Do not wait while `blocked` is set.
|
||||
|
||||
| Now reads | What happened | What you do |
|
||||
|---|---|---|
|
||||
|
||||
+40
-6
@@ -277,6 +277,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
`docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check`
|
||||
показывает число browser guards против ориентира 200 и предупреждает — не
|
||||
краснеет (#699) — при росте выше него и о любом id без browser-обоснования;
|
||||
с #811 неверные Count/Total, повтор ID и нарушенная структура категорий
|
||||
документа — ошибки дешёвой проверки; membership (пропущенный/устаревший ID)
|
||||
остаётся предупреждением CLI и строгой проверкой юнита.
|
||||
удалять чужой мутант ради числа не нужно; ревьюер проверяет
|
||||
не только наличие строки, но и невозможность более дешёвого `node --test`.
|
||||
|
||||
@@ -398,14 +401,14 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Файл генерируется `node scripts/reviews-index.mjs` и пересобирается **только
|
||||
коммитами, идущими в `dev`** (#657, решение 1б): слиянием кандидата —
|
||||
после ребейза, а если `dev` не двигался, то поверх материала перед
|
||||
fast-forward (`--commit-if-stale`, коммит класса C) — и публикацией документа ревью ТЗ
|
||||
fast-forward (коммит класса C) — и публикацией документа ревью ТЗ
|
||||
прямо в `dev`. В ветке задачи индекс не пересобирается — ни при приведении к
|
||||
dev, ни при публикации документа код-ревью: иначе две параллельные задачи
|
||||
конфликтуют на нём по построению. Сам `INDEX.md` руками не правится никогда. Конфликт ребейза, в котором **все** пути —
|
||||
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
|
||||
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
|
||||
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
|
||||
вместе с любым другим путём — прежний отказ с перечнем файлов. Ещё два общих
|
||||
вместе с любым ручным конфликтом — отказ с перечнем файлов. Ещё два общих
|
||||
файла не конфликтуют по смыслу (#698): записи ченджлогов в `## Unreleased`
|
||||
объединяет встроенный драйвер `merge=union` (`.gitattributes`), а на конфликте в
|
||||
`scripts/monolith-baseline.json` ребейз берёт сторону `dev` — числа
|
||||
@@ -414,7 +417,29 @@ Validate кандидата. Сегодня пять чисел исходник
|
||||
с полосой 2 000 Б; поэтому сторона `dev` в базе краснеет, если задача сама
|
||||
меняла эти числа, — это прежний возврат автору, только после Validate, а не до
|
||||
ревью. Полоса у всех шести чисел — #699. Ни ченджлоги, ни база не входят в
|
||||
patch-id кандидата слияния: вердикт к работе задачи остаётся в силе. Шаг Validate
|
||||
patch-id кандидата слияния: вердикт к работе задачи остаётся в силе.
|
||||
|
||||
В `docs/testing-notes/mutation-browser-guards.md` производны только цифры
|
||||
Count категорий и числитель Total (#811). Ребейз объединяет стороны после
|
||||
нейтрализации этих цифр и пересчитывает их по объединённым спискам ID; после
|
||||
чистого merge пересчёт тоже обязателен (иначе два одинаковых инкремента git
|
||||
может склеить в один). ID, причины, названия категорий, знаменатель лимита и
|
||||
прочая проза остаются ручными: смысловой конфликт или нарушенная структура
|
||||
отменяет ребейз и восстанавливает исходное дерево. В patch-id нейтрализованы
|
||||
только те же числовые ячейки, не весь Markdown.
|
||||
|
||||
Финальный индекс при слиянии строит генератор **из принимаемого дерева**
|
||||
после зелёного код-ревью и, при ребейзе, после равенства patch-id (#811).
|
||||
`candidate-reviews-index.mjs` из доверенного снимка `dev` экспортирует
|
||||
закоммиченные `scripts/` и `docs/reviews/` без `.git`, symlink и credentials,
|
||||
запускает только генерацию и `--check` с ограниченными правами Node, затем сам
|
||||
добавляет/коммитит единственный `INDEX.md`. Это защита от случайных побочных
|
||||
эффектов уже проверенного кода, не песочница для враждебного кода.
|
||||
Управление ребейзом, CI proof, Git-запись и push остаются в снимке `dev`.
|
||||
На промежуточной остановке ребейза используется доверенный генератор: ещё
|
||||
не принятый дифф не исполняется ради индекса.
|
||||
|
||||
Шаг Validate
|
||||
«индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md`
|
||||
расходится с каталогом (на issue-ветках не судится: их переписывает конвейер).
|
||||
Ручная правка каталога
|
||||
@@ -976,6 +1001,9 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
|
||||
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
|
||||
Профиль добавляет и правка того, что его меряет и судит без `src/**`: его
|
||||
бюджетов и общего харнесса large-house — раннера, фикстуры, контракта карточки,
|
||||
оценщика (#770).
|
||||
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
|
||||
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
|
||||
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
|
||||
@@ -1096,7 +1124,9 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
трогающих `demo/golden/baselines/**`, — `Release:` плюс ровно один источник:
|
||||
`Baseline-Reviewed: <URL GitHub run>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`. Локальный хеш обязан
|
||||
совпадать с `localAttestation.sha256` в принятом индексе.
|
||||
совпадать с `localAttestation.sha256` в принятом индексе. Трейлер черновика
|
||||
`Spec-Draft`, если он есть, — ровно один, вида `sha256:<64 строчных hex>`
|
||||
(#766, §11.8); обычный коммит его не несёт.
|
||||
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
|
||||
job `provenance` в `validate.yml`.
|
||||
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
|
||||
@@ -1567,11 +1597,13 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
конфликта. Шаг слияния поэтому:
|
||||
|
||||
- сверяет вершину ветки с материалом ревью (#312) — иначе `S6-in-progress`;
|
||||
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
|
||||
- если `dev` не двигался — финальный индекс из принимаемого дерева (§2.10),
|
||||
затем push с `--force-with-lease` на текущую вершину;
|
||||
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
|
||||
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
|
||||
к другому диффу не применим, §2.10; метка снимается и ставится заново, чтобы
|
||||
событие запустило новый заход, #706), публикация кандидата в ветку задачи и
|
||||
событие запустило новый заход, #706); при равном patch-id финальный индекс
|
||||
строится из принимаемого дерева, затем публикация кандидата в ветку задачи и
|
||||
ожидание зелёного лёгкого Validate **на этом SHA** — его запускает сам push в
|
||||
ветку, dispatch — только если прогона нет за три минуты (#696, #709), — и только
|
||||
затем push в `dev` с lease на ту вершину, поверх которой кандидат собран.
|
||||
@@ -1915,6 +1947,8 @@ Low в issue не пишутся. Цвет ночи — цвет Validate.
|
||||
`S5-ready`, трек на момент написания — `ask`, и трейлер равен «Тело issue»
|
||||
зелёного (High 0) `SPEC-REVIEW` этой эпохи из вершины диапазона или
|
||||
`origin/dev`. Отказ называет причину; при устаревшем ТЗ — оба хеша и документ.
|
||||
Повтор и неверный формат трейлера хук `commit-msg` отвергает раньше, при
|
||||
коммите, тем же разбором (#766, §10.1); эпоху, трек и SPEC-REVIEW он не судит.
|
||||
Комментарий «Черновик:» и учёт в WIP машиной не проверяются, как занятие `S6`.
|
||||
|
||||
Ревьюер ТЗ черновик не читает и доводом не считает: судится тело issue
|
||||
|
||||
@@ -1,19 +1,53 @@
|
||||
"""Config flow: a single entry with no parameters."""
|
||||
"""Single-entry config flow with an explicit previous-installation choice."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, DOMAIN
|
||||
from .previous_data import ArchiveError, archive_previous_data, inspect_previous_data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""One-step setup."""
|
||||
"""Restore or archive only after the final user confirmation."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
self._previous_checked = False
|
||||
self._start_fresh = False
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
if self._async_in_progress(include_uninitialized=True):
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
if not self._previous_checked:
|
||||
try:
|
||||
previous = await self.hass.async_add_executor_job(
|
||||
inspect_previous_data, self.hass.config.config_dir
|
||||
)
|
||||
except OSError:
|
||||
_LOGGER.exception("Cannot inspect previous House Plan data")
|
||||
return self.async_abort(reason="previous_data_error")
|
||||
self._previous_checked = True
|
||||
if previous.found:
|
||||
self._previous = previous
|
||||
return await self.async_step_previous_data()
|
||||
if user_input is not None:
|
||||
if self._start_fresh:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
archive_previous_data, self.hass.config.config_dir
|
||||
)
|
||||
except ArchiveError:
|
||||
return self.async_abort(reason="archive_failed")
|
||||
return self.async_create_entry(
|
||||
title="House Plan",
|
||||
data={},
|
||||
@@ -25,6 +59,35 @@ class HouseplanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema({vol.Optional(CONF_ADMIN_ONLY, default=True): bool}),
|
||||
)
|
||||
|
||||
async def async_step_previous_data(self, user_input=None):
|
||||
previous = self._previous
|
||||
translations = await async_get_translations(
|
||||
self.hass, self.hass.config.language, "config", {DOMAIN}
|
||||
)
|
||||
warning = ""
|
||||
if previous.spaces is None:
|
||||
warning = translations[f"component.{DOMAIN}.config.error.previous_data_unreadable"]
|
||||
return self.async_show_menu(
|
||||
step_id="previous_data",
|
||||
menu_options=["restore", "start_fresh"],
|
||||
description_placeholders={
|
||||
"spaces": str(previous.spaces) if previous.spaces is not None else "—",
|
||||
"files": str(previous.files),
|
||||
"modified": datetime.fromtimestamp(previous.modified, UTC).strftime(
|
||||
"%Y-%m-%d %H:%M:%S UTC"
|
||||
) if previous.modified is not None else "—",
|
||||
"warning": warning,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_restore(self, user_input=None):
|
||||
self._start_fresh = False
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_start_fresh(self, user_input=None):
|
||||
self._start_fresh = True
|
||||
return await self.async_step_user()
|
||||
|
||||
@staticmethod
|
||||
def async_get_options_flow(config_entry):
|
||||
return HouseplanOptionsFlow()
|
||||
|
||||
@@ -57,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.80.0-beta.3"
|
||||
VERSION = "1.80.0-beta.8"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
@@ -131,6 +131,7 @@ ERROR_CODES: frozenset[str] = frozenset({
|
||||
# served by the generic per-code fallback on the frontend.
|
||||
ERROR_CODE_FAMILIES: tuple[str, ...] = (
|
||||
"junction_limit_",
|
||||
"node_move_",
|
||||
"value_badge_",
|
||||
"value_source_",
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";import{aG as e,l as o,aJ as t,eE as s,A as a,eF as i,eG as l,eH as n,E as r,c}from"./houseplan-card-D6V9pMgM.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{u as e,cn as o,A as t,aI as s,aJ as a,aK as i,p as l,n}from"./open-spans-Co-2W4Ja.js";import{l as r,U as c}from"./houseplan-card-BRGPGuZP.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return r(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],t=this._lang,s=[{value:"",label:c(t,"editor.floor_none")}],a=this._floorToken;a&&s.push({value:a,label:c(t,"editor.floor_index",{index:String(this._config?.floor)})});const i="string"==typeof this._config?.floor?this._config.floor:"";i&&!e.some(e=>e.value===i)&&s.push({value:i,label:i}),s.push(...e);const l="string"==typeof this._config?.default_floor?this._config.default_floor:"",n=[...e];return l&&!e.some(e=>e.value===l)&&n.unshift({value:l,label:l}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:s}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:n}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:o(c(t,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return t;const e=s(this,a,r(this.hass,this._config.language));if("cold"===e)return i();if("warm"===e)return l;this._loadSpaces();const o=this._lang,h={title:c(o,"editor.title"),floor:c(o,"editor.floor"),default_floor:c(o,"editor.default_floor"),language:c(o,"editor.language"),icon_size:c(o,"editor.icon_size"),show_temperature:c(o,"editor.show_temperature"),live_states:c(o,"editor.live_states"),show_signal:c(o,"editor.show_signal"),kiosk:c(o,"editor.kiosk"),cycle:c(o,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),u=e=>n`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return n`
|
||||
${u(f.slice(0,3))}
|
||||
${_?n`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${c(o,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:t}
|
||||
${u(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={node_move_hint:"Drag the wall node. Original wall axes and horizontal/vertical walls snap. Esc cancels; Undo restores the whole move.",node_move_ambiguous:"These nodes are too close. Zoom in to choose one.",node_move_choose_axis:"Move along one of the original walls to choose the sliding axis.",node_move_invalid:"Cannot move this node: {reason}",node_move_opening_blocked:"A door or window would not fit or would cross the node. The move was cancelled.",node_move_update_required:"Update the House Plan integration to move wall nodes.",node_move_history:"Move wall node",node_move_unsupported_junction:"This complex through-junction cannot be moved. T and X junctions without extra branches are supported.","furn.title":"Furniture library","furn.symbol":"Symbol","furn.group_furniture":"Furniture","furn.group_appliance":"Appliances","furn.group_sanitary":"Plumbing","furn.group_other":"Other","furn.width":"Width","furn.depth":"Depth","furn.back_to_categories":"All categories","furn.pick_hint":"Choose a category, then a symbol.","furn.place_hint":"Click on the plan — the piece lands against the nearest wall. Shift places it free.","furn.cat_air_conditioner":"Air conditioners","furn.cat_armchair":"Armchairs","furn.cat_bathtub":"Bathtubs","furn.cat_bed":"Beds","furn.cat_bidet":"Bidets","furn.cat_boiler":"Water heaters","furn.cat_chair":"Chairs","furn.cat_coffee_table":"Coffee tables","furn.cat_computer":"Computer","furn.cat_cooktop":"Cooktops","furn.cat_dining_table":"Dining tables","furn.cat_dishwasher":"Dishwashers","furn.cat_dryer":"Dryers","furn.cat_exercise":"Exercise equipment","furn.cat_fireplace":"Fireplaces","furn.cat_fridge":"Refrigerators","furn.cat_hood":"Cooker hood","furn.cat_kitchen_cabinet":"Kitchen cabinets","furn.cat_kitchen_sink":"Kitchen sinks","furn.cat_nightstand":"Cabinets","furn.cat_oven":"Oven","furn.cat_plant":"Plants","furn.cat_rug":"Rugs","furn.cat_shelving":"Shelving","furn.cat_shower":"Showers","furn.cat_sink":"Sinks","furn.cat_sofa":"Sofas","furn.cat_stairs":"Stairs","furn.cat_toilet":"Toilets","furn.cat_tv":"Televisions","furn.cat_wardrobe":"Wardrobes","furn.cat_washer":"Washing machines","furn.cat_work_table":"Desks","furn.sym_exercise":"Exercise equipment","furn.sym_computer":"Computer","furn.sym_hood":"Cooker hood","furn.sym_oven":"Oven","furn.sym_sofa":"Two-seat sofa","furn.sym_sofa_three_seat":"Three-seat sofa","furn.sym_sofa_corner_right":"Right sectional sofa","furn.sym_armchair":"Soft armchair","furn.sym_armchair_office":"Office chair","furn.sym_coffee_table":"Rectangular coffee table","furn.sym_coffee_table_round":"Round coffee table","furn.sym_coffee_table_oval":"Oval coffee table","furn.sym_coffee_table_rounded":"Rounded coffee table","furn.sym_table_dining":"Rectangular dining table","furn.sym_table_round":"Round table","furn.sym_table_dining_oval":"Oval dining table","furn.sym_table_dining_rounded":"Rounded dining table","furn.sym_chair":"Chair","furn.sym_chair_bar":"Bar stool","furn.sym_desk":"Rectangular desk","furn.sym_desk_corner":"Corner desk","furn.sym_bed_double":"Double bed","furn.sym_bed_single":"Single bed","furn.sym_nightstand":"Bedside table","furn.sym_cabinet_tv":"TV cabinet","furn.sym_cabinet_shoe":"Shoe cabinet","furn.sym_cabinet_sink":"Sink cabinet","furn.sym_wardrobe":"Wardrobe","furn.sym_bookshelf":"Bookcase","furn.sym_wall_unit":"Wall unit","furn.sym_kitchen_floor":"Kitchen floor module","furn.sym_kitchen_floor_corner":"Kitchen floor corner module","furn.sym_kitchen_wall":"Kitchen wall module","furn.sym_kitchen_wall_corner":"Kitchen wall corner module","furn.sym_shelf_floor":"Floor shelving unit","furn.sym_shelf_wall":"Wall shelf","furn.sym_fridge":"Fridge","furn.sym_stove":"Four-burner cooktop","furn.sym_cooktop_two":"Two-burner cooktop","furn.sym_dishwasher":"Dishwasher","furn.sym_washer":"Washing machine","furn.sym_dryer":"Tumble dryer","furn.sym_tv":"TV on stand","furn.sym_tv_wall":"Wall-mounted TV","furn.sym_ac":"Air conditioner","furn.sym_water_heater":"Water heater","furn.sym_toilet":"Floor-standing toilet","furn.sym_toilet_built_in":"Built-in toilet","furn.sym_bathtub":"Rectangular bathtub","furn.sym_bathtub_corner":"Corner bathtub","furn.sym_shower":"Shower","furn.sym_sink":"Washbasin","furn.sym_kitchen_sink":"Single kitchen sink","furn.sym_kitchen_sink_double":"Double kitchen sink","furn.sym_bidet":"Floor-standing bidet","furn.sym_bidet_built_in":"Built-in bidet","furn.sym_stairs":"Stairs","furn.sym_fireplace":"Fireplace","furn.sym_plant":"Plant","furn.sym_rug":"Rug"};export{e};
|
||||
@@ -0,0 +1 @@
|
||||
function t(t){const a=t.visualViewport;return a&&Number.isFinite(a.width)&&Number.isFinite(a.height)?{left:Number(a.offsetLeft)||0,top:Number(a.offsetTop)||0,width:Math.max(0,Number(a.width)||0),height:Math.max(0,Number(a.height)||0)}:{left:0,top:0,width:t.innerWidth,height:t.innerHeight}}function a(t,a,h,e=7,i=8){const o=h.left+i,n=h.top+i,m=Math.max(o,h.left+h.width-i),r=Math.max(n,h.top+h.height-i),M=Math.max(0,m-o),f=Math.max(0,r-n),u=Math.min(Math.max(0,a.width),M),p=Math.min(Math.max(0,a.height),f);let x=t.left;x+u>m&&(x=t.right-u),x=Math.min(Math.max(o,x),Math.max(o,m-u));const d=t.bottom+e,s=t.top-e-p;let c="bottom",b=d;return d+p>r&&s>=n?(c="top",b=Math.min(Math.max(n,s),Math.max(n,r-p))):b=Math.min(Math.max(n,d),Math.max(n,r-p)),{left:Math.round(x),top:Math.round(b),side:c,maxWidth:Math.round(M),maxHeight:Math.round(f)}}function h(t){const a=t.HTMLElement?.prototype;return"function"==typeof a?.showPopover&&"function"==typeof a?.hidePopover}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{h as a,t as f,a as p};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2522
File diff suppressed because one or more lines are too long
-2568
File diff suppressed because one or more lines are too long
-44
File diff suppressed because one or more lines are too long
+44
File diff suppressed because one or more lines are too long
-27
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+28
-28
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";var e={"led.tool":"LED-Streifen","led.hint":"Zeichnen Sie den Streifen Punkt für Punkt entlang der Wandflächen oder über den Boden; er rastet am Raster und an Wandflächen ein. Danach wählen Sie das Gerät, das er darstellt.","led.start":"Klicken, um den Streifen zu beginnen","led.start_touch":"Tippen, um den Streifen zu beginnen","led.points":"Punkte: {n} · Umschalt — 45°-Schritte · Esc — fertig · Strg+Z — Punkt entfernen","led.points_touch":"Punkte: {n} · Werkzeug wechseln zum Beenden","led.finish":"Fertig","led.stopped":"Der Streifen stößt an eine Wand","led.unbound":"Streifen ist mit keinem Gerät verbunden","led.bind":"Gerät verbinden","led.change":"Gerät wechseln","led.later":"Später","led.unbind":"Trennen","led.delete":"Streifen löschen","led.settings":"Geräteeinstellungen","led.type_note":"LED-Streifen: Form im Geräte-Editor auf dem Plan bearbeiten","led.show_strip":"Als LED-Streifen zeigen","led.show_icon":"Als Symbol zeigen","led.cancel":"Abbrechen","led.taken":"Bereits mit einem anderen LED-Streifen verbunden","led.space":"Zuerst den LED-Streifen lösen: Er gehört zu seinem Bereich","led.limit_points":"Höchstens 50 Punkte pro Streifen","led.limit_strips":"Höchstens 50 Streifen pro Bereich","led.pick_title":"Gerät für den Streifen wählen","led.pick_new":"Neues Gerät…","led.pick_empty":"Auf diesem Plan gibt es noch keine freien Geräte.","led.saved":"LED-Streifen gespeichert","led.save_failed":"Streifen nicht gespeichert: {err}","led.history_draw":"LED-Streifen","led.history_shape":"Form des LED-Streifens","led.history_view":"Darstellung des Geräts","led.optimize_walls":"Streifen durch Wände: {n} ({spaces})"};const n="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";export{e as dictionary,n as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={"led.tool":"LED-Streifen","led.hint":"Zeichnen Sie den Streifen Punkt für Punkt entlang der Wandflächen oder über den Boden; er rastet am Raster und an Wandflächen ein. Danach wählen Sie das Gerät, das er darstellt.","led.start":"Klicken, um den Streifen zu beginnen","led.start_touch":"Tippen, um den Streifen zu beginnen","led.points":"Punkte: {n} · Umschalt — 45°-Schritte · Esc — fertig · Strg+Z — Punkt entfernen","led.points_touch":"Punkte: {n} · Werkzeug wechseln zum Beenden","led.finish":"Fertig","led.stopped":"Der Streifen stößt an eine Wand","led.unbound":"Streifen ist mit keinem Gerät verbunden","led.bind":"Gerät verbinden","led.change":"Gerät wechseln","led.later":"Später","led.unbind":"Trennen","led.delete":"Streifen löschen","led.settings":"Geräteeinstellungen","led.type_note":"LED-Streifen: Form im Geräte-Editor auf dem Plan bearbeiten","led.show_strip":"Als LED-Streifen zeigen","led.show_icon":"Als Symbol zeigen","led.cancel":"Abbrechen","led.taken":"Bereits mit einem anderen LED-Streifen verbunden","led.space":"Zuerst den LED-Streifen lösen: Er gehört zu seinem Bereich","led.limit_points":"Höchstens 50 Punkte pro Streifen","led.limit_strips":"Höchstens 50 Streifen pro Bereich","led.pick_title":"Gerät für den Streifen wählen","led.pick_new":"Neues Gerät…","led.pick_empty":"Auf diesem Plan gibt es noch keine freien Geräte.","led.saved":"LED-Streifen gespeichert","led.save_failed":"Streifen nicht gespeichert: {err}","led.history_draw":"LED-Streifen","led.history_shape":"Form des LED-Streifens","led.history_view":"Darstellung des Geräts","led.optimize_walls":"Streifen durch Wände: {n} ({spaces})"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";var e={"led.tool":"Ruban LED","led.hint":"Tracez le ruban point par point le long des faces des murs ou sur le sol ; il s’aimante à la grille et aux faces des murs. Ensuite, choisissez l’appareil qu’il représente.","led.start":"Cliquez pour commencer le ruban","led.start_touch":"Touchez pour commencer le ruban","led.points":"Points : {n} · Maj — pas de 45° · Échap — terminer · Ctrl+Z — retirer le point","led.points_touch":"Points : {n} · changez d’outil pour terminer","led.finish":"Terminer","led.stopped":"Le ruban bute contre un mur","led.unbound":"Ruban non lié à un appareil","led.bind":"Lier un appareil","led.change":"Changer d’appareil","led.later":"Plus tard","led.unbind":"Délier","led.delete":"Supprimer le ruban","led.settings":"Réglages de l’appareil","led.type_note":"Ruban LED : modifiez sa forme sur le plan dans l’éditeur d’appareils","led.show_strip":"Afficher en ruban LED","led.show_icon":"Afficher en icône","led.cancel":"Annuler","led.taken":"Déjà lié à un autre ruban LED","led.space":"Détachez d’abord le ruban LED : il appartient à son espace","led.limit_points":"50 points au maximum par ruban","led.limit_strips":"50 rubans au maximum par espace","led.pick_title":"Choisissez l’appareil du ruban","led.pick_new":"Nouvel appareil…","led.pick_empty":"Aucun appareil libre sur ce plan pour l’instant.","led.saved":"Ruban LED enregistré","led.save_failed":"Ruban non enregistré : {err}","led.history_draw":"Ruban LED","led.history_shape":"Forme du ruban LED","led.history_view":"Représentation de l’appareil","led.optimize_walls":"Rubans traversant des murs : {n} ({spaces})"};const a="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";export{e as dictionary,a as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={"led.tool":"Ruban LED","led.hint":"Tracez le ruban point par point le long des faces des murs ou sur le sol ; il s’aimante à la grille et aux faces des murs. Ensuite, choisissez l’appareil qu’il représente.","led.start":"Cliquez pour commencer le ruban","led.start_touch":"Touchez pour commencer le ruban","led.points":"Points : {n} · Maj — pas de 45° · Échap — terminer · Ctrl+Z — retirer le point","led.points_touch":"Points : {n} · changez d’outil pour terminer","led.finish":"Terminer","led.stopped":"Le ruban bute contre un mur","led.unbound":"Ruban non lié à un appareil","led.bind":"Lier un appareil","led.change":"Changer d’appareil","led.later":"Plus tard","led.unbind":"Délier","led.delete":"Supprimer le ruban","led.settings":"Réglages de l’appareil","led.type_note":"Ruban LED : modifiez sa forme sur le plan dans l’éditeur d’appareils","led.show_strip":"Afficher en ruban LED","led.show_icon":"Afficher en icône","led.cancel":"Annuler","led.taken":"Déjà lié à un autre ruban LED","led.space":"Détachez d’abord le ruban LED : il appartient à son espace","led.limit_points":"50 points au maximum par ruban","led.limit_strips":"50 rubans au maximum par espace","led.pick_title":"Choisissez l’appareil du ruban","led.pick_new":"Nouvel appareil…","led.pick_empty":"Aucun appareil libre sur ce plan pour l’instant.","led.saved":"Ruban LED enregistré","led.save_failed":"Ruban non enregistré : {err}","led.history_draw":"Ruban LED","led.history_shape":"Forme du ruban LED","led.history_view":"Représentation de l’appareil","led.optimize_walls":"Rubans traversant des murs : {n} ({spaces})"};const a="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,a as fingerprint};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const d="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";export{e as dictionary,d as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const l="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,l as fingerprint};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";import{eu as e,eC as t,c6 as s,eJ as o,eK as i,ad as n,ee as a,O as r,eL as l,az as d,ef as c,aL as p,eM as u,eN as h,eO as f,A as m,eP as v}from"./houseplan-card-D6V9pMgM.js";import{L as g,v as w,s as $,a as _,b as y,p as k,c as b,d as C,e as P}from"./led-strip-geometry-BqzguNpx.js";const M="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-5x8viCry.js"):import(new URL(`./led-strip-field-5x8viCry.js?${Date.now()}`,import.meta.url).href)).then(e=>{e.LED_FIELD_FINGERPRINT===o?L=e:F=""},()=>{F=e}).finally(()=>{U=null})),L||U?.then(()=>L&&t()),L}const D=e=>e.some(e=>e.glow&&"on"===e.state),R=e=>e.map(e=>[e[0],e[1]]),E="#383838";function A(o,i){if(!o)return null;const n=Array.isArray(o.masonryGeometry)?o.masonryGeometry:[],a=[...e(o.masonryGeometry),...o.opaqueBodies],r=[];for(const e of a)for(let t=0;t<e.length;t++){const s=e[t],o=e[(t+1)%e.length];s&&o&&r.push({a:[s[0],s[1]],b:[o[0],o[1]]})}const l=25,d=new Map;r.forEach((e,t)=>{const s=Math.floor(Math.min(e.a[0],e.b[0])/l),o=Math.floor(Math.max(e.a[0],e.b[0])/l),i=Math.floor(Math.min(e.a[1],e.b[1])/l),n=Math.floor(Math.max(e.a[1],e.b[1])/l);for(let e=s;e<=o;e++)for(let s=i;s<=n;s++){const o=`${e},${s}`,i=d.get(o);i?i.push(t):d.set(o,[t])}});const c=e=>{let t=1/0,s=1/0,o=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),s=Math.min(s,n[1]),o=Math.max(o,n[0]),i=Math.max(i,n[1]);return[t,s,o,i]},p=(e,t)=>e[0]>=t[0]&&e[0]<=t[2]&&e[1]>=t[1]&&e[1]<=t[3],u=n.filter(e=>e?.[0]?.length).map(e=>({box:c(e[0]),poly:e})),h=o.opaqueBodies.map(e=>({box:c(e),body:e}));return{faces:r,inside:e=>u.some(({box:s,poly:o})=>p(e,s)&&t([e[0],e[1]],[o]))||h.some(({box:t,body:o})=>p(e,t)&&s([e[0],e[1]],o)),epsilon:i,near:e=>{const t=new Set;for(let s=Math.floor(e[0]/l);s<=Math.floor(e[2]/l);s++)for(let o=Math.floor(e[1]/l);o<=Math.floor(e[3]/l);o++)for(const e of d.get(`${s},${o}`)||[])t.add(e);return[...t].map(e=>r[e])}}}function I(e){const t=e.candidate?.sourceEid||e.device.primary||"",s=t?e.hass?.states?.[t]?.state:void 0,o="unavailable"===s||"unknown"===s||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=o?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:o?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const T=new WeakMap;function j(e,t,s){const o=P*s;const i=T.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===o&&i.points.length===e.points.length&&i.points.every((t,s)=>t[0]===e.points[s][0]&&t[1]===e.points[s][1]))return i.value;const n=R(e.points),a=y(n,t,o/2),r={t:o,path:a,d:k(a)};return T.set(e,{faces:t,epsilon:t?.epsilon,points:n,value:r}),r}function G(e){if(!e.views.length)return d``;const t=e.handlers,s=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),o=e.iso;return d`<g class="led-strips" data-hp-led-strips="${s.length}"
|
||||
data-hp-iso=${o?"raised":m}>
|
||||
${o?d`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
|
||||
x="-100000" y="-100000" width="200000" height="200000">
|
||||
<feGaussianBlur stdDeviation="${o.shadow.sigma}"></feGaussianBlur></filter></defs>`:m}
|
||||
<style>
|
||||
.led-strip .led-focus { fill: none; stroke: transparent; }
|
||||
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
|
||||
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
|
||||
</style>
|
||||
${v(s,e=>e.strip.id,s=>{const{t:i,path:n,d:a}=j(s.strip,e.faces,e.d),r="unavailable"===s.state,l=r?"#9e9e9e":"on"===s.state&&s.appearance?s.appearance.c:"#FFFFFF",c=2*b(i*e.perUnit)/(e.perUnit||1),p=s.device,u=t=>function(e,t,s){const o=e.currentTarget,i=o?.getScreenCTM?.();if(!i||s.views.length<2)return t;const n=i.inverse(),a=[n.a*e.clientX+n.c*e.clientY+n.e,n.b*e.clientX+n.d*e.clientY+n.f],r=s.views.map(e=>{const{t:t,path:o}=j(e.strip,s.faces,s.d);return{id:e.strip.id,points:o.points.map(e=>[e[0]*s.perUnit,e[1]*s.perUnit]),closed:o.closed,thicknessPx:t*s.perUnit}}),l=C([a[0]*s.perUnit,a[1]*s.perUnit],r);return s.views.find(e=>e.strip.id===l)||t}(t,s,e).device,h=o?`translate(0 ${-o.lift})`:m;return d`<g class="led-strip state-${s.state}" data-led-strip="${s.strip.id}"
|
||||
data-marker="${p.id}" data-state="${s.state}" data-closed="${n.closed?"true":"false"}">
|
||||
${o?d`<path class="led-iso-shadow" d="${a}" fill="none" stroke="black"
|
||||
stroke-opacity="${o.shadow.opacity}" stroke-width="${i}" stroke-linecap="round"
|
||||
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
|
||||
transform="translate(${o.shadow.dx} ${o.shadow.dy-o.lift})"></path>
|
||||
${[1,.5].map(e=>d`<path class="led-iso-edge" d="${a}" fill="none" stroke="${o.edge}"
|
||||
stroke-width="${i}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
|
||||
transform="translate(0 ${-o.lift+o.depth*e})"></path>`)}`:m}
|
||||
<g transform=${h}>
|
||||
<path class="led-outline" d="${a}" fill="none" stroke="${E}" stroke-width="${i}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
|
||||
<path class="led-core" d="${a}" fill="none" stroke="${l}" stroke-width="${i/2}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
|
||||
<path class="led-focus" d="${a}" stroke-width="${1.6*i}" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true"></path>
|
||||
${t?d`<path class="led-hit" d="${a}" stroke-width="${c}"
|
||||
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
|
||||
role="button" tabindex="0" aria-label="${t.label(p)}"
|
||||
data-hp="device" data-id="${p.id}"
|
||||
@click=${e=>t.click(e,u(e))}
|
||||
@keydown=${e=>t.keydown(e,p)}
|
||||
@contextmenu=${e=>t.contextmenu(e,u(e))}
|
||||
@pointerdown=${e=>t.pointerdown(e,u(e))}
|
||||
@pointermove=${e=>t.pointermove(e,u(e))}
|
||||
@pointerup=${e=>t.pointerup(e,u(e))}
|
||||
@pointercancel=${e=>t.pointercancel(e,p)}
|
||||
@lostpointercapture=${e=>t.pointercancel(e,p)}
|
||||
@pointerover=${e=>t.pointerover(e,u(e))}
|
||||
@pointerleave=${()=>t.pointerleave()}
|
||||
@focus=${e=>t.focus(e,p)}
|
||||
@blur=${()=>t.blur(p)}></path>`:m}
|
||||
</g>
|
||||
</g>`})}
|
||||
</g>`}function H(e,t,s,o){return(t?e.find(e=>e.id===t):void 0)??(s?e.find(e=>o(s,e)):void 0)}function N(e){const t=new Map(e.devices.map(e=>[e.id,e])),s=g/e.cellCm*e.gridPitch,o=new Map(i({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:s,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),r=[];for(const i of e.space.led_strips||[]){if(!1===i?.active||!i.marker||!w(i.points))continue;const a={...i,points:i.points.map(e=>[e[0]*n,e[1]*n])},l=t.get(a.marker);if(!l||l.space!==e.space.id)continue;if(l.hidden&&!e.showHidden)continue;const d=H(e.space.rooms,l.marker?.room_id,$(R(a.points)),e.inRoom);r.push(I({strip:a,device:l,candidate:o.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!d&&e.glowFor(d),defaultRadius:s,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const l=_/e.cellCm*e.gridPitch;return{views:r,faces:A(e.scene,l),d:e.iconPct/100*a(e.space),scene:e.scene,polygons:e.polygons}}const q=new WeakMap;function B(e){for(const t of q.get(e)?.frame.views??[])T.delete(t.strip);q.delete(e),L?.releaseLedField(e)}function S(e){return{shapes:q.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,visibilityPaths:0,pathChars:0,recomputes:0}}}function O(e,t,s){const o=t.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),i=o.length?e._lightBarriers(t,o.map(({room:e,poly:t})=>({r:e,poly:t}))):null,n=[t,e._renderDevices,e._renderPlanHass,s,i,e._mode,e._showAll],a=q.get(e);if(a&&a.key.every((e,t)=>e===n[t]))return a.frame;for(const e of a?.frame.views??[])T.delete(e.strip);const d=e._config?.icon_size??2.5,c=N({space:t,devices:e._renderDevices,hass:e._renderPlanHass,virtualLights:e._virtualLights,defaultColor:e._fillColors.glow_light.c,paletteAlpha:e._fillColors.glow_light.a,cellCm:e._cellCm,gridPitch:e._gridPitch,iconPct:d>8?2.5:d,scene:i,polygons:o,glowFor:e=>l(s,e),inRoom:(t,s)=>e._pointInRoom(t,s),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&q.set(e,{key:n,frame:c}),c}function W(e,t,s,o){const i=O(e,t,s),n=e._stageEl?.clientWidth,a="iso"===e._renderProjection&&"view"===e._mode,r=a?i.d*c:i.d,l="theme-dark"===p(e._renderPlanHass)?"dark":"light",m=!!e._isoLightFloors?.size,v=u(l,m),g=G({views:i.views,d:r,iso:a?{lift:f.lift*r,depth:f.depth*r,edge:h(E,l,m),shadow:{dx:v.dx*r,dy:v.dy*r,sigma:v.sigma*r,opacity:v.opacityWhite}}:null,faces:i.faces,perUnit:n&&o.w?n/o.w:1,handlers:"view"===e._mode?{click:(t,s)=>e._clickDevice(t,s),keydown:(t,s)=>e._keyDevice(t,s),contextmenu:(t,s)=>e._ctxDevice(t,s),pointerdown:(t,s)=>e._pointerDown(t,s),pointermove:(t,s)=>{const o=e._pointerMove(t,s);o&&e._showDeviceTip(t,o)},pointerup:(t,s)=>e._pointerUp(t,s),pointercancel:(t,s)=>e._pointerCancel(t,s),pointerover:(t,s)=>e._showDeviceTip(t,s),pointerleave:()=>e._clearPointerHover(),focus:(t,s)=>e._showDeviceFocusTip(t,s),blur:t=>e._hideDeviceFocusTip(t.id),label:e=>e.name}:null});return"plan"===e._mode||"decor"===e._mode?d`<g class="led-passive" opacity="0.45" pointer-events="none">${g}</g>`:g}function z(e,t,s){if(!1===e.isConnected)return d``;const o=O(e,t,s),i=o.scene&&(D(o.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return i?i.renderLedField({views:o.views,scene:o.scene,polygons:o.polygons,faces:o.faces,spaceId:t.id,owner:e,requestUpdate:()=>e.requestUpdate(),isConnected:()=>!1!==e.isConnected}):d``}function Y(e){const t=e.space.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),s=N({space:e.space,devices:e.devices,hass:e.hass,virtualLights:e.virtualLights,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,cellCm:e.cellCm,gridPitch:e.gridPitch,iconPct:e.iconPct,scene:e.scene,polygons:t,glowFor:e.glowFor,inRoom:e.inRoom,showHidden:!1}),o=e.live?s.views:s.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),i=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},_/e.cellCm*e.gridPitch),n=e.isConnected()&&e.scene&&e.live&&(D(o)||L?.hasLedField(e.owner))?x(e.space.id,()=>e.isConnected()&&e.ready()):null;return n||L?.releaseLedField(e.owner),d`${n?n.renderLedField({views:o,scene:e.scene,polygons:t,faces:i,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:e.isConnected}):m}${G({views:o,d:s.d,faces:i,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,N as ledFrame,O as ledFrameFor,S as ledStats,I as ledStripView,j as ledStripePath,B as releaseLed,z as renderLedFieldFor,W as renderLedLayerFor,G as renderLedStripes,Y as renderStaticLed,H as stripRoom};
|
||||
@@ -0,0 +1,47 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{E as e,N as t,r as s,ax as o,f as i,A as n}from"./open-spans-Co-2W4Ja.js";import{cA as a,cH as r,aS as l,cL as c,cq as d,cr as p,Y as u,cM as h,cN as f,cO as m,cP as v}from"./houseplan-card-BRGPGuZP.js";import{L as g,v as w,s as $,a as _,b as y,p as k,c as b,d as C,e as P}from"./led-strip-geometry-BmgmliDq.js";const M="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";let F,L=null,U=null;function x(t,s){return L||U||F===t||""===F||(U=(void 0===F?import("./led-strip-field-BrWH5irR.js"):import(new URL(`./led-strip-field-BrWH5irR.js?${Date.now()}`,import.meta.url).href)).then(t=>{t.LED_FIELD_FINGERPRINT===e?L=t:F=""},()=>{F=t}).finally(()=>{U=null})),L||U?.then(()=>L&&s()),L}const D=e=>e.some(e=>e.glow&&"on"===e.state),R=e=>e.map(e=>[e[0],e[1]]),E="#383838";function A(e,t){if(!e)return null;const s=Array.isArray(e.masonryGeometry)?e.masonryGeometry:[],o=[...a(e.masonryGeometry),...e.opaqueBodies],i=[];for(const e of o)for(let t=0;t<e.length;t++){const s=e[t],o=e[(t+1)%e.length];s&&o&&i.push({a:[s[0],s[1]],b:[o[0],o[1]]})}const n=25,c=new Map;i.forEach((e,t)=>{const s=Math.floor(Math.min(e.a[0],e.b[0])/n),o=Math.floor(Math.max(e.a[0],e.b[0])/n),i=Math.floor(Math.min(e.a[1],e.b[1])/n),a=Math.floor(Math.max(e.a[1],e.b[1])/n);for(let e=s;e<=o;e++)for(let s=i;s<=a;s++){const o=`${e},${s}`,i=c.get(o);i?i.push(t):c.set(o,[t])}});const d=e=>{let t=1/0,s=1/0,o=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),s=Math.min(s,n[1]),o=Math.max(o,n[0]),i=Math.max(i,n[1]);return[t,s,o,i]},p=(e,t)=>e[0]>=t[0]&&e[0]<=t[2]&&e[1]>=t[1]&&e[1]<=t[3],u=s.filter(e=>e?.[0]?.length).map(e=>({box:d(e[0]),poly:e})),h=e.opaqueBodies.map(e=>({box:d(e),body:e}));return{faces:i,inside:e=>u.some(({box:t,poly:s})=>p(e,t)&&r([e[0],e[1]],[s]))||h.some(({box:t,body:s})=>p(e,t)&&l([e[0],e[1]],s)),epsilon:t,near:e=>{const t=new Set;for(let s=Math.floor(e[0]/n);s<=Math.floor(e[2]/n);s++)for(let o=Math.floor(e[1]/n);o<=Math.floor(e[3]/n);o++)for(const e of c.get(`${s},${o}`)||[])t.add(e);return[...t].map(e=>i[e])}}}function j(e){const t=e.candidate?.sourceEid||e.device.primary||"",s=t?e.hass?.states?.[t]?.state:void 0,o="unavailable"===s||"unknown"===s||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=o?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:o?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const I=new WeakMap;function T(e,t,s){const o=P*s;const i=I.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===o&&i.points.length===e.points.length&&i.points.every((t,s)=>t[0]===e.points[s][0]&&t[1]===e.points[s][1]))return i.value;const n=R(e.points),a=y(n,t,o/2),r={t:o,path:a,d:k(a)};return I.set(e,{faces:t,epsilon:t?.epsilon,points:n,value:r}),r}function H(e){if(!e.views.length)return i``;const t=e.handlers,s=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),o=e.iso;return i`<g class="led-strips" data-hp-led-strips="${s.length}"
|
||||
data-hp-iso=${o?"raised":n}>
|
||||
${o?i`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
|
||||
x="-100000" y="-100000" width="200000" height="200000">
|
||||
<feGaussianBlur stdDeviation="${o.shadow.sigma}"></feGaussianBlur></filter></defs>`:n}
|
||||
<style>
|
||||
.led-strip .led-focus { fill: none; stroke: transparent; }
|
||||
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
|
||||
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
|
||||
</style>
|
||||
${v(s,e=>e.strip.id,s=>{const{t:a,path:r,d:l}=T(s.strip,e.faces,e.d),c="unavailable"===s.state,d=c?"#9e9e9e":"on"===s.state&&s.appearance?s.appearance.c:"#FFFFFF",p=2*b(a*e.perUnit)/(e.perUnit||1),u=s.device,h=t=>function(e,t,s){const o=e.currentTarget,i=o?.getScreenCTM?.();if(!i||s.views.length<2)return t;const n=i.inverse(),a=[n.a*e.clientX+n.c*e.clientY+n.e,n.b*e.clientX+n.d*e.clientY+n.f],r=s.views.map(e=>{const{t:t,path:o}=T(e.strip,s.faces,s.d);return{id:e.strip.id,points:o.points.map(e=>[e[0]*s.perUnit,e[1]*s.perUnit]),closed:o.closed,thicknessPx:t*s.perUnit}}),l=C([a[0]*s.perUnit,a[1]*s.perUnit],r);return s.views.find(e=>e.strip.id===l)||t}(t,s,e).device,f=o?`translate(0 ${-o.lift})`:n;return i`<g class="led-strip state-${s.state}" data-led-strip="${s.strip.id}"
|
||||
data-marker="${u.id}" data-state="${s.state}" data-closed="${r.closed?"true":"false"}">
|
||||
${o?i`<path class="led-iso-shadow" d="${l}" fill="none" stroke="black"
|
||||
stroke-opacity="${o.shadow.opacity}" stroke-width="${a}" stroke-linecap="round"
|
||||
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
|
||||
transform="translate(${o.shadow.dx} ${o.shadow.dy-o.lift})"></path>
|
||||
${[1,.5].map(e=>i`<path class="led-iso-edge" d="${l}" fill="none" stroke="${o.edge}"
|
||||
stroke-width="${a}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
|
||||
transform="translate(0 ${-o.lift+o.depth*e})"></path>`)}`:n}
|
||||
<g transform=${f}>
|
||||
<path class="led-outline" d="${l}" fill="none" stroke="${E}" stroke-width="${a}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${c?`${2*a} ${1.5*a}`:n}></path>
|
||||
<path class="led-core" d="${l}" fill="none" stroke="${d}" stroke-width="${a/2}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${c?`${2*a} ${1.5*a}`:n}></path>
|
||||
<path class="led-focus" d="${l}" stroke-width="${1.6*a}" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true"></path>
|
||||
${t?i`<path class="led-hit" d="${l}" stroke-width="${p}"
|
||||
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
|
||||
role="button" tabindex="0" aria-label="${t.label(u)}"
|
||||
data-hp="device" data-id="${u.id}"
|
||||
@click=${e=>t.click(e,h(e))}
|
||||
@keydown=${e=>t.keydown(e,u)}
|
||||
@contextmenu=${e=>t.contextmenu(e,h(e))}
|
||||
@pointerdown=${e=>t.pointerdown(e,h(e))}
|
||||
@pointermove=${e=>t.pointermove(e,h(e))}
|
||||
@pointerup=${e=>t.pointerup(e,h(e))}
|
||||
@pointercancel=${e=>t.pointercancel(e,u)}
|
||||
@lostpointercapture=${e=>t.pointercancel(e,u)}
|
||||
@pointerover=${e=>t.pointerover(e,h(e))}
|
||||
@pointerleave=${()=>t.pointerleave()}
|
||||
@focus=${e=>t.focus(e,u)}
|
||||
@blur=${()=>t.blur(u)}></path>`:n}
|
||||
</g>
|
||||
</g>`})}
|
||||
</g>`}function N(e,t,s,o){return(t?e.find(e=>e.id===t):void 0)??(s?e.find(e=>o(s,e)):void 0)}function q(e){const s=new Map(e.devices.map(e=>[e.id,e])),o=g/e.cellCm*e.gridPitch,i=new Map(c({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:o,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),n=[];for(const a of e.space.led_strips||[]){if(!1===a?.active||!a.marker||!w(a.points))continue;const r={...a,points:a.points.map(e=>[e[0]*t,e[1]*t])},l=s.get(r.marker);if(!l||l.space!==e.space.id)continue;if(l.hidden&&!e.showHidden)continue;const c=N(e.space.rooms,l.marker?.room_id,$(R(r.points)),e.inRoom);n.push(j({strip:r,device:l,candidate:i.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!c&&e.glowFor(c),defaultRadius:o,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const a=_/e.cellCm*e.gridPitch;return{views:n,faces:A(e.scene,a),d:e.iconPct/100*d(e.space),scene:e.scene,polygons:e.polygons}}const B=new WeakMap;function G(e){for(const t of B.get(e)?.frame.views??[])I.delete(t.strip);B.delete(e),L?.releaseLedField(e)}function S(e){return{shapes:B.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,visibilityPaths:0,pathChars:0,recomputes:0}}}function O(e,t,i){const n=t.rooms.flatMap(e=>{const t=s(e);return t?[{room:e,poly:t}]:[]}),a=n.length?e._lightBarriers(t,n.map(({room:e,poly:t})=>({r:e,poly:t}))):null,r=[t,e._renderDevices,e._renderPlanHass,i,a,e._mode,e._showAll],l=B.get(e);if(l&&l.key.every((e,t)=>e===r[t]))return l.frame;for(const e of l?.frame.views??[])I.delete(e.strip);const c=e._config?.icon_size??2.5,d=q({space:t,devices:e._renderDevices,hass:e._renderPlanHass,virtualLights:e._virtualLights,defaultColor:e._fillColors.glow_light.c,paletteAlpha:e._fillColors.glow_light.a,cellCm:e._cellCm,gridPitch:e._gridPitch,iconPct:c>8?2.5:c,scene:a,polygons:n,glowFor:e=>o(i,e),inRoom:(t,s)=>e._pointInRoom(t,s),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&B.set(e,{key:r,frame:d}),d}function W(e,t,s,o){const n=O(e,t,s),a=e._stageEl?.clientWidth,r="iso"===e._renderProjection&&"view"===e._mode,l=r?n.d*p:n.d,c="theme-dark"===u(e._renderPlanHass)?"dark":"light",d=!!e._isoLightFloors?.size,v=h(c,d),g=H({views:n.views,d:l,iso:r?{lift:m.lift*l,depth:m.depth*l,edge:f(E,c,d),shadow:{dx:v.dx*l,dy:v.dy*l,sigma:v.sigma*l,opacity:v.opacityWhite}}:null,faces:n.faces,perUnit:a&&o.w?a/o.w:1,handlers:"view"===e._mode?{click:(t,s)=>e._clickDevice(t,s),keydown:(t,s)=>e._keyDevice(t,s),contextmenu:(t,s)=>e._ctxDevice(t,s),pointerdown:(t,s)=>e._pointerDown(t,s),pointermove:(t,s)=>{const o=e._pointerMove(t,s);o&&e._showDeviceTip(t,o)},pointerup:(t,s)=>e._pointerUp(t,s),pointercancel:(t,s)=>e._pointerCancel(t,s),pointerover:(t,s)=>e._showDeviceTip(t,s),pointerleave:()=>e._clearPointerHover(),focus:(t,s)=>e._showDeviceFocusTip(t,s),blur:t=>e._hideDeviceFocusTip(t.id),label:e=>e.name}:null});return"plan"===e._mode||"decor"===e._mode?i`<g class="led-passive" opacity="0.45" pointer-events="none">${g}</g>`:g}function Y(e,t,s){if(!1===e.isConnected)return i``;const o=O(e,t,s),n=o.scene&&(D(o.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return n?n.renderLedField({views:o.views,scene:o.scene,polygons:o.polygons,faces:o.faces,spaceId:t.id,owner:e,requestUpdate:()=>e.requestUpdate(),isConnected:()=>!1!==e.isConnected}):i``}function z(e){const t=e.space.rooms.flatMap(e=>{const t=s(e);return t?[{room:e,poly:t}]:[]}),o=q({space:e.space,devices:e.devices,hass:e.hass,virtualLights:e.virtualLights,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,cellCm:e.cellCm,gridPitch:e.gridPitch,iconPct:e.iconPct,scene:e.scene,polygons:t,glowFor:e.glowFor,inRoom:e.inRoom,showHidden:!1}),a=e.live?o.views:o.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),r=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},_/e.cellCm*e.gridPitch),l=e.isConnected()&&e.scene&&e.live&&(D(a)||L?.hasLedField(e.owner))?x(e.space.id,()=>e.isConnected()&&e.ready()):null;return l||L?.releaseLedField(e.owner),i`${l?l.renderLedField({views:a,scene:e.scene,polygons:t,faces:r,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:e.isConnected}):n}${H({views:a,d:o.d,faces:r,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,q as ledFrame,O as ledFrameFor,S as ledStats,j as ledStripView,T as ledStripePath,G as releaseLed,Y as renderLedFieldFor,W as renderLedLayerFor,H as renderLedStripes,z as renderStaticLed,N as stripRoom};
|
||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";const t=t=>[t.left,t.top,t.right,t.bottom,t.width,t.height].every(Number.isFinite)&&t.right>=t.left&&t.bottom>=t.top&&t.width>=0&&t.height>=0;function o(o){const{preferred:e,size:i,bounds:h,blockers:r,gap:n=8,edge:f=8}=o;if(![e.x,e.y,i.width,i.height,n,f].every(Number.isFinite)||i.width<=0||i.height<=0||n<0||f<0||!t(h)||r.some(o=>!t(o)))return null;const s=h.left+f,l=h.right-f-i.width,d=h.top+f,b=h.bottom-f-i.height;if(![s,l,d,b].every(Number.isFinite)||l<s||b<d)return null;const g=r.filter(t=>t.right>t.left&&t.bottom>t.top).map(t=>({left:t.left-n-i.width,right:t.right+n,top:t.top-n-i.height,bottom:t.bottom+n})),a=(t,o,e)=>Math.max(o,Math.min(e,t)),c=new Set([s,l,a(e.x,s,l)]),m=new Set([d,b,a(e.y,d,b)]);for(const t of g)c.add(a(t.left,s,l)),c.add(a(t.right,s,l)),m.add(a(t.top,d,b)),m.add(a(t.bottom,d,b));const p=[...c].sort((t,o)=>t-o),u=[...m].sort((t,o)=>t-o);let w=null,y=1/0;for(const t of u)for(const o of p){if(g.some(e=>o>e.left&&o<e.right&&t>e.top&&t<e.bottom))continue;const i=Math.hypot(o-e.x,t-e.y);(!w||i<y)&&(w={left:o,top:t},y=i)}return w}export{o as p};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";import{eR as e,eS as n,eG as c}from"./houseplan-card-D6V9pMgM.js";function o(c,o){return new e([{code:"en",dictionary:c},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915",console.warn,n)}function r(e){return function(e,n){return{state:c=>{const o=e.state(c);return"pending"!==o&&n.some(e=>"pending"===e.state(c))?"pending":o},dictionary:n=>e.dictionary(n),ensure:c=>Promise.all([e,...n].map(e=>e.ensure(c))).then(()=>{})}}(c,e)}export{o as n,r as s};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{co as n,cp as a,aJ as e}from"./open-spans-Co-2W4Ja.js";function o(e,o){return new n([{code:"en",dictionary:e},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342",console.warn,a)}function c(n){return function(n,a){return{state:e=>{const o=n.state(e);return"pending"!==o&&a.some(n=>"pending"===n.state(e))?"pending":o},dictionary:a=>n.dictionary(a),ensure:e=>Promise.all([n,...a].map(n=>n.ensure(e))).then(()=>{})}}(e,n)}export{o as n,c as s};
|
||||
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";import{aG as e,l as t,aJ as s,A as a,eF as o,eG as i,eH as n,E as l,eQ as c,c as r}from"./houseplan-card-D6V9pMgM.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return l;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{...this._config,fit:c(this._config.fit)}}
|
||||
.schema=${this._schema}
|
||||
.computeLabel=${e=>_[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`}_valueChanged(e){const t={...this._config||{},...e.detail.value};delete t.aspect_ratio;const s=new Event("config-changed",{bubbles:!0,composed:!0});s.detail={config:t},this.dispatchEvent(s)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-space-card-editor")||customElements.define("houseplan-space-card-editor",h);
|
||||
@@ -0,0 +1,7 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{u as e,A as t,aI as s,aJ as a,aK as o,p as i,n}from"./open-spans-Co-2W4Ja.js";import{l,U as c,cQ as r}from"./houseplan-card-BRGPGuZP.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return l(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:c(this._lang,"editor.fit_content")},{value:"house",label:c(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return t;const e=s(this,a,l(this.hass,this._config.language));if("cold"===e)return o();if("warm"===e)return i;this._loadSpaces();const h=this._lang,_={space:c(h,"editor.space"),title:c(h,"editor.title"),fit:c(h,"editor.framing"),show_button:c(h,"editor.show_button"),button_label:c(h,"editor.button_label"),button_target:c(h,"editor.button_target"),icon_size:c(h,"editor.icon_size"),show_temperature:c(h,"editor.show_temperature"),live_states:c(h,"editor.live_states"),light_pools:c(h,"editor.light_pools"),show_signal:c(h,"editor.show_signal")};return n`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{...this._config,fit:r(this._config.fit)}}
|
||||
.schema=${this._schema}
|
||||
.computeLabel=${e=>_[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`}_valueChanged(e){const t={...this._config||{},...e.detail.value};delete t.aspect_ratio;const s=new Event("config-changed",{bubbles:!0,composed:!0});s.detail={config:t},this.dispatchEvent(s)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-space-card-editor")||customElements.define("houseplan-space-card-editor",h);
|
||||
+52
-52
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";import{c as a,A as e}from"./houseplan-card-D6V9pMgM.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-8tzkJjlW.js";import"./form-kit.styles-DcEgGWA8.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{n as a,A as s}from"./open-spans-Co-2W4Ja.js";import{s as e,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-D8WiQSym.js";import"./houseplan-card-BRGPGuZP.js";import"./form-kit.styles-DyFi9GJ3.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!e(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(s=>s.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
@@ -10,31 +10,31 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
dismiss-on-scrim aria-busy=${String(d.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor" @click=${()=>m.closeSource()}>
|
||||
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
|
||||
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:s}
|
||||
<section class="summary-general" aria-labelledby="summary-general-title">
|
||||
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
|
||||
<div class="summary-general-grid">
|
||||
${d.localOnly?e:a`<div class="summary-field">
|
||||
${d.localOnly?s:a`<div class="summary-field">
|
||||
<label for="summary-panel-title">${b("summary.panel_title")}</label>
|
||||
<input id="summary-panel-title" type="text" .value=${d.draft.title}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&"title"===y?.path)}
|
||||
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
|
||||
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
|
||||
@input=${a=>m.mutate(s=>{s.title=a.target.value})} />
|
||||
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:s}
|
||||
</div>`}
|
||||
${g}
|
||||
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
|
||||
${d.localOnly?s:a`<label class="summary-switch" for="summary-mobile-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
|
||||
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
|
||||
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
|
||||
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
|
||||
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
|
||||
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
|
||||
@change=${a=>m.mutate(s=>{s.show_on_mobile=a.target.checked})} /></label>`}
|
||||
</div>
|
||||
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
|
||||
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:s}
|
||||
</section>
|
||||
${d.localOnly?e:a`
|
||||
${d.localOnly?s:a`
|
||||
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
|
||||
<header class="summary-blocks-heading">
|
||||
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
|
||||
@@ -42,7 +42,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
|
||||
</header>
|
||||
<div class="summary-editor-blocks">
|
||||
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
|
||||
${d.draft.blocks.map((e,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
|
||||
@dragover=${a=>a.preventDefault()}
|
||||
@drop=${a=>m.drop(a,`block:${t}`)}>
|
||||
<div class="summary-block-head">
|
||||
@@ -57,40 +57,40 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
?disabled=${t===d.draft.blocks.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<input class="summary-block-title" type="text" .value=${s.title}
|
||||
<input class="summary-block-title" type="text" .value=${e.title}
|
||||
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
|
||||
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
|
||||
@input=${a=>m.mutate(s=>{s.blocks[t].title=a.target.value})} />
|
||||
<button class="summary-icon-button summary-visibility" type="button"
|
||||
?disabled=${d.busy} aria-pressed=${String(s.visible)}
|
||||
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
|
||||
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
|
||||
?disabled=${d.busy} aria-pressed=${String(e.visible)}
|
||||
title=${b(e.visible?"summary.hide_block":"summary.show_block")}
|
||||
aria-label=${b(e.visible?"summary.hide_block":"summary.show_block")}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(e.visible?"eye":"eyeOff")}</button>
|
||||
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:s}
|
||||
<div class="summary-scope">
|
||||
<select .value=${s.scope.type} ?disabled=${d.busy}
|
||||
<select .value=${e.scope.type} ?disabled=${d.busy}
|
||||
aria-label=${b("summary.scope")}
|
||||
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
|
||||
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
|
||||
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
|
||||
@change=${a=>m.mutate(s=>{s.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
|
||||
<option value="all" ?selected=${"all"===e.scope.type}>${b("summary.scope_all")}</option>
|
||||
<option value="space" ?selected=${"space"===e.scope.type}>${b("summary.scope_space")}</option>
|
||||
</select>
|
||||
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
|
||||
${"space"===e.scope.type?a`<select .value=${e.scope.space_id}
|
||||
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
|
||||
aria-invalid=${"error"===n?.kind?"true":"false"}
|
||||
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
|
||||
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
|
||||
${c._model.map(e=>a`<option value=${e.id}
|
||||
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
|
||||
</select>`:e}
|
||||
@change=${a=>m.mutate(s=>{const e=s.blocks[t].scope;"space"===e.type&&(e.space_id=a.target.value)})}>
|
||||
${c._model.some(a=>a.id===e.scope.space_id)?s:a`<option value=${e.scope.space_id} selected>${e.scope.space_id}</option>`}
|
||||
${c._model.map(s=>a`<option value=${s.id}
|
||||
?selected=${s.id===e.scope.space_id}>${s.title}</option>`)}
|
||||
</select>`:s}
|
||||
</div>
|
||||
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
|
||||
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:s}
|
||||
</div>
|
||||
<div class="summary-block-body">
|
||||
<div class="summary-editor-values">
|
||||
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
|
||||
${e.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===e.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${e.id}\n${i.id}`;return a`<div class="summary-editor-value"
|
||||
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
|
||||
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
|
||||
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
|
||||
@@ -101,7 +101,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.down")}
|
||||
aria-label=${b("summary.down")}
|
||||
?disabled=${c===s.values.length-1||d.busy}
|
||||
?disabled=${c===e.values.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<div class="summary-value-label">
|
||||
@@ -109,8 +109,8 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
|
||||
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
|
||||
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
|
||||
@input=${a=>m.mutate(s=>{s.blocks[t].values[c].label=a.target.value})} />
|
||||
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:s}
|
||||
</div>
|
||||
<div class="summary-source-field">
|
||||
<button class="summary-source" type="button"
|
||||
@@ -119,9 +119,9 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
|
||||
aria-invalid=${"error"===$?.kind?"true":"false"}
|
||||
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
|
||||
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
|
||||
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(e.id,i.id)}}>
|
||||
<span class="summary-source-caption"><strong>${g}</strong>
|
||||
${_?a`<small>${_}</small>`:e}</span>
|
||||
${_?a`<small>${_}</small>`:s}</span>
|
||||
${l("chevron")}
|
||||
</button>
|
||||
${k&&f?a`<div class="summary-source-picker"
|
||||
@@ -135,39 +135,39 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
<div class="summary-source-results" role="listbox"
|
||||
aria-label=${b("summary.select_source")}>
|
||||
<div class="summary-source-group">${b("summary.system_group")}</div>
|
||||
${["device_count","total_area","datetime"].map(e=>a`
|
||||
${["device_count","total_area","datetime"].map(s=>a`
|
||||
<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`system:${e}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
|
||||
${b(`summary.system.${e}`)}
|
||||
aria-selected=${p===`system:${s}`?"true":"false"}
|
||||
@click=${()=>m.setSource(e.id,i.id,`system:${s}`)}>
|
||||
${b(`summary.system.${s}`)}
|
||||
</button>`)}
|
||||
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
|
||||
<button type="button" role="option" class="broken" aria-selected="true"
|
||||
?disabled=${d.busy}
|
||||
@click=${()=>m.setSource(s.id,i.id,p)}>
|
||||
@click=${()=>m.setSource(e.id,i.id,p)}>
|
||||
${g}
|
||||
</button>`:e}
|
||||
</button>`:s}
|
||||
<div class="summary-source-group">${b("summary.entities_group")}</div>
|
||||
${f.entries.map(e=>a`<button type="button" role="option"
|
||||
${f.entries.map(s=>a`<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`entity:${e.id}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
|
||||
<span>${e.label}</span><small>${e.id}</small>
|
||||
aria-selected=${p===`entity:${s.id}`?"true":"false"}
|
||||
@click=${()=>m.setSource(e.id,i.id,`entity:${s.id}`)}>
|
||||
<span>${s.label}</span><small>${s.id}</small>
|
||||
</button>`)}
|
||||
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
|
||||
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
|
||||
${f.total?s:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
|
||||
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:s}
|
||||
</div>
|
||||
</div>`:e}
|
||||
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
|
||||
</div>`:s}
|
||||
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:s}
|
||||
</div>
|
||||
<button class="summary-icon-button summary-value-remove" type="button"
|
||||
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
|
||||
</div>`})}
|
||||
<button class="summary-add summary-add-value" type="button"
|
||||
?disabled=${s.values.length>=20||d.busy}
|
||||
title=${s.values.length>=20?b("summary.limit_values"):""}
|
||||
?disabled=${e.values.length>=20||d.busy}
|
||||
title=${e.values.length>=20?b("summary.limit_values"):""}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
|
||||
${l("plus")}${b("summary.add_value")}
|
||||
</button>
|
||||
@@ -186,11 +186,11 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f
|
||||
${l("plus")}${b("summary.add_block")}
|
||||
</button>
|
||||
</section>
|
||||
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
|
||||
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:s}
|
||||
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
|
||||
?disabled=${d.busy} @click=${()=>m.reload()}>
|
||||
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
|
||||
</button>`:e}
|
||||
</button>`:s}
|
||||
`}
|
||||
</div>
|
||||
<div class="row summary-editor-footer" slot="footer">
|
||||
-1
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";import{e$ as e,O as t,b5 as n,M as i,af as o,ad as r,cf as s,f0 as a,ce as c,b4 as l,b3 as d,b6 as u,f1 as f,f2 as m,ch as g,f3 as v}from"./houseplan-card-D6V9pMgM.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
|
||||
+1
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";import{af as e,N as t,R as n,G as i,bN as s,a7 as o,a8 as r,r as a,aE as l,J as c}from"./open-spans-Co-2W4Ja.js";import{a8 as u,b0 as d,cT as f,cl as m,d0 as v,aZ as y}from"./houseplan-card-BRGPGuZP.js";function g(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const s=String(i.binding||"").indexOf(":");if(s<1)continue;const o=i.binding.slice(0,s),r=i.binding.slice(s+1),a="device"===o?e.registry.devices?.[r]:null,l="entity"===o?e.registry.entities?.[r]:null,c="device"===o?r:l?.device_id,u="device"===o?a?.area_id:l?.area_id||c&&e.registry.devices?.[c]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;c&&e.registry.devices?.[c]&&e.spaceIds.has(f)&&(!n.devices.has(c)||"entity"===o&&n.liveEntities.has(r))&&t.add(c)}return t}function _(e,t){return e?.length?t?.length?c(e,t):e:t}function h(o,r){const a=e(o.rooms,r.walls,r.openCuts,[],i,r.cellCm,n,t);return{roomGeom:"ok"===a.status||"degraded-extra"===a.status?a.roomGeom:void 0,multiWallNodes:s(o.rooms,r.walls,r.openCuts,i,r.cellCm,n,t)}}function*p(e,s,o){const r=f(e,s),c=o(s,r);let u=null;for(const e of s.rooms){if(!e.id)continue;const o=a(e);if(!o)continue;const d=l(s.rooms,e.id,r.walls,r.openCuts,i,r.cellCm,n,t,c.roomGeom,c.multiWallNodes)||o;u=_(u,m(d,r.physicalBodies)),yield}const d=v(u||[],s.stairs);let g=d.next();for(;!g.done;)yield,g=d.next();u=g.value;const h=r.cellCm/n;return y(u)*h*h/1e4}function*S(e,t,n=h,i){try{let s=0,o=!1;const r=new Set;for(let a=0;a<t.length;a++){const l=t[a],c=e.spaces.find(e=>String(e?.id)===l.id);if(!c)continue;const u=i?.key(a,c,l);let d;if(i&&void 0!==u&&i.values.has(u))d=i.values.get(u)??null;else{try{d=yield*p(c,l,n)}catch{if(!i)return null;d=null}i&&void 0!==u&&i.values.set(u,d)}void 0!==u&&r.add(u),null===d?o=!0:s+=d}if(i)for(const e of[...i.values.keys()])r.has(e)||i.values.delete(e);return o?null:s}catch{return null}}function b(e,t,n=h){const i=S(e,t,n);let s=i.next();for(;!s.done;)s=i.next();return s.value}function C(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:d(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function w(e,t){const n=e?.states?.[t];if(!n)return null;const i=o(e,t);return i?r(i,String(n.attributes?.unit_of_measurement||"")):null}export{S as cleanFloorAreaSteps,g as representedHaDeviceIds,h as spaceWallGeometry,w as summaryEntityValue,C as summarySystemValue,b as totalCleanFloorAreaM2};
|
||||
-272
File diff suppressed because one or more lines are too long
+272
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";var e={"furn.title":"Möbelbibliothek","furn.symbol":"Symbol","furn.group_furniture":"Möbel","furn.group_appliance":"Geräte","furn.group_sanitary":"Sanitär","furn.group_other":"Sonstiges","furn.width":"Breite","furn.depth":"Tiefe","furn.back_to_categories":"Alle Kategorien","furn.pick_hint":"Wählen Sie eine Kategorie und dann ein Symbol.","furn.place_hint":"Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.","furn.cat_air_conditioner":"Klimaanlagen","furn.cat_armchair":"Sessel","furn.cat_bathtub":"Badewannen","furn.cat_bed":"Betten","furn.cat_bidet":"Bidets","furn.cat_boiler":"Warmwasserbereiter","furn.cat_chair":"Stühle","furn.cat_coffee_table":"Couchtische","furn.cat_computer":"Computer","furn.cat_cooktop":"Kochfelder","furn.cat_dining_table":"Esstische","furn.cat_dishwasher":"Geschirrspüler","furn.cat_dryer":"Wäschetrockner","furn.cat_exercise":"Fitnessgerät","furn.cat_fireplace":"Kamine","furn.cat_fridge":"Kühlschränke","furn.cat_hood":"Dunstabzugshaube","furn.cat_kitchen_cabinet":"Küchenschränke","furn.cat_kitchen_sink":"Küchenspülen","furn.cat_nightstand":"Schränke","furn.cat_oven":"Backofen","furn.cat_plant":"Pflanzen","furn.cat_rug":"Teppiche","furn.cat_shelving":"Regale","furn.cat_shower":"Duschen","furn.cat_sink":"Waschbecken","furn.cat_sofa":"Sofas","furn.cat_stairs":"Treppen","furn.cat_toilet":"Toiletten","furn.cat_tv":"Fernseher","furn.cat_wardrobe":"Kleiderschränke","furn.cat_washer":"Waschmaschinen","furn.cat_work_table":"Schreibtische","furn.sym_exercise":"Fitnessgerät","furn.sym_computer":"Computer","furn.sym_hood":"Dunstabzugshaube","furn.sym_oven":"Backofen","furn.sym_sofa":"Zweisitzer-Sofa","furn.sym_sofa_three_seat":"Dreisitzer-Sofa","furn.sym_sofa_corner_right":"Ecksofa rechts","furn.sym_armchair":"Polstersessel","furn.sym_armchair_office":"Bürostuhl","furn.sym_coffee_table":"Rechteckiger Couchtisch","furn.sym_coffee_table_round":"Runder Couchtisch","furn.sym_coffee_table_oval":"Ovaler Couchtisch","furn.sym_coffee_table_rounded":"Abgerundeter Couchtisch","furn.sym_table_dining":"Rechteckiger Esstisch","furn.sym_table_round":"Rundtisch","furn.sym_table_dining_oval":"Ovaler Esstisch","furn.sym_table_dining_rounded":"Abgerundeter Esstisch","furn.sym_chair":"Stuhl","furn.sym_chair_bar":"Barhocker","furn.sym_desk":"Rechteckiger Schreibtisch","furn.sym_desk_corner":"Eckschreibtisch","furn.sym_bed_double":"Doppelbett","furn.sym_bed_single":"Einzelbett","furn.sym_nightstand":"Nachttisch","furn.sym_cabinet_tv":"TV-Schrank","furn.sym_cabinet_shoe":"Schuhschrank","furn.sym_cabinet_sink":"Waschtischunterschrank","furn.sym_wardrobe":"Kleiderschrank","furn.sym_bookshelf":"Bücherschrank","furn.sym_wall_unit":"Schrankwand","furn.sym_kitchen_floor":"Küchen-Unterschrank","furn.sym_kitchen_floor_corner":"Küchen-Eckunterschrank","furn.sym_kitchen_wall":"Küchen-Hängeschrank","furn.sym_kitchen_wall_corner":"Küchen-Eckhängeschrank","furn.sym_shelf_floor":"Standregal","furn.sym_shelf_wall":"Wandregal","furn.sym_fridge":"Kühlschrank","furn.sym_stove":"Vierflammiges Kochfeld","furn.sym_cooktop_two":"Zweiflammiges Kochfeld","furn.sym_dishwasher":"Geschirrspüler","furn.sym_washer":"Waschmaschine","furn.sym_dryer":"Wäschetrockner","furn.sym_tv":"Fernseher mit Standfuß","furn.sym_tv_wall":"Wandfernseher","furn.sym_ac":"Klimaanlage","furn.sym_water_heater":"Wassererhitzer","furn.sym_toilet":"Stand-WC","furn.sym_toilet_built_in":"Wand-WC","furn.sym_bathtub":"Rechteckige Badewanne","furn.sym_bathtub_corner":"Eckbadewanne","furn.sym_shower":"Dusche","furn.sym_sink":"Waschbecken","furn.sym_kitchen_sink":"Einzel-Küchenspüle","furn.sym_kitchen_sink_double":"Doppel-Küchenspüle","furn.sym_bidet":"Stand-Bidet","furn.sym_bidet_built_in":"Wand-Bidet","furn.sym_stairs":"Treppe","furn.sym_fireplace":"Kamin","furn.sym_plant":"Pflanze","furn.sym_rug":"Teppich"};const r="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";export{e as dictionary,r as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={node_move_hint:"Wandknoten ziehen. Ursprüngliche Wandachsen und horizontale/vertikale Wände rasten ein. Esc bricht ab; Rückgängig stellt die gesamte Bewegung wieder her.",node_move_ambiguous:"Die Knoten liegen zu nahe beieinander. Zum Auswählen vergrößern.",node_move_choose_axis:"Entlang einer ursprünglichen Wand ziehen, um die Gleitachse zu wählen.",node_move_invalid:"Knoten kann nicht bewegt werden: {reason}",node_move_opening_blocked:"Eine Tür oder ein Fenster würde nicht passen oder den Knoten kreuzen. Bewegung abgebrochen.",node_move_update_required:"House-Plan-Integration aktualisieren, um Wandknoten zu bewegen.",node_move_history:"Wandknoten bewegen",node_move_unsupported_junction:"Dieser komplexe Durchgangsknoten kann nicht bewegt werden. T- und X-Knoten ohne zusätzliche Äste werden unterstützt.","furn.title":"Möbelbibliothek","furn.symbol":"Symbol","furn.group_furniture":"Möbel","furn.group_appliance":"Geräte","furn.group_sanitary":"Sanitär","furn.group_other":"Sonstiges","furn.width":"Breite","furn.depth":"Tiefe","furn.back_to_categories":"Alle Kategorien","furn.pick_hint":"Wählen Sie eine Kategorie und dann ein Symbol.","furn.place_hint":"Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.","furn.cat_air_conditioner":"Klimaanlagen","furn.cat_armchair":"Sessel","furn.cat_bathtub":"Badewannen","furn.cat_bed":"Betten","furn.cat_bidet":"Bidets","furn.cat_boiler":"Warmwasserbereiter","furn.cat_chair":"Stühle","furn.cat_coffee_table":"Couchtische","furn.cat_computer":"Computer","furn.cat_cooktop":"Kochfelder","furn.cat_dining_table":"Esstische","furn.cat_dishwasher":"Geschirrspüler","furn.cat_dryer":"Wäschetrockner","furn.cat_exercise":"Fitnessgerät","furn.cat_fireplace":"Kamine","furn.cat_fridge":"Kühlschränke","furn.cat_hood":"Dunstabzugshaube","furn.cat_kitchen_cabinet":"Küchenschränke","furn.cat_kitchen_sink":"Küchenspülen","furn.cat_nightstand":"Schränke","furn.cat_oven":"Backofen","furn.cat_plant":"Pflanzen","furn.cat_rug":"Teppiche","furn.cat_shelving":"Regale","furn.cat_shower":"Duschen","furn.cat_sink":"Waschbecken","furn.cat_sofa":"Sofas","furn.cat_stairs":"Treppen","furn.cat_toilet":"Toiletten","furn.cat_tv":"Fernseher","furn.cat_wardrobe":"Kleiderschränke","furn.cat_washer":"Waschmaschinen","furn.cat_work_table":"Schreibtische","furn.sym_exercise":"Fitnessgerät","furn.sym_computer":"Computer","furn.sym_hood":"Dunstabzugshaube","furn.sym_oven":"Backofen","furn.sym_sofa":"Zweisitzer-Sofa","furn.sym_sofa_three_seat":"Dreisitzer-Sofa","furn.sym_sofa_corner_right":"Ecksofa rechts","furn.sym_armchair":"Polstersessel","furn.sym_armchair_office":"Bürostuhl","furn.sym_coffee_table":"Rechteckiger Couchtisch","furn.sym_coffee_table_round":"Runder Couchtisch","furn.sym_coffee_table_oval":"Ovaler Couchtisch","furn.sym_coffee_table_rounded":"Abgerundeter Couchtisch","furn.sym_table_dining":"Rechteckiger Esstisch","furn.sym_table_round":"Rundtisch","furn.sym_table_dining_oval":"Ovaler Esstisch","furn.sym_table_dining_rounded":"Abgerundeter Esstisch","furn.sym_chair":"Stuhl","furn.sym_chair_bar":"Barhocker","furn.sym_desk":"Rechteckiger Schreibtisch","furn.sym_desk_corner":"Eckschreibtisch","furn.sym_bed_double":"Doppelbett","furn.sym_bed_single":"Einzelbett","furn.sym_nightstand":"Nachttisch","furn.sym_cabinet_tv":"TV-Schrank","furn.sym_cabinet_shoe":"Schuhschrank","furn.sym_cabinet_sink":"Waschtischunterschrank","furn.sym_wardrobe":"Kleiderschrank","furn.sym_bookshelf":"Bücherschrank","furn.sym_wall_unit":"Schrankwand","furn.sym_kitchen_floor":"Küchen-Unterschrank","furn.sym_kitchen_floor_corner":"Küchen-Eckunterschrank","furn.sym_kitchen_wall":"Küchen-Hängeschrank","furn.sym_kitchen_wall_corner":"Küchen-Eckhängeschrank","furn.sym_shelf_floor":"Standregal","furn.sym_shelf_wall":"Wandregal","furn.sym_fridge":"Kühlschrank","furn.sym_stove":"Vierflammiges Kochfeld","furn.sym_cooktop_two":"Zweiflammiges Kochfeld","furn.sym_dishwasher":"Geschirrspüler","furn.sym_washer":"Waschmaschine","furn.sym_dryer":"Wäschetrockner","furn.sym_tv":"Fernseher mit Standfuß","furn.sym_tv_wall":"Wandfernseher","furn.sym_ac":"Klimaanlage","furn.sym_water_heater":"Wassererhitzer","furn.sym_toilet":"Stand-WC","furn.sym_toilet_built_in":"Wand-WC","furn.sym_bathtub":"Rechteckige Badewanne","furn.sym_bathtub_corner":"Eckbadewanne","furn.sym_shower":"Dusche","furn.sym_sink":"Waschbecken","furn.sym_kitchen_sink":"Einzel-Küchenspüle","furn.sym_kitchen_sink_double":"Doppel-Küchenspüle","furn.sym_bidet":"Stand-Bidet","furn.sym_bidet_built_in":"Wand-Bidet","furn.sym_stairs":"Treppe","furn.sym_fireplace":"Kamin","furn.sym_plant":"Pflanze","furn.sym_rug":"Teppich"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";var e={"furn.title":"Bibliothèque de mobilier","furn.symbol":"Symbole","furn.group_furniture":"Mobilier","furn.group_appliance":"Électroménager","furn.group_sanitary":"Sanitaires","furn.group_other":"Autre","furn.width":"Largeur","furn.depth":"Profondeur","furn.back_to_categories":"Toutes les catégories","furn.pick_hint":"Choisissez un symbole, puis cliquez sur le plan.","furn.place_hint":"Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.","furn.cat_air_conditioner":"Climatiseurs","furn.cat_armchair":"Fauteuils","furn.cat_bathtub":"Baignoires","furn.cat_bed":"Lits","furn.cat_bidet":"Bidets","furn.cat_boiler":"Chauffe-eau","furn.cat_chair":"Chaises","furn.cat_coffee_table":"Tables basses","furn.cat_computer":"Ordinateur","furn.cat_cooktop":"Plaques de cuisson","furn.cat_dining_table":"Tables à manger","furn.cat_dishwasher":"Lave-vaisselle","furn.cat_dryer":"Sèche-linge","furn.cat_exercise":"Équipement de sport","furn.cat_fireplace":"Cheminées","furn.cat_fridge":"Réfrigérateurs","furn.cat_hood":"Hotte","furn.cat_kitchen_cabinet":"Meubles de cuisine","furn.cat_kitchen_sink":"Éviers de cuisine","furn.cat_nightstand":"Meubles","furn.cat_oven":"Four","furn.cat_plant":"Plantes","furn.cat_rug":"Tapis","furn.cat_shelving":"Étagères","furn.cat_shower":"Douches","furn.cat_sink":"Lavabos","furn.cat_sofa":"Canapés","furn.cat_stairs":"Escaliers","furn.cat_toilet":"Toilettes","furn.cat_tv":"Téléviseurs","furn.cat_wardrobe":"Armoires","furn.cat_washer":"Lave-linge","furn.cat_work_table":"Bureaux","furn.sym_exercise":"Équipement de sport","furn.sym_computer":"Ordinateur","furn.sym_hood":"Hotte","furn.sym_oven":"Four","furn.sym_sofa":"Canapé","furn.sym_sofa_three_seat":"Canapé trois places","furn.sym_sofa_corner_right":"Canapé d'angle droit","furn.sym_armchair":"Fauteuil","furn.sym_armchair_office":"Chaise de bureau","furn.sym_coffee_table":"Table basse","furn.sym_coffee_table_round":"Table basse ronde","furn.sym_coffee_table_oval":"Table basse ovale","furn.sym_coffee_table_rounded":"Table basse arrondie","furn.sym_table_dining":"Table à manger","furn.sym_table_round":"Table ronde","furn.sym_table_dining_oval":"Table à manger ovale","furn.sym_table_dining_rounded":"Table à manger arrondie","furn.sym_chair":"Chaise","furn.sym_chair_bar":"Tabouret de bar","furn.sym_desk":"Bureau","furn.sym_desk_corner":"Bureau d'angle","furn.sym_bed_double":"Lit double","furn.sym_bed_single":"Lit simple","furn.sym_nightstand":"Table de chevet","furn.sym_cabinet_tv":"Meuble TV","furn.sym_cabinet_shoe":"Meuble à chaussures","furn.sym_cabinet_sink":"Meuble sous lavabo","furn.sym_wardrobe":"Armoire","furn.sym_bookshelf":"Bibliothèque","furn.sym_wall_unit":"Élément mural","furn.sym_kitchen_floor":"Module de cuisine bas","furn.sym_kitchen_floor_corner":"Module de cuisine bas d'angle","furn.sym_kitchen_wall":"Module de cuisine haut","furn.sym_kitchen_wall_corner":"Module de cuisine haut d'angle","furn.sym_shelf_floor":"Étagère au sol","furn.sym_shelf_wall":"Étagère murale","furn.sym_fridge":"Réfrigérateur","furn.sym_stove":"Cuisinière","furn.sym_cooktop_two":"Plaque à deux feux","furn.sym_dishwasher":"Lave-vaisselle","furn.sym_washer":"Lave-linge","furn.sym_dryer":"Sèche-linge","furn.sym_tv":"Téléviseur","furn.sym_tv_wall":"Téléviseur mural","furn.sym_ac":"Climatiseur","furn.sym_water_heater":"Chauffe-eau","furn.sym_toilet":"Toilettes","furn.sym_toilet_built_in":"Toilettes suspendues","furn.sym_bathtub":"Baignoire","furn.sym_bathtub_corner":"Baignoire d'angle","furn.sym_shower":"Douche","furn.sym_sink":"Lavabo","furn.sym_kitchen_sink":"Évier de cuisine","furn.sym_kitchen_sink_double":"Évier de cuisine double","furn.sym_bidet":"Bidet","furn.sym_bidet_built_in":"Bidet suspendu","furn.sym_stairs":"Escalier","furn.sym_fireplace":"Cheminée","furn.sym_plant":"Plante","furn.sym_rug":"Tapis"};const r="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";export{e as dictionary,r as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={node_move_hint:"Faites glisser le nœud du mur. Les axes initiaux et les murs horizontaux/verticaux s'aimantent. Échap annule ; Annuler restaure tout le déplacement.",node_move_ambiguous:"Ces nœuds sont trop proches. Zoomez pour en choisir un.",node_move_choose_axis:"Déplacez-vous le long d'un mur initial pour choisir l'axe de glissement.",node_move_invalid:"Impossible de déplacer ce nœud : {reason}",node_move_opening_blocked:"Une porte ou fenêtre ne tiendrait plus ou traverserait le nœud. Déplacement annulé.",node_move_update_required:"Mettez à jour l'intégration House Plan pour déplacer les nœuds des murs.",node_move_history:"Déplacer un nœud de mur",node_move_unsupported_junction:"Cette jonction traversante complexe ne peut pas être déplacée. Les jonctions T et X sans branche supplémentaire sont prises en charge.","furn.title":"Bibliothèque de mobilier","furn.symbol":"Symbole","furn.group_furniture":"Mobilier","furn.group_appliance":"Électroménager","furn.group_sanitary":"Sanitaires","furn.group_other":"Autre","furn.width":"Largeur","furn.depth":"Profondeur","furn.back_to_categories":"Toutes les catégories","furn.pick_hint":"Choisissez un symbole, puis cliquez sur le plan.","furn.place_hint":"Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.","furn.cat_air_conditioner":"Climatiseurs","furn.cat_armchair":"Fauteuils","furn.cat_bathtub":"Baignoires","furn.cat_bed":"Lits","furn.cat_bidet":"Bidets","furn.cat_boiler":"Chauffe-eau","furn.cat_chair":"Chaises","furn.cat_coffee_table":"Tables basses","furn.cat_computer":"Ordinateur","furn.cat_cooktop":"Plaques de cuisson","furn.cat_dining_table":"Tables à manger","furn.cat_dishwasher":"Lave-vaisselle","furn.cat_dryer":"Sèche-linge","furn.cat_exercise":"Équipement de sport","furn.cat_fireplace":"Cheminées","furn.cat_fridge":"Réfrigérateurs","furn.cat_hood":"Hotte","furn.cat_kitchen_cabinet":"Meubles de cuisine","furn.cat_kitchen_sink":"Éviers de cuisine","furn.cat_nightstand":"Meubles","furn.cat_oven":"Four","furn.cat_plant":"Plantes","furn.cat_rug":"Tapis","furn.cat_shelving":"Étagères","furn.cat_shower":"Douches","furn.cat_sink":"Lavabos","furn.cat_sofa":"Canapés","furn.cat_stairs":"Escaliers","furn.cat_toilet":"Toilettes","furn.cat_tv":"Téléviseurs","furn.cat_wardrobe":"Armoires","furn.cat_washer":"Lave-linge","furn.cat_work_table":"Bureaux","furn.sym_exercise":"Équipement de sport","furn.sym_computer":"Ordinateur","furn.sym_hood":"Hotte","furn.sym_oven":"Four","furn.sym_sofa":"Canapé","furn.sym_sofa_three_seat":"Canapé trois places","furn.sym_sofa_corner_right":"Canapé d'angle droit","furn.sym_armchair":"Fauteuil","furn.sym_armchair_office":"Chaise de bureau","furn.sym_coffee_table":"Table basse","furn.sym_coffee_table_round":"Table basse ronde","furn.sym_coffee_table_oval":"Table basse ovale","furn.sym_coffee_table_rounded":"Table basse arrondie","furn.sym_table_dining":"Table à manger","furn.sym_table_round":"Table ronde","furn.sym_table_dining_oval":"Table à manger ovale","furn.sym_table_dining_rounded":"Table à manger arrondie","furn.sym_chair":"Chaise","furn.sym_chair_bar":"Tabouret de bar","furn.sym_desk":"Bureau","furn.sym_desk_corner":"Bureau d'angle","furn.sym_bed_double":"Lit double","furn.sym_bed_single":"Lit simple","furn.sym_nightstand":"Table de chevet","furn.sym_cabinet_tv":"Meuble TV","furn.sym_cabinet_shoe":"Meuble à chaussures","furn.sym_cabinet_sink":"Meuble sous lavabo","furn.sym_wardrobe":"Armoire","furn.sym_bookshelf":"Bibliothèque","furn.sym_wall_unit":"Élément mural","furn.sym_kitchen_floor":"Module de cuisine bas","furn.sym_kitchen_floor_corner":"Module de cuisine bas d'angle","furn.sym_kitchen_wall":"Module de cuisine haut","furn.sym_kitchen_wall_corner":"Module de cuisine haut d'angle","furn.sym_shelf_floor":"Étagère au sol","furn.sym_shelf_wall":"Étagère murale","furn.sym_fridge":"Réfrigérateur","furn.sym_stove":"Cuisinière","furn.sym_cooktop_two":"Plaque à deux feux","furn.sym_dishwasher":"Lave-vaisselle","furn.sym_washer":"Lave-linge","furn.sym_dryer":"Sèche-linge","furn.sym_tv":"Téléviseur","furn.sym_tv_wall":"Téléviseur mural","furn.sym_ac":"Climatiseur","furn.sym_water_heater":"Chauffe-eau","furn.sym_toilet":"Toilettes","furn.sym_toilet_built_in":"Toilettes suspendues","furn.sym_bathtub":"Baignoire","furn.sym_bathtub_corner":"Baignoire d'angle","furn.sym_shower":"Douche","furn.sym_sink":"Lavabo","furn.sym_kitchen_sink":"Évier de cuisine","furn.sym_kitchen_sink_double":"Évier de cuisine double","furn.sym_bidet":"Bidet","furn.sym_bidet_built_in":"Bidet suspendu","furn.sym_stairs":"Escalier","furn.sym_fireplace":"Cheminée","furn.sym_plant":"Plante","furn.sym_rug":"Tapis"};const r="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,r as fingerprint};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Zeigt Eltern und nächste Hops laut Integration. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt den Elternknoten eines Endgeräts oder den aktiven nächsten Hop eines Routers zum Koordinator laut Integration. Es gibt höchstens einen ausgehenden Pfeil; eingehende Pfeile zeigen Geräte, die über diesen Knoten routen. Gewöhnliche Nachbarn werden ausgeblendet. Alle Linien sind durchgezogen. Linie und Pfeil verwenden LQI 0–255: rot bei 0, gelb bei 128, grün bei 255. Ohne LQI sind sie grau mit schwarzer Kontur. Eine Beschriftung am Pfeilende nennt ein Ziel in einem anderen Bereich oder außerhalb des Plans. Keine Routendaten bedeutet fehlende eindeutige Angaben, nicht dass das Gerät offline ist. Dies sind zuletzt empfangene Integrationsdaten, keine Live-Paketverfolgung oder Garantie des gesamten Pfads. Der ZHA-Cache kann beim Lesen bereits alt sein; veraltete und unvollständige Daten werden markiert. Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan mit Routen kann länger als 10 Minuten dauern. House Plan wartet im Hintergrund weiter. Der Scan kann Zigbee vorübergehend verlangsamen.",scan_connecting:"Verbindung zu Zigbee2MQTT wird hergestellt",scan_waiting:"Warten auf die Karte von Zigbee2MQTT",scan_elapsed:"Vergangen: {time}",scan_background_hint:"Du kannst die Einstellungen schließen — das Warten wird im Hintergrund fortgesetzt.",scan_long_wait:"Zigbee2MQTT hat noch nicht geantwortet. Das Warten wird fortgesetzt.",scan_cancel:"Anfrage abbrechen",scan_cancel_hint:"Nur das Warten von House Plan wird beendet. Zigbee2MQTT kann den Scan fortsetzen.",scan_cancelled:"Warten abgebrochen. Der Scan von Zigbee2MQTT ist möglicherweise noch nicht beendet.",scan_backend_required:"Aktualisieren Sie die House-Plan-Integration und starten Sie Home Assistant neu, um Kartenscans im Hintergrund zu nutzen.",scan_connection_lost:"Die MQTT-Verbindung wurde unterbrochen; das Ergebnis ist möglicherweise nicht angekommen. Das Warten wurde beendet, Zigbee2MQTT scannt möglicherweise weiter.",scan_frontend_disconnected:"Die Verbindung zu Home Assistant wurde unterbrochen. Das Warten wird im Hintergrund fortgesetzt. Stelle die Verbindung wieder her, um den Status zu sehen.",scan_unavailable:"MQTT ist in Home Assistant nicht verfügbar oder wird nicht unterstützt.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · unvollständige Daten",status_no_links:"Empfangen: {time} · keine bestätigten Routen",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich",route_device_not_on_plan_named:"Gerät ist nicht im Plan ({name})",route_unknown:"Keine Routendaten",route_stale:"Veraltete Daten",route_partial:"Unvollständige Daten",status_cache:"Integrationscache; Zeitpunkt des Funkscans unbekannt"};const n="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";export{e as dictionary,n as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Zeigt Eltern und nächste Hops laut Integration. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt den Elternknoten eines Endgeräts oder den aktiven nächsten Hop eines Routers zum Koordinator laut Integration. Es gibt höchstens einen ausgehenden Pfeil; eingehende Pfeile zeigen Geräte, die über diesen Knoten routen. Gewöhnliche Nachbarn werden ausgeblendet. Alle Linien sind durchgezogen. Linie und Pfeil verwenden LQI 0–255: rot bei 0, gelb bei 128, grün bei 255. Ohne LQI sind sie grau mit schwarzer Kontur. Eine Beschriftung am Pfeilende nennt ein Ziel in einem anderen Bereich oder außerhalb des Plans. Keine Routendaten bedeutet fehlende eindeutige Angaben, nicht dass das Gerät offline ist. Dies sind zuletzt empfangene Integrationsdaten, keine Live-Paketverfolgung oder Garantie des gesamten Pfads. Der ZHA-Cache kann beim Lesen bereits alt sein; veraltete und unvollständige Daten werden markiert. Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan mit Routen kann länger als 10 Minuten dauern. House Plan wartet im Hintergrund weiter. Der Scan kann Zigbee vorübergehend verlangsamen.",scan_connecting:"Verbindung zu Zigbee2MQTT wird hergestellt",scan_waiting:"Warten auf die Karte von Zigbee2MQTT",scan_elapsed:"Vergangen: {time}",scan_background_hint:"Du kannst die Einstellungen schließen — das Warten wird im Hintergrund fortgesetzt.",scan_long_wait:"Zigbee2MQTT hat noch nicht geantwortet. Das Warten wird fortgesetzt.",scan_cancel:"Anfrage abbrechen",scan_cancel_hint:"Nur das Warten von House Plan wird beendet. Zigbee2MQTT kann den Scan fortsetzen.",scan_cancelled:"Warten abgebrochen. Der Scan von Zigbee2MQTT ist möglicherweise noch nicht beendet.",scan_backend_required:"Aktualisieren Sie die House-Plan-Integration und starten Sie Home Assistant neu, um Kartenscans im Hintergrund zu nutzen.",scan_connection_lost:"Die MQTT-Verbindung wurde unterbrochen; das Ergebnis ist möglicherweise nicht angekommen. Das Warten wurde beendet, Zigbee2MQTT scannt möglicherweise weiter.",scan_frontend_disconnected:"Die Verbindung zu Home Assistant wurde unterbrochen. Das Warten wird im Hintergrund fortgesetzt. Stelle die Verbindung wieder her, um den Status zu sehen.",scan_unavailable:"MQTT ist in Home Assistant nicht verfügbar oder wird nicht unterstützt.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · unvollständige Daten",status_no_links:"Empfangen: {time} · keine bestätigten Routen",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich",route_other_space_named:"{space} ({name})",route_device_not_on_plan_named:"Gerät ist nicht im Plan ({name})",route_unknown:"Keine Routendaten",route_stale:"Veraltete Daten",route_partial:"Unvollständige Daten",status_cache:"Integrationscache; Zeitpunkt des Funkscans unbekannt"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Affiche les parents et prochains sauts signalés par l’intégration. Le survol ne lance aucune requête.",help:"Affiche le parent d’un appareil terminal ou le prochain saut actif d’un routeur vers le coordinateur selon l’intégration. Il y a au plus une flèche sortante ; les flèches entrantes indiquent les appareils passant par ce nœud. Les simples voisins sont masqués. Toutes les lignes sont continues. Ligne et flèche utilisent le LQI 0–255 : rouge à 0, jaune à 128, vert à 255. Sans LQI, elles sont grises avec un contour noir. Une étiquette au bout de la flèche nomme une cible dans un autre espace ou absente du plan. Aucune donnée de route indique l’absence d’information univoque, pas un appareil hors ligne. Il s’agit des derniers relevés de l’intégration, pas du trajet des paquets en direct ni d’une garantie du chemin entier. Le cache ZHA peut déjà être ancien à la lecture ; les données anciennes et incomplètes sont signalées. Les liens ne sont visibles qu’à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau avec les routes peut prendre plus de 10 minutes. House Plan continue d’attendre en arrière-plan. L’analyse peut réduire temporairement la réactivité de Zigbee.",scan_connecting:"Connexion à Zigbee2MQTT",scan_waiting:"En attente de la carte de Zigbee2MQTT",scan_elapsed:"Temps écoulé : {time}",scan_background_hint:"Vous pouvez fermer les réglages — l’attente continuera en arrière-plan.",scan_long_wait:"Zigbee2MQTT n’a pas encore répondu. L’attente continue.",scan_cancel:"Annuler la demande",scan_cancel_hint:"Seule l’attente de House Plan est annulée. L’analyse de Zigbee2MQTT peut continuer.",scan_cancelled:"Attente annulée. L’analyse de Zigbee2MQTT n’est peut-être pas encore terminée.",scan_backend_required:"Mettez à jour l’intégration House Plan et redémarrez Home Assistant pour utiliser les analyses de carte en arrière-plan.",scan_connection_lost:"La connexion MQTT a été perdue ; le résultat n’est peut-être pas arrivé. L’attente est terminée, mais Zigbee2MQTT peut encore analyser le réseau.",scan_frontend_disconnected:"La connexion à Home Assistant a été interrompue. L’attente continue en arrière-plan. Reconnectez-vous pour retrouver l’état de la demande.",scan_unavailable:"MQTT n’est pas disponible ou n’est pas pris en charge dans Home Assistant.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · données incomplètes",status_no_links:"Reçu : {time} · aucune route confirmée",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace",route_device_not_on_plan_named:"Appareil absent du plan ({name})",route_unknown:"Aucune donnée de route",route_stale:"Données anciennes",route_partial:"Données incomplètes",status_cache:"Cache de l’intégration ; date de l’analyse radio inconnue"};const n="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";export{e as dictionary,n as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Affiche les parents et prochains sauts signalés par l’intégration. Le survol ne lance aucune requête.",help:"Affiche le parent d’un appareil terminal ou le prochain saut actif d’un routeur vers le coordinateur selon l’intégration. Il y a au plus une flèche sortante ; les flèches entrantes indiquent les appareils passant par ce nœud. Les simples voisins sont masqués. Toutes les lignes sont continues. Ligne et flèche utilisent le LQI 0–255 : rouge à 0, jaune à 128, vert à 255. Sans LQI, elles sont grises avec un contour noir. Une étiquette au bout de la flèche nomme une cible dans un autre espace ou absente du plan. Aucune donnée de route indique l’absence d’information univoque, pas un appareil hors ligne. Il s’agit des derniers relevés de l’intégration, pas du trajet des paquets en direct ni d’une garantie du chemin entier. Le cache ZHA peut déjà être ancien à la lecture ; les données anciennes et incomplètes sont signalées. Les liens ne sont visibles qu’à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau avec les routes peut prendre plus de 10 minutes. House Plan continue d’attendre en arrière-plan. L’analyse peut réduire temporairement la réactivité de Zigbee.",scan_connecting:"Connexion à Zigbee2MQTT",scan_waiting:"En attente de la carte de Zigbee2MQTT",scan_elapsed:"Temps écoulé : {time}",scan_background_hint:"Vous pouvez fermer les réglages — l’attente continuera en arrière-plan.",scan_long_wait:"Zigbee2MQTT n’a pas encore répondu. L’attente continue.",scan_cancel:"Annuler la demande",scan_cancel_hint:"Seule l’attente de House Plan est annulée. L’analyse de Zigbee2MQTT peut continuer.",scan_cancelled:"Attente annulée. L’analyse de Zigbee2MQTT n’est peut-être pas encore terminée.",scan_backend_required:"Mettez à jour l’intégration House Plan et redémarrez Home Assistant pour utiliser les analyses de carte en arrière-plan.",scan_connection_lost:"La connexion MQTT a été perdue ; le résultat n’est peut-être pas arrivé. L’attente est terminée, mais Zigbee2MQTT peut encore analyser le réseau.",scan_frontend_disconnected:"La connexion à Home Assistant a été interrompue. L’attente continue en arrière-plan. Reconnectez-vous pour retrouver l’état de la demande.",scan_unavailable:"MQTT n’est pas disponible ou n’est pas pris en charge dans Home Assistant.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · données incomplètes",status_no_links:"Reçu : {time} · aucune route confirmée",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace",route_other_space_named:"{space} ({name})",route_device_not_on_plan_named:"Appareil absent du plan ({name})",route_unknown:"Aucune donnée de route",route_stale:"Données anciennes",route_partial:"Données incomplètes",status_cache:"Cache de l’intégration ; date de l’analyse radio inconnue"};const n="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";export{e as dictionary,n as fingerprint};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+38
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dfcc8d349bf9e825c1f2b8c38c703453914f20306b5f585e37497fed58e68915";try{await import("./houseplan-assets/houseplan-card-D6V9pMgM.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="f6677941a27213f73ac2369b95804c6f631cd13289c874143a69fbb23a369342";try{await import("./houseplan-assets/open-spans-Co-2W4Ja.js");await import("./houseplan-assets/houseplan-card-BRGPGuZP.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -21,5 +21,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.80.0-beta.3"
|
||||
"version": "1.80.0-beta.8"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Read-only discovery and reversible archival of a previous installation.
|
||||
|
||||
Only the config flow calls this module, in HA's executor. Never use Store here:
|
||||
loading a Store may migrate/save data before the administrator makes a choice.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from .const import (
|
||||
ASSETS_DIR,
|
||||
DOMAIN,
|
||||
FILES_DIR,
|
||||
PLANS_DIR,
|
||||
STORAGE_CONFIG_KEY,
|
||||
STORAGE_KEY,
|
||||
STORAGE_MINOR_VERSION,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_VIRTUAL_LIGHTS_KEY,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
STORE_KEYS = (
|
||||
STORAGE_CONFIG_KEY, STORAGE_KEY, STORAGE_VIRTUAL_LIGHTS_KEY, f"{DOMAIN}.trails"
|
||||
)
|
||||
DATA_DIRS = (PLANS_DIR, FILES_DIR, ASSETS_DIR)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreviousData:
|
||||
"""One summary, shared by all menu placeholders."""
|
||||
|
||||
found: bool
|
||||
spaces: int | None
|
||||
files: int
|
||||
modified: float | None
|
||||
unsafe: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Inventory:
|
||||
sources: list[Path]
|
||||
files: list[tuple[Path, os.stat_result]]
|
||||
unsafe: bool = False
|
||||
|
||||
|
||||
class ArchiveError(Exception):
|
||||
"""Archive failed; a partial archive may need manual recovery."""
|
||||
|
||||
def __init__(self, archive_path: Path | None):
|
||||
super().__init__(f"Previous data archival failed (archive: {archive_path})")
|
||||
self.archive_path = archive_path
|
||||
|
||||
|
||||
def _lstat(path: Path) -> os.stat_result | None:
|
||||
try:
|
||||
return path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _inventory(root: Path) -> _Inventory:
|
||||
result = _Inventory([], [])
|
||||
# Do not even enumerate a linked parent: that would access an external tree.
|
||||
for parent in (root / ".storage", root / DOMAIN):
|
||||
info = _lstat(parent)
|
||||
if info is not None and not stat.S_ISDIR(info.st_mode):
|
||||
result.unsafe = True
|
||||
if result.unsafe:
|
||||
return result
|
||||
|
||||
def visit(path: Path, info: os.stat_result) -> None:
|
||||
if stat.S_ISDIR(info.st_mode):
|
||||
with os.scandir(path) as children:
|
||||
for child in children:
|
||||
visit(Path(child.path), child.stat(follow_symlinks=False))
|
||||
else:
|
||||
result.files.append((path, info))
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
result.unsafe = True
|
||||
|
||||
for key in STORE_KEYS:
|
||||
path = root / ".storage" / key
|
||||
info = _lstat(path)
|
||||
if info is not None:
|
||||
result.sources.append(path)
|
||||
result.files.append((path, info))
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
result.unsafe = True
|
||||
for name in DATA_DIRS:
|
||||
path = root / name
|
||||
info = _lstat(path)
|
||||
if info is not None:
|
||||
result.sources.append(path)
|
||||
visit(path, info)
|
||||
if not stat.S_ISDIR(info.st_mode):
|
||||
result.unsafe = True
|
||||
return result
|
||||
|
||||
|
||||
def _spaces(path: Path) -> int | None:
|
||||
if _lstat(path) is None:
|
||||
return 0
|
||||
try:
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
envelope = json.load(stream)
|
||||
if not isinstance(envelope, dict):
|
||||
return None
|
||||
version = envelope.get("version")
|
||||
minor = envelope.get("minor_version", 1)
|
||||
if (type(version) is not int or version != STORAGE_VERSION
|
||||
or type(minor) is not int or not 0 <= minor <= STORAGE_MINOR_VERSION):
|
||||
return None
|
||||
data = envelope.get("data")
|
||||
config = data.get("config") if isinstance(data, dict) else None
|
||||
spaces = config.get("spaces") if isinstance(config, dict) else None
|
||||
if not isinstance(spaces, list) or not all(isinstance(s, dict) for s in spaces):
|
||||
return None
|
||||
return len(spaces)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def inspect_previous_data(config_dir: str) -> PreviousData:
|
||||
"""Discover only fixed active paths, without writes or following symlinks."""
|
||||
root = Path(config_dir)
|
||||
inventory = _inventory(root)
|
||||
return PreviousData(
|
||||
found=bool(inventory.files) or inventory.unsafe,
|
||||
spaces=None if inventory.unsafe else _spaces(root / ".storage" / STORAGE_CONFIG_KEY),
|
||||
files=sum(path.parent != root / ".storage" for path, _ in inventory.files),
|
||||
modified=max((info.st_mtime for _, info in inventory.files), default=None),
|
||||
unsafe=inventory.unsafe,
|
||||
)
|
||||
|
||||
|
||||
def _archive_name() -> str:
|
||||
return f"{datetime.now(UTC):%Y%m%dT%H%M%S.%fZ}-{uuid4().hex}"
|
||||
|
||||
|
||||
def archive_previous_data(config_dir: str) -> Path | None:
|
||||
"""Rename the complete old set; rollback handled failures without overwrites.
|
||||
|
||||
This is not a crash-atomic transaction. If rollback itself fails, all bytes
|
||||
remain at their original paths or in the logged, retained partial archive.
|
||||
"""
|
||||
root = Path(config_dir)
|
||||
created: list[Path] = []
|
||||
moved: list[tuple[Path, Path]] = []
|
||||
archive: Path | None = None
|
||||
try:
|
||||
inventory = _inventory(root)
|
||||
archive_parent = root / DOMAIN / "archive"
|
||||
parent_info = _lstat(archive_parent)
|
||||
if inventory.unsafe or (parent_info is not None and not stat.S_ISDIR(parent_info.st_mode)):
|
||||
raise OSError(errno.EPERM, "Unsafe previous data paths")
|
||||
if not inventory.files:
|
||||
return None
|
||||
destination_parent = archive_parent
|
||||
while _lstat(destination_parent) is None:
|
||||
destination_parent = destination_parent.parent
|
||||
device = destination_parent.stat().st_dev
|
||||
if any(path.lstat().st_dev != device for path in inventory.sources) or any(
|
||||
info.st_dev != device for _, info in inventory.files
|
||||
):
|
||||
raise OSError(errno.EXDEV, "Archive and previous data must be on one filesystem")
|
||||
|
||||
for directory in (root / DOMAIN, archive_parent):
|
||||
if _lstat(directory) is None:
|
||||
directory.mkdir()
|
||||
created.append(directory)
|
||||
# Exclusive creation: never reuse/overwrite an old archive, even on a collision.
|
||||
archive = archive_parent / _archive_name()
|
||||
archive.mkdir()
|
||||
created.append(archive)
|
||||
storage = archive / "storage"
|
||||
storage.mkdir()
|
||||
created.append(storage)
|
||||
for source in inventory.sources:
|
||||
destination = (storage if source.parent == root / ".storage" else archive) / source.name
|
||||
source.rename(destination)
|
||||
moved.append((source, destination))
|
||||
return archive
|
||||
except OSError as error:
|
||||
rollback_failed = False
|
||||
for source, destination in reversed(moved):
|
||||
try:
|
||||
if _lstat(source) is not None:
|
||||
raise FileExistsError(source)
|
||||
destination.rename(source)
|
||||
except OSError:
|
||||
rollback_failed = True
|
||||
_LOGGER.exception("Cannot restore %s from partial House Plan archive %s", source, archive)
|
||||
if not rollback_failed:
|
||||
for directory in reversed(created):
|
||||
try:
|
||||
directory.rmdir() # Only our empty directories; never delete user files.
|
||||
except OSError:
|
||||
_LOGGER.exception("Cannot remove empty archive directory %s", directory)
|
||||
_LOGGER.error("House Plan archival failed; retained archive if any: %s: %s", archive, error)
|
||||
raise ArchiveError(archive) from error
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"previous_data": {
|
||||
"title": "Previous installation found",
|
||||
"description": "Found previous data: spaces — {spaces}, files — {files}, last modified — {modified}. {warning}\n\nStarting fresh moves all previous data to a permanent archive. No data is changed until you confirm the next form.",
|
||||
"menu_options": {
|
||||
"restore": "Restore previous plans",
|
||||
"start_fresh": "Start fresh"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": { "admin_only": "Only administrators may edit the layout" }
|
||||
@@ -9,7 +17,15 @@
|
||||
"create_entry": {
|
||||
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
|
||||
},
|
||||
"abort": { "single_instance_allowed": "Already configured — only one entry is allowed." }
|
||||
"error": {
|
||||
"previous_data_unreadable": "The previous configuration could not be read."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured — only one entry is allowed.",
|
||||
"already_in_progress": "Another House Plan setup is already in progress.",
|
||||
"previous_data_error": "Previous data could not be inspected. Check file permissions and the Home Assistant log; no data was changed.",
|
||||
"archive_failed": "Previous data could not be archived. No entry was created. Check the Home Assistant log for the archive path and recovery details."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
|
||||
@@ -156,6 +156,9 @@ def _global_settings(value: object) -> dict[str, Any]:
|
||||
show_room_tooltip = value.get("show_room_tooltip")
|
||||
if isinstance(show_room_tooltip, bool):
|
||||
out["show_room_tooltip"] = show_room_tooltip
|
||||
show_device_battery = value.get("show_device_battery")
|
||||
if isinstance(show_device_battery, bool) or show_device_battery == "low":
|
||||
out["show_device_battery"] = show_device_battery
|
||||
volumetric_view = value.get("volumetric_view")
|
||||
if isinstance(volumetric_view, bool):
|
||||
out["volumetric_view"] = volumetric_view
|
||||
@@ -286,6 +289,9 @@ def _project_marker(ids: _Pseudonyms, marker: dict[str, Any]) -> dict[str, Any]:
|
||||
"ripple_size", "size", "angle", "glow_radius_cm", "glow_color", "is_light",
|
||||
"use_climate_temp",
|
||||
)))
|
||||
hide_battery = marker.get("hide_battery")
|
||||
if isinstance(hide_battery, bool):
|
||||
out["hide_battery"] = hide_battery
|
||||
icon = marker.get("icon")
|
||||
if isinstance(icon, str) and _SAFE_ICON.fullmatch(icon):
|
||||
out["icon"] = icon
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"previous_data": {
|
||||
"title": "Frühere Installation gefunden",
|
||||
"description": "Frühere Daten gefunden: Bereiche — {spaces}, Dateien — {files}, zuletzt geändert — {modified}. {warning}\n\nEin Neuanfang verschiebt alle früheren Daten in ein dauerhaftes Archiv. Vor der Bestätigung des nächsten Formulars werden keine Daten geändert.",
|
||||
"menu_options": {
|
||||
"restore": "Frühere Pläne wiederherstellen",
|
||||
"start_fresh": "Neu beginnen"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
@@ -11,8 +19,14 @@
|
||||
"create_entry": {
|
||||
"panel_ready": "Öffnen Sie House Plan über die Seitenleiste, wenn der Eintrag verfügbar ist. Für die Bearbeitung wird ein Desktop-Browser empfohlen. Fehlt der Eintrag, verwenden Sie die House-Plan-Dashboard-Karte und prüfen Sie die Systeminformationen."
|
||||
},
|
||||
"error": {
|
||||
"previous_data_unreadable": "Die frühere Konfiguration konnte nicht gelesen werden."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig."
|
||||
"single_instance_allowed": "Bereits konfiguriert – es ist nur eine Instanz zulässig.",
|
||||
"already_in_progress": "Eine andere House-Plan-Einrichtung läuft bereits.",
|
||||
"previous_data_error": "Frühere Daten konnten nicht geprüft werden. Prüfen Sie Dateiberechtigungen und das Home-Assistant-Protokoll; es wurden keine Daten geändert.",
|
||||
"archive_failed": "Frühere Daten konnten nicht archiviert werden. Es wurde kein Eintrag erstellt. Archivpfad und Hinweise zur Wiederherstellung stehen im Home-Assistant-Protokoll."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"previous_data": {
|
||||
"title": "Previous installation found",
|
||||
"description": "Found previous data: spaces — {spaces}, files — {files}, last modified — {modified}. {warning}\n\nStarting fresh moves all previous data to a permanent archive. No data is changed until you confirm the next form.",
|
||||
"menu_options": {
|
||||
"restore": "Restore previous plans",
|
||||
"start_fresh": "Start fresh"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
@@ -11,8 +19,14 @@
|
||||
"create_entry": {
|
||||
"panel_ready": "Open House Plan from the sidebar when the item is available. A desktop browser is recommended for editing. If the item is missing, use the House Plan dashboard card and check System Health."
|
||||
},
|
||||
"error": {
|
||||
"previous_data_unreadable": "The previous configuration could not be read."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Already configured — only one entry is allowed."
|
||||
"single_instance_allowed": "Already configured — only one entry is allowed.",
|
||||
"already_in_progress": "Another House Plan setup is already in progress.",
|
||||
"previous_data_error": "Previous data could not be inspected. Check file permissions and the Home Assistant log; no data was changed.",
|
||||
"archive_failed": "Previous data could not be archived. No entry was created. Check the Home Assistant log for the archive path and recovery details."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"previous_data": {
|
||||
"title": "Installation précédente trouvée",
|
||||
"description": "Données précédentes trouvées : espaces — {spaces}, fichiers — {files}, dernière modification — {modified}. {warning}\n\nRepartir de zéro déplace les anciennes données dans une archive permanente. Aucune donnée ne change avant la confirmation du formulaire suivant.",
|
||||
"menu_options": {
|
||||
"restore": "Restaurer les plans précédents",
|
||||
"start_fresh": "Repartir de zéro"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
@@ -11,8 +19,14 @@
|
||||
"create_entry": {
|
||||
"panel_ready": "Ouvrez House Plan depuis la barre latérale lorsque l’entrée est disponible. Un navigateur sur ordinateur est recommandé pour la modification. Si l’entrée est absente, utilisez la carte House Plan du tableau de bord et consultez les informations système."
|
||||
},
|
||||
"error": {
|
||||
"previous_data_unreadable": "La configuration précédente n’a pas pu être lue."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée."
|
||||
"single_instance_allowed": "Déjà configuré — une seule entrée est autorisée.",
|
||||
"already_in_progress": "Une autre configuration House Plan est déjà en cours.",
|
||||
"previous_data_error": "Impossible de vérifier les anciennes données. Vérifiez les permissions des fichiers et le journal Home Assistant ; aucune donnée n’a été modifiée.",
|
||||
"archive_failed": "Impossible d’archiver les anciennes données. Aucune entrée n’a été créée. Consultez le journal Home Assistant pour le chemin de l’archive et les instructions de récupération."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"previous_data": {
|
||||
"title": "Найдены данные прежней установки",
|
||||
"description": "Найдены прежние данные: пространств — {spaces}, файлов — {files}, последнее изменение — {modified}. {warning}\n\n«Начать с чистого листа» перенесёт прежние данные в постоянный архив. До подтверждения следующей формы данные не изменятся.",
|
||||
"menu_options": {
|
||||
"restore": "Восстановить прежние планы",
|
||||
"start_fresh": "Начать с чистого листа"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "House Plan",
|
||||
"data": {
|
||||
@@ -11,8 +19,14 @@
|
||||
"create_entry": {
|
||||
"panel_ready": "Откройте House Plan в боковом меню, если пункт появился. Для редактирования рекомендуется браузер на компьютере. Если пункта нет, используйте карточку House Plan на панели и проверьте информацию о системе."
|
||||
},
|
||||
"error": {
|
||||
"previous_data_unreadable": "Данные прежнего конфига не удалось прочитать."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Уже настроено — допускается одна запись."
|
||||
"single_instance_allowed": "Уже настроено — допускается одна запись.",
|
||||
"already_in_progress": "Другой мастер установки House Plan уже открыт.",
|
||||
"previous_data_error": "Не удалось проверить прежние данные. Проверьте права на файлы и журнал Home Assistant; данные не изменились.",
|
||||
"archive_failed": "Не удалось перенести прежние данные в архив. Запись не создана. Путь архива и сведения для восстановления — в журнале Home Assistant."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -1402,6 +1402,9 @@ def _north_deg(value):
|
||||
|
||||
_BG_MODE = vol.In(["static", "daynight"])
|
||||
_SUN_RAY_ORIGIN = vol.In(["inner", "outer"])
|
||||
# #792/#807: absent = every battery device, exact False = none, "low" = only
|
||||
# the red low state. Strings other than "low" and truthy numbers are refused.
|
||||
_DEVICE_BATTERY = vol.Any(bool, "low")
|
||||
|
||||
SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -2000,6 +2003,8 @@ MARKER_SCHEMA = vol.Schema(
|
||||
),
|
||||
# climate current_temperature: badge + room-average vote (off unless True)
|
||||
vol.Optional("use_climate_temp"): vol.Any(bool, None),
|
||||
# Per-marker battery indicator override; absence/False keeps global policy.
|
||||
vol.Optional("hide_battery"): bool,
|
||||
vol.Optional("room_id"): vol.Any(str, None),
|
||||
# Keep in sync with DISPLAY_MODES in src/logic.ts. `ripple` is no longer
|
||||
# offered, but remains accepted while old stores migrate to icon_ripple.
|
||||
@@ -2321,6 +2326,7 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("sun_ray_origin"): _SUN_RAY_ORIGIN,
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
vol.Optional("show_device_battery"): _DEVICE_BATTERY,
|
||||
# #649: installation-wide 2.5D View; absent/false keeps Flat.
|
||||
vol.Optional("volumetric_view"): bool,
|
||||
# #661: the moon on the "Follow the Sun" background; absent/false = off.
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""#803: server-owned local node-move proof, independent of browser claims.
|
||||
|
||||
No arbitrary candidate, allow-rehost flag or persisted node graph is accepted.
|
||||
Undo proves the same forward operation from its proposed baseline to the exact
|
||||
current space before allowing the inverse (including a transverse X split).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from .coordinate_canonicalization import canonicalize_config_geometry
|
||||
from .wall_segment_model import _wall_key, commit_wall_segment_model
|
||||
|
||||
EPS = 1e-9
|
||||
DIRECTION_EPS = math.sqrt(2 * EPS - EPS * EPS)
|
||||
GRID = 1 / 240
|
||||
|
||||
|
||||
class NodeMoveError(ValueError):
|
||||
"""Stable fail-closed protocol error."""
|
||||
|
||||
def __init__(self, reason: str = "invalid") -> None:
|
||||
self.code = f"node_move_{reason}"
|
||||
super().__init__(self.code)
|
||||
|
||||
|
||||
def _sub(a, b):
|
||||
return [a[0] - b[0], a[1] - b[1]]
|
||||
|
||||
|
||||
def _dot(a, b):
|
||||
return a[0] * b[0] + a[1] * b[1]
|
||||
|
||||
|
||||
def _cross(a, b):
|
||||
return a[0] * b[1] - a[1] * b[0]
|
||||
|
||||
|
||||
def _parallel(a, b):
|
||||
"""Shared angular predicate, never the positional EPS."""
|
||||
return abs(_cross(a, b)) <= DIRECTION_EPS
|
||||
|
||||
|
||||
def _length(a, b):
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
|
||||
def _same(a, b):
|
||||
return _length(a, b) <= EPS
|
||||
|
||||
|
||||
def _unit(a):
|
||||
span = math.hypot(*a)
|
||||
return [a[0] / span, a[1] / span]
|
||||
|
||||
|
||||
def _fraction(p, a, b):
|
||||
d = _sub(b, a)
|
||||
return _dot(_sub(p, a), d) / _dot(d, d)
|
||||
|
||||
|
||||
def _at(a, b, t):
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
|
||||
|
||||
def _on(p, w):
|
||||
d = _sub(w["b"], w["a"])
|
||||
t = _fraction(p, w["a"], w["b"])
|
||||
return abs(_cross(_sub(p, w["a"]), d)) <= EPS * math.hypot(*d) and -EPS <= t <= 1 + EPS
|
||||
|
||||
|
||||
def _ref(w):
|
||||
return f'{w["kind"]}:{w["id"]}'
|
||||
|
||||
|
||||
def _walls(space):
|
||||
return [dict(w, kind=kind) for collection, kind in [("wall_segments", "wall"), ("partitions", "partition")]
|
||||
for w in space.get(collection, []) if _length(w["a"], w["b"]) > EPS]
|
||||
|
||||
|
||||
def _intersection(a, b):
|
||||
da, db = _sub(a["b"], a["a"]), _sub(b["b"], b["a"])
|
||||
den = _cross(da, db)
|
||||
if abs(den) <= EPS * math.hypot(*da) * math.hypot(*db):
|
||||
return None
|
||||
t, u = _cross(_sub(b["a"], a["a"]), db) / den, _cross(_sub(b["a"], a["a"]), da) / den
|
||||
return _at(a["a"], a["b"], t) if -EPS <= t <= 1 + EPS and -EPS <= u <= 1 + EPS else None
|
||||
|
||||
|
||||
def _nodes(walls):
|
||||
points = [p for w in walls for p in [w["a"], w["b"]]]
|
||||
for i, a in enumerate(walls):
|
||||
for b in walls[i + 1:]:
|
||||
p = _intersection(a, b)
|
||||
if p is not None:
|
||||
points.append(p)
|
||||
unique = {}
|
||||
for p in points:
|
||||
unique[(round(p[0], 9), round(p[1], 9))] = p
|
||||
return list(unique.values())
|
||||
|
||||
|
||||
def _classify(walls, p):
|
||||
incident = sorted((w for w in walls if _on(p, w)), key=_ref)
|
||||
axes = []
|
||||
for w in incident:
|
||||
d = _unit(_sub(w["b"], w["a"]))
|
||||
if d[0] < -EPS or (abs(d[0]) <= EPS and d[1] < 0):
|
||||
d = [-d[0], -d[1]]
|
||||
axis = next((a for a in axes if _parallel(a["direction"], d)), None)
|
||||
if axis is None:
|
||||
axis = {"direction": d, "walls": [], "rays": []}
|
||||
axes.append(axis)
|
||||
axis["walls"].append(w)
|
||||
for end in [w["a"], w["b"]]:
|
||||
if not _same(end, p):
|
||||
ray = _unit(_sub(end, p))
|
||||
if not any(_parallel(r, ray) and _dot(r, ray) > 0 for r in axis["rays"]):
|
||||
axis["rays"].append(ray)
|
||||
for a in axes:
|
||||
a["walls"].sort(key=_ref)
|
||||
a["key"] = _ref(a["walls"][0])
|
||||
# An axis cannot borrow an opposite ray from a different axis.
|
||||
a["passing"] = any(_dot(r, a["direction"]) > 0 for r in a["rays"]) \
|
||||
and any(_dot(r, a["direction"]) < 0 for r in a["rays"])
|
||||
valence = sum(len(a["rays"]) for a in axes)
|
||||
passing = sum(a["passing"] for a in axes)
|
||||
branches = valence - 2 * passing
|
||||
if not (valence <= 6 and ((passing == 0 and branches >= 1)
|
||||
or (passing == 1 and branches <= 1) or (passing == 2 and branches == 0))):
|
||||
raise NodeMoveError("unsupported_junction")
|
||||
return incident, axes, passing
|
||||
|
||||
|
||||
def _point(value):
|
||||
if not isinstance(value, list) or len(value) != 2 \
|
||||
or any(isinstance(v, bool) or not isinstance(v, (int, float))
|
||||
or not math.isfinite(v) or abs(v) > 5000 for v in value):
|
||||
raise NodeMoveError()
|
||||
return value
|
||||
|
||||
|
||||
def move_node_space(source: dict[str, Any], intent: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Apply only the proven incident edit. Foreign records are copied intact."""
|
||||
if set(intent) != {"point", "target", "axis", "split_ids"}:
|
||||
raise NodeMoveError()
|
||||
p, target = _point(intent["point"]), _point(intent["target"])
|
||||
if intent["axis"] is not None and not isinstance(intent["axis"], str):
|
||||
raise NodeMoveError()
|
||||
split_ids = intent["split_ids"]
|
||||
if not isinstance(split_ids, dict) or len(split_ids) > 2 \
|
||||
or any(not isinstance(k, str) or not isinstance(v, str) or not v or len(v) > 256
|
||||
for k, v in split_ids.items()):
|
||||
raise NodeMoveError()
|
||||
walls = _walls(source)
|
||||
nodes = _nodes(walls)
|
||||
if not any(_same(p, n) for n in nodes):
|
||||
raise NodeMoveError()
|
||||
incident, axes, passing = _classify(walls, p)
|
||||
if _same(p, target):
|
||||
return copy.deepcopy(source)
|
||||
on_grid = all(abs(v / GRID - math.floor(v / GRID + 0.5)) <= 0.0001 for v in target)
|
||||
constrained = any(abs(_cross(_sub(target, p), a["direction"])) <= EPS for a in axes) \
|
||||
or any(not _same(end, p) and (abs(end[0] - target[0]) <= EPS or abs(end[1] - target[1]) <= EPS)
|
||||
for w in incident for end in [w["a"], w["b"]])
|
||||
if not on_grid and not constrained:
|
||||
raise NodeMoveError()
|
||||
carrier = next((a for a in axes if a["passing"] and a["key"] == intent["axis"]), None) if passing else None
|
||||
if passing and carrier is None:
|
||||
raise NodeMoveError()
|
||||
if carrier:
|
||||
d = carrier["direction"]
|
||||
if abs(_cross(_sub(target, p), d)) > EPS:
|
||||
raise NodeMoveError()
|
||||
positions = [_dot(_sub(end, p), d) for w in carrier["walls"] for end in [w["a"], w["b"]] if not _same(end, p)]
|
||||
wanted = _dot(_sub(target, p), d)
|
||||
lo, hi = max(v for v in positions if v < -EPS), min(v for v in positions if v > EPS)
|
||||
if not lo + EPS < wanted < hi - EPS:
|
||||
raise NodeMoveError()
|
||||
for other in nodes:
|
||||
if not _same(p, other) and abs(_cross(_sub(other, p), d)) <= EPS:
|
||||
t = _dot(_sub(other, p), d)
|
||||
if abs(t) > EPS and t * wanted > 0 and abs(t) <= abs(wanted) + EPS:
|
||||
raise NodeMoveError()
|
||||
if carrier:
|
||||
swept_lo, swept_hi = min(0, wanted), max(0, wanted)
|
||||
for opening in source.get("openings", []):
|
||||
host = opening.get("host")
|
||||
wall = next((w for w in carrier["walls"] if host and _ref(w) == f'{host["kind"]}:{host["id"]}'), None)
|
||||
if wall is None:
|
||||
continue
|
||||
center = _dot(_sub(_at(wall["a"], wall["b"], host["t"]), p), d)
|
||||
margin = opening["length"] / 2 + wall["cm"] / (source.get("cell_cm") or 5) * GRID / 2
|
||||
if center + margin >= swept_lo - EPS and center - margin <= swept_hi + EPS:
|
||||
raise NodeMoveError("opening_blocked")
|
||||
out = copy.deepcopy(source)
|
||||
replacements = {}
|
||||
used = {item.get("id") for collection in ("rooms", "openings", "decor", "stairs", "wall_columns", "wall_segments", "partitions")
|
||||
for item in source.get(collection, [])}
|
||||
for old in incident:
|
||||
ref = _ref(old)
|
||||
stays = carrier and any(_ref(w) == ref for w in carrier["walls"])
|
||||
a_moves, b_moves = _same(old["a"], p), _same(old["b"], p)
|
||||
if not a_moves and not b_moves and stays:
|
||||
replacements[ref] = [old]
|
||||
elif a_moves or b_moves:
|
||||
replacements[ref] = [dict(old, a=target[:] if a_moves else old["a"], b=target[:] if b_moves else old["b"])]
|
||||
else:
|
||||
new_id = split_ids.get(ref)
|
||||
if new_id is None or new_id in used:
|
||||
raise NodeMoveError()
|
||||
used.add(new_id)
|
||||
first_keeps = _fraction(p, old["a"], old["b"]) >= 0.5 - EPS
|
||||
replacements[ref] = [dict(old, id=old["id"] if first_keeps else new_id, b=target[:]),
|
||||
dict(old, id=new_id if first_keeps else old["id"], a=target[:])]
|
||||
if any(_length(w["a"], w["b"]) <= EPS for children in replacements.values() for w in children):
|
||||
raise NodeMoveError()
|
||||
for collection, kind in [("wall_segments", "wall"), ("partitions", "partition")]:
|
||||
if collection in out:
|
||||
out[collection] = [{**w, "id": child["id"], "a": child["a"][:], "b": child["b"][:]}
|
||||
for w in out[collection] for child in replacements.get(f'{kind}:{w["id"]}', [w])]
|
||||
incident_by_ref = {_ref(w): w for w in incident}
|
||||
for room in out.get("rooms", []):
|
||||
if not room.get("poly") or len(room.get("wall_ids", [])) != len(room["poly"]):
|
||||
raise NodeMoveError()
|
||||
poly, ids = [], []
|
||||
for i, old_point in enumerate(room["poly"]):
|
||||
old_id = room["wall_ids"][i]
|
||||
ref = f"wall:{old_id}"
|
||||
children = replacements.get(ref)
|
||||
if children is None:
|
||||
poly.append(old_point)
|
||||
ids.append(old_id)
|
||||
continue
|
||||
forward = _same(old_point, incident_by_ref[ref]["a"])
|
||||
for child in children if forward else reversed(children):
|
||||
poly.append(child["a"][:] if forward else child["b"][:])
|
||||
ids.append(child["id"])
|
||||
room["poly"], room["wall_ids"] = poly, ids
|
||||
old_by_ref = {_ref(w): w for w in walls}
|
||||
after = _walls(out)
|
||||
changed_ids = {_ref(w) for children in replacements.values() for w in children}
|
||||
parent_refs = {_ref(w): parent for parent, children in replacements.items() for w in children}
|
||||
checked = set()
|
||||
for a in after:
|
||||
if _ref(a) not in changed_ids:
|
||||
continue
|
||||
for b in after:
|
||||
pair = tuple(sorted([_ref(a), _ref(b)]))
|
||||
if a is b or pair in checked:
|
||||
continue
|
||||
checked.add(pair)
|
||||
old_a, old_b = old_by_ref.get(_ref(a)), old_by_ref.get(_ref(b))
|
||||
hit = _intersection(a, b)
|
||||
if hit is not None and _same(hit, target) and (
|
||||
parent_refs.get(_ref(a), _ref(a)) not in incident_by_ref
|
||||
or parent_refs.get(_ref(b), _ref(b)) not in incident_by_ref):
|
||||
raise NodeMoveError()
|
||||
if hit is not None and not _same(hit, target):
|
||||
previous = _intersection(old_a, old_b) if old_a and old_b else None
|
||||
if previous is None or not _same(hit, previous):
|
||||
raise NodeMoveError()
|
||||
if hit is None and abs(_cross(_sub(a["b"], a["a"]), _sub(b["b"], b["a"]))) <= EPS:
|
||||
if any(_same(end, target) and _on(end, b) for end in [a["a"], a["b"]]) and (
|
||||
parent_refs.get(_ref(a), _ref(a)) not in incident_by_ref
|
||||
or parent_refs.get(_ref(b), _ref(b)) not in incident_by_ref):
|
||||
raise NodeMoveError()
|
||||
for end in [a["a"], a["b"]]:
|
||||
if _on(end, b) and not _same(end, target) and (not old_a or not old_b
|
||||
or not any(_same(q, end) and _on(q, old_b) for q in [old_a["a"], old_a["b"]])):
|
||||
raise NodeMoveError()
|
||||
for opening in out.get("openings", []):
|
||||
host = opening.get("host")
|
||||
if not host:
|
||||
if any(_on([opening["x"], opening["y"]], w) for w in incident):
|
||||
raise NodeMoveError("opening_blocked")
|
||||
continue
|
||||
ref = f'{host["kind"]}:{host["id"]}'
|
||||
children = replacements.get(ref)
|
||||
if children is None:
|
||||
continue
|
||||
old = incident_by_ref[ref]
|
||||
if len(children) == 1 and _same(children[0]["a"], old["a"]) and _same(children[0]["b"], old["b"]):
|
||||
continue
|
||||
old_span, half, t = _length(old["a"], old["b"]), opening["length"] / 2, host["t"]
|
||||
along, split_along = t * old_span, _fraction(p, old["a"], old["b"]) * old_span
|
||||
child = children[0]
|
||||
if len(children) == 2:
|
||||
if along - half < split_along + EPS and along + half > split_along - EPS:
|
||||
raise NodeMoveError("opening_blocked")
|
||||
first = along + half <= split_along
|
||||
child = children[0 if first else 1]
|
||||
span = _length(child["a"], child["b"])
|
||||
t = along / span if first else 1 - (old_span - along) / span
|
||||
elif carrier and any(_ref(w) == ref for w in carrier["walls"]):
|
||||
t = _fraction([opening["x"], opening["y"]], child["a"], child["b"])
|
||||
else:
|
||||
span = _length(child["a"], child["b"])
|
||||
if span <= EPS:
|
||||
raise NodeMoveError()
|
||||
t = 1 - (1 - host["t"]) * old_span / span if _same(old["a"], p) else host["t"] * old_span / span
|
||||
span = _length(child["a"], child["b"])
|
||||
jamb = child["cm"] / (source.get("cell_cm") or 5) * GRID / 2
|
||||
if span <= EPS or t * span - half < jamb - EPS or t * span + half > span - jamb + EPS:
|
||||
raise NodeMoveError("opening_blocked")
|
||||
opening["x"], opening["y"] = _at(child["a"], child["b"], t)
|
||||
opening["host"] = {**host, "id": child["id"], "t": t}
|
||||
angle = math.degrees(math.atan2(child["b"][1] - child["a"][1], child["b"][0] - child["a"][0]))
|
||||
opening["angle"] = angle - 180 if angle >= 90 else angle + 180 if angle < -90 else angle
|
||||
if "walls" in source:
|
||||
out["walls"] = [{"key": _wall_key(w["a"], w["b"]), "cm": w["cm"], "a": w["a"][:], "b": w["b"][:]}
|
||||
for w in out.get("wall_segments", []) if w["cm"] > 0]
|
||||
return out
|
||||
|
||||
|
||||
def node_move_host_baseline(current: dict[str, Any], candidate: dict[str, Any], space_id: str,
|
||||
intent: dict[str, Any], direction: str = "apply",
|
||||
before_space: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Independent identity ledger; candidate host IDs never authorize themselves.
|
||||
|
||||
Only an opening wholly on a transverse whole-X child may change its host ID.
|
||||
Derive that child from ORIGINAL interval/midpoint lineage and frozen carrier,
|
||||
not from move_node_space's replacements or returned opening. For Undo the
|
||||
first proof establishes before_space; this ledger checks the inverse IDs.
|
||||
Returning an identity-adjusted baseline leaves strict jamb validation intact.
|
||||
"""
|
||||
# The durable legacy record may not yet contain a structural catalogue.
|
||||
# Normalize a COPY with the same read-compatible identity model as the first
|
||||
# proof; raw legacy absence is not evidence of a missing node or new host.
|
||||
current, _ = commit_wall_segment_model(current)
|
||||
old_spaces = {s["id"]: s for s in current.get("spaces", [])}
|
||||
new_spaces = {s["id"]: s for s in candidate.get("spaces", [])}
|
||||
if old_spaces.keys() != new_spaces.keys() or direction not in {"apply", "undo"}:
|
||||
raise NodeMoveError()
|
||||
source = before_space if direction == "undo" else old_spaces.get(space_id)
|
||||
if source is None or source.get("id") != space_id:
|
||||
raise NodeMoveError()
|
||||
p = intent["point"]
|
||||
incident, axes, _ = _classify(_walls(source), p)
|
||||
carrier = next((a for a in axes if a["passing"] and a["key"] == intent["axis"]), None)
|
||||
carrier_refs = {_ref(w) for w in carrier["walls"]} if carrier else set()
|
||||
transverse = {_ref(w): w for w in incident if _ref(w) not in carrier_refs
|
||||
and not _same(w["a"], p) and not _same(w["b"], p)}
|
||||
baseline = copy.deepcopy(current)
|
||||
|
||||
def identity(opening):
|
||||
host = opening.get("host")
|
||||
return (host["kind"], host["id"]) if host else None
|
||||
|
||||
for old_space, adjusted in zip(current.get("spaces", []), baseline.get("spaces", []), strict=True):
|
||||
sid = old_space["id"]
|
||||
original = source if sid == space_id else old_space
|
||||
old = {o["id"]: o for o in old_space.get("openings", [])}
|
||||
new = {o["id"]: o for o in new_spaces[sid].get("openings", [])}
|
||||
originals = {o["id"]: o for o in original.get("openings", [])}
|
||||
if old.keys() != new.keys() or old.keys() != originals.keys():
|
||||
raise NodeMoveError()
|
||||
adjusted_openings = {o["id"]: o for o in adjusted.get("openings", [])}
|
||||
for oid, opening in originals.items():
|
||||
initial = identity(opening)
|
||||
forward = initial
|
||||
wall = transverse.get(f'{initial[0]}:{initial[1]}') if initial and sid == space_id else None
|
||||
if wall and not _same(p, intent["target"]):
|
||||
span = _length(wall["a"], wall["b"])
|
||||
split = _fraction(p, wall["a"], wall["b"])
|
||||
along, half = opening["host"]["t"] * span, opening["length"] / 2
|
||||
if along - half < split * span + EPS and along + half > split * span - EPS:
|
||||
raise NodeMoveError("opening_blocked")
|
||||
first = along + half <= split * span
|
||||
keeps_parent = first == (split >= 0.5 - EPS)
|
||||
child_id = wall["id"] if keeps_parent else intent["split_ids"].get(_ref(wall))
|
||||
if not child_id:
|
||||
raise NodeMoveError()
|
||||
forward = (initial[0], child_id)
|
||||
expected_old, expected_new = (forward, initial) if direction == "undo" else (initial, forward)
|
||||
if identity(old[oid]) != expected_old or identity(new[oid]) != expected_new:
|
||||
raise NodeMoveError()
|
||||
if expected_new:
|
||||
adjusted_openings[oid]["host"]["id"] = expected_new[1]
|
||||
return baseline
|
||||
|
||||
|
||||
def node_move_candidate(current: dict[str, Any], space_id: str, intent: dict[str, Any],
|
||||
direction: str = "apply", before_space: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Independently prove the forward delta; inverse has no broader authority."""
|
||||
baseline, _ = commit_wall_segment_model(current)
|
||||
space = next((s for s in baseline.get("spaces", []) if s["id"] == space_id), None)
|
||||
if space is None or direction not in {"apply", "undo"}:
|
||||
raise NodeMoveError()
|
||||
if direction == "undo":
|
||||
if before_space is None or before_space.get("id") != space_id:
|
||||
raise NodeMoveError()
|
||||
proposed = copy.deepcopy(baseline)
|
||||
proposed["spaces"] = [before_space if s["id"] == space_id else s for s in proposed["spaces"]]
|
||||
# Proof has to reach exactly the current canonical document, including
|
||||
# every foreign field. A forged baseline cannot move a distal endpoint,
|
||||
# rehost another opening, alter metadata or change another floor.
|
||||
forward = move_node_space(before_space, intent)
|
||||
proof = copy.deepcopy(baseline)
|
||||
proof["spaces"] = [forward if s["id"] == space_id else s for s in proof["spaces"]]
|
||||
proof, _ = commit_wall_segment_model(proof)
|
||||
if canonicalize_config_geometry(proof) != canonicalize_config_geometry(baseline):
|
||||
raise NodeMoveError()
|
||||
candidate, _ = commit_wall_segment_model(proposed)
|
||||
return candidate
|
||||
moved = move_node_space(space, intent)
|
||||
baseline["spaces"] = [moved if s["id"] == space_id else s for s in baseline["spaces"]]
|
||||
candidate, _ = commit_wall_segment_model(baseline)
|
||||
return candidate
|
||||
@@ -137,6 +137,7 @@ from .virtual_lights import (
|
||||
async_toggle_virtual_light,
|
||||
async_virtual_light_snapshot,
|
||||
)
|
||||
from .wall_node_move import NodeMoveError, node_move_candidate, node_move_host_baseline
|
||||
from .wall_segment_model import (
|
||||
WALL_SEGMENT_MODEL_VERSION,
|
||||
WallSegmentMigrationError,
|
||||
@@ -270,6 +271,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_config_get)
|
||||
websocket_api.async_register_command(hass, ws_virtual_light_toggle)
|
||||
websocket_api.async_register_command(hass, ws_config_set)
|
||||
websocket_api.async_register_command(hass, ws_wall_node_move)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize)
|
||||
websocket_api.async_register_command(hass, ws_plan_optimize_undo)
|
||||
websocket_api.async_register_command(hass, ws_space_delete)
|
||||
@@ -1333,49 +1335,59 @@ async def ws_files_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
from .const import FILES_DIR
|
||||
from .plans import attachment_refs
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(msg["marker_id"])
|
||||
base = Path(hass.config.path(FILES_DIR)).resolve()
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": 0, "kept": 0})
|
||||
return
|
||||
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
async with rt.write_lock:
|
||||
stored = await rt.config_store.async_load() or {}
|
||||
refs = attachment_refs(stored.get("config") or {})
|
||||
removed, kept = await hass.async_add_executor_job(
|
||||
_drop_unreferenced_marker_files, files_dir, msg["marker_id"], refs,
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
|
||||
def _rm() -> tuple[int, int]:
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return removed, kept
|
||||
|
||||
removed, kept = await hass.async_add_executor_job(_rm)
|
||||
if kept:
|
||||
def _drop_unreferenced_marker_files(
|
||||
files_dir: Path, marker_id: str, refs: set[str],
|
||||
) -> tuple[int, int]:
|
||||
"""Remove the files of ``<files>/<marker>`` the stored config does not name.
|
||||
|
||||
The one rule behind ``houseplan/files/cleanup`` and behind the markers a
|
||||
space delete takes with it (#819). Runs in the executor; returns
|
||||
``(removed, kept)``. An id that does not name a folder inside the files
|
||||
directory touches nothing.
|
||||
"""
|
||||
from .validation import sanitize_marker_id
|
||||
|
||||
mid = sanitize_marker_id(marker_id)
|
||||
base = files_dir.resolve()
|
||||
target = (base / mid).resolve() if mid else base
|
||||
if not mid or not str(target).startswith(str(base)) or target == base:
|
||||
return 0, 0
|
||||
if not target.is_dir():
|
||||
return 0, 0
|
||||
removed = kept = 0
|
||||
for item in sorted(target.iterdir()):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if f"{mid}/{item.name}" in refs:
|
||||
kept += 1
|
||||
continue
|
||||
try:
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not remove %s: %s", item, err)
|
||||
if not kept:
|
||||
try:
|
||||
target.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"House Plan: kept %s file(s) in %s — the configuration still references them", kept, mid
|
||||
)
|
||||
connection.send_result(msg["id"], {"ok": True, "removed": removed, "kept": kept})
|
||||
return removed, kept
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -1480,6 +1492,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"integration_version": VERSION,
|
||||
# #423: protocol capability is independent from release skew.
|
||||
"support_api": SUPPORT_API_VERSION,
|
||||
"wall_node_move_api": 1,
|
||||
"decor_assets_api": DECOR_ASSETS_API_VERSION,
|
||||
"summary_panel_api": SUMMARY_PANEL_API_VERSION,
|
||||
**({"radar_stage1_api": 1} if rt.radar_coordinator is not None else {}),
|
||||
@@ -1808,6 +1821,81 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/wall/node_move",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("expected_rev"): int,
|
||||
vol.Required("intent"): dict,
|
||||
vol.Optional("direction", default="apply"): vol.In(["apply", "undo"]),
|
||||
vol.Optional("before_space"): dict,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_wall_node_move(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
"""Narrow local node edit and its proved inverse; ordinary set stays strict."""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Not allowed to edit the plan")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
if len(json.dumps(msg, separators=(",", ":"))) > MAX_CONFIG_BYTES:
|
||||
connection.send_error(msg["id"], "too_large", "Node operation exceeds the configuration limit")
|
||||
return
|
||||
async with rt.write_lock:
|
||||
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
|
||||
if resolved is None:
|
||||
return
|
||||
data = resolved.config_data
|
||||
rev = int(data.get("rev", 0))
|
||||
if msg["expected_rev"] != rev:
|
||||
connection.send_error(msg["id"], "conflict", "Plan revision changed; reload before moving a node")
|
||||
return
|
||||
previous = data.get("config", {})
|
||||
|
||||
def _validate_node_cpu():
|
||||
candidate = node_move_candidate(previous, msg["space_id"], msg["intent"],
|
||||
msg["direction"], msg.get("before_space"))
|
||||
validate_wall_model_transition(candidate, previous)
|
||||
candidate = CONFIG_SCHEMA(candidate)
|
||||
validate_opening_passages(candidate, previous)
|
||||
# A separate original-geometry ledger proves the allowed host IDs;
|
||||
# it never copies authority from the candidate. Then strict jambs.
|
||||
proved_baseline = node_move_host_baseline(previous, candidate, msg["space_id"],
|
||||
msg["intent"], msg["direction"], msg.get("before_space"))
|
||||
validate_partition_opening_hosts(candidate, proved_baseline)
|
||||
counts = validate_junction_limits(candidate, previous)
|
||||
return candidate, counts
|
||||
|
||||
try:
|
||||
candidate, counts = await hass.async_add_executor_job(_validate_node_cpu)
|
||||
except (NodeMoveError, JunctionLimitError, WallModelClientOutdatedError,
|
||||
OpeningPassageError, PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError) as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except (vol.Invalid, WallSegmentMigrationError, ValueError, KeyError,
|
||||
TypeError, ZeroDivisionError) as err:
|
||||
connection.send_error(msg["id"], NodeMoveError().code, str(err))
|
||||
return
|
||||
if candidate == previous:
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": rev, "config": candidate})
|
||||
return
|
||||
if len(json.dumps(candidate, separators=(",", ":"))) > MAX_CONFIG_BYTES:
|
||||
connection.send_error(msg["id"], "too_large", "Result exceeds the configuration limit")
|
||||
return
|
||||
new_rev = rev + 1
|
||||
await async_save_config_state(rt, candidate, new_rev, previous_rev=rev)
|
||||
rt.junction_baseline = (new_rev, counts)
|
||||
try:
|
||||
await _discard_optimizer_snapshot(rt)
|
||||
except Exception: # noqa: BLE001 - durable write already accepted
|
||||
_LOGGER.exception("House Plan: discarding stale optimization backup failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev, "config": candidate})
|
||||
|
||||
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
|
||||
@@ -1869,6 +1957,16 @@ def _space_delete_candidate(
|
||||
if isinstance(stair, dict) and stair.get("target_space_id") == space_id:
|
||||
stair["target_space_id"] = None
|
||||
for marker in candidate_config.get("markers") or []:
|
||||
# #162/#822: fulfil the confirmation in the same authoritative pair.
|
||||
vacuum = marker.get("vacuum")
|
||||
routes = vacuum.get("map_routes") if isinstance(vacuum, dict) else None
|
||||
if isinstance(routes, list):
|
||||
kept_routes = [
|
||||
route for route in routes
|
||||
if not isinstance(route, dict) or route.get("space") != space_id
|
||||
]
|
||||
if len(kept_routes) != len(routes):
|
||||
vacuum["map_routes"] = kept_routes
|
||||
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
|
||||
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
|
||||
references_deleted_space = (
|
||||
@@ -1902,12 +2000,133 @@ def _space_delete_candidate(
|
||||
return candidate_config, candidate_layout, dependencies, removed_layout
|
||||
|
||||
|
||||
def _delete_plan_markers(
|
||||
config: dict[str, Any], layout: dict[str, Any], marker_ids: list[str],
|
||||
) -> list[str]:
|
||||
"""Delete markers the way the device dialog's «Delete» does (#819).
|
||||
|
||||
Mirror of the card's ``deletePlanMarkerRecords`` and the cleanup around
|
||||
it (``src/space-deletion.ts`` ``deletePlanMarkers``; the shared fixture
|
||||
``test/fixtures/space-delete-with-markers.json`` pins both): a real binding
|
||||
drops every marker of that binding and leaves a hidden tombstone of the
|
||||
exact binding, a virtual marker simply goes; links to the dropped ids in
|
||||
other markers' ``controls``, LED strips and area snapshots go with them,
|
||||
and so do their layout positions. Mutates the given (already copied)
|
||||
pair; returns the dropped ids, sorted.
|
||||
"""
|
||||
dropped: set[str] = set()
|
||||
for marker_id in sorted(marker_ids):
|
||||
markers = config.get("markers") or []
|
||||
target = next(
|
||||
(marker for marker in markers
|
||||
if marker.get("id") == marker_id and marker.get("removed") is not True),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
continue
|
||||
binding = target.get("binding")
|
||||
virtual = binding == "virtual"
|
||||
kept = []
|
||||
for marker in markers:
|
||||
if marker.get("id") == marker_id or (not virtual and marker.get("binding") == binding):
|
||||
dropped.add(str(marker.get("id")))
|
||||
else:
|
||||
kept.append(marker)
|
||||
if not virtual:
|
||||
kept.append({"id": marker_id, "binding": binding, "removed": True, "hidden": True})
|
||||
config["markers"] = kept
|
||||
if not dropped:
|
||||
return []
|
||||
for marker in config.get("markers") or []:
|
||||
controls = marker.get("controls")
|
||||
if not isinstance(controls, list):
|
||||
continue
|
||||
links = [
|
||||
ref for ref in controls
|
||||
if not (isinstance(ref, str) and ref.startswith("marker:")
|
||||
and ref[len("marker:"):] in dropped)
|
||||
]
|
||||
if len(links) != len(controls):
|
||||
marker["controls"] = links or None
|
||||
for space in config.get("spaces") or []:
|
||||
for strip in space.get("led_strips") or []:
|
||||
if isinstance(strip, dict) and strip.get("marker") in dropped:
|
||||
strip["marker"] = None
|
||||
strip["active"] = True
|
||||
snapshot = (config.get("settings") or {}).get("marker_area_snapshot")
|
||||
if isinstance(snapshot, dict):
|
||||
for marker_id in dropped:
|
||||
snapshot.pop(marker_id, None)
|
||||
for marker_id in dropped:
|
||||
layout.pop(marker_id, None)
|
||||
return sorted(dropped)
|
||||
|
||||
|
||||
def _space_delete_target(
|
||||
config: dict[str, Any], layout: dict[str, Any], space_id: str,
|
||||
*, remove_markers: bool = False,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[str], int, list[str]]:
|
||||
"""The exact pair ``space/delete`` commits, and the markers it deleted.
|
||||
|
||||
Without ``remove_markers`` this is the #244 candidate unchanged. With it
|
||||
(#819) every active marker that blocks the delete first goes the way
|
||||
``_delete_plan_markers`` sends it, so the space is no longer in use. The
|
||||
last space keeps its own rule: its markers are detached, never deleted.
|
||||
"""
|
||||
source_config, source_layout = config, layout
|
||||
removed_markers: list[str] = []
|
||||
spaces = config.get("spaces") or []
|
||||
if remove_markers and not (len(spaces) == 1 and spaces[0].get("id") == space_id):
|
||||
source_config = json.loads(json.dumps(config))
|
||||
source_layout = json.loads(json.dumps(layout))
|
||||
removed_markers = _delete_plan_markers(
|
||||
source_config, source_layout,
|
||||
_space_marker_dependencies(source_config, source_layout, space_id),
|
||||
)
|
||||
target_config, target_layout, dependencies, removed_layout = _space_delete_candidate(
|
||||
source_config, source_layout, space_id,
|
||||
)
|
||||
removed_layout += len(layout) - len(source_layout)
|
||||
return target_config, target_layout, dependencies, removed_layout, removed_markers
|
||||
|
||||
|
||||
async def _forget_deleted_markers(
|
||||
hass: HomeAssistant, config: dict[str, Any], marker_ids: list[str],
|
||||
) -> None:
|
||||
"""Attachments and robot trails of markers a space delete took (#819).
|
||||
|
||||
The same clean-up the card runs after the device dialog's «Delete»
|
||||
(``files/cleanup`` and ``trail/delete`` per marker). It follows an already
|
||||
durable commit, so it is best-effort: a failure is logged and never turns
|
||||
the accepted delete into an error the client would retry.
|
||||
"""
|
||||
from .plans import attachment_refs
|
||||
|
||||
refs = attachment_refs(config)
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
|
||||
def _drop_files() -> None:
|
||||
for marker_id in marker_ids:
|
||||
_drop_unreferenced_marker_files(files_dir, marker_id, refs)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(_drop_files)
|
||||
except Exception: # noqa: BLE001 - the delete is already durable
|
||||
_LOGGER.exception("House Plan: removing attachments of deleted markers failed")
|
||||
try:
|
||||
await _purge_trail_recorder(hass, config)
|
||||
except Exception: # noqa: BLE001 - the delete is already durable
|
||||
_LOGGER.exception("House Plan: removing trails of deleted markers failed")
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "houseplan/space/delete",
|
||||
vol.Required("space_id"): str,
|
||||
vol.Required("expected_config_rev"): int,
|
||||
vol.Required("expected_layout_rev"): int,
|
||||
# #819: optional, so an older card keeps the `space_in_use` answer.
|
||||
vol.Optional("remove_markers", default=False): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -1944,8 +2163,11 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
):
|
||||
connection.send_error(msg["id"], "space_not_found", "Space no longer exists")
|
||||
return
|
||||
target_config, target_layout, dependencies, removed_layout = (
|
||||
_space_delete_candidate(current_config, current_layout, space_id)
|
||||
(
|
||||
target_config, target_layout, dependencies, removed_layout, removed_markers,
|
||||
) = _space_delete_target(
|
||||
current_config, current_layout, space_id,
|
||||
remove_markers=msg["remove_markers"],
|
||||
)
|
||||
spaces = current_config.get("spaces") or []
|
||||
deleting_last_space = (
|
||||
@@ -1984,6 +2206,10 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"final_metadata": original_metadata,
|
||||
}
|
||||
await _commit_pair(rt, pending, rollback)
|
||||
if removed_markers:
|
||||
# Under the lock, like config/set: a later write must not
|
||||
# resurrect a marker between this commit and its clean-up.
|
||||
await _forget_deleted_markers(hass, pending["config"], removed_markers)
|
||||
except PairCommitFailure as err:
|
||||
message = (
|
||||
"Space delete failed; the previous plan is pending recovery"
|
||||
@@ -2014,6 +2240,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"config_rev": new_config_rev,
|
||||
"layout_rev": new_layout_rev,
|
||||
"removed_layout": removed_layout,
|
||||
"removed_markers": removed_markers,
|
||||
})
|
||||
|
||||
|
||||
|
||||
+194
-57
@@ -8,15 +8,24 @@ import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-hous
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
import {
|
||||
assertCardContract,
|
||||
assertIsometricCandidate,
|
||||
installEpochDiagnostics,
|
||||
LARGE_HOUSE_CARD_CONTRACT,
|
||||
planLargeHouseIterations,
|
||||
} from './performance/card-contract.mjs';
|
||||
import { attributeResizeLongTask } from './performance/resize-attribution.mjs';
|
||||
import { floorCacheBuilds, floorCacheSnapshot, judgeSwitchCycle } from './performance/switch-cycle-guard.mjs';
|
||||
import {
|
||||
ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
makeIsometricStage3DenseFixture,
|
||||
} from './performance/isometric-stage3-dense-fixture.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const { samples, warmups, iterations } = planLargeHouseIterations({
|
||||
samples: valueArg('samples'), warmups: valueArg('warmups'),
|
||||
});
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
@@ -38,6 +47,12 @@ const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-p
|
||||
const requiresIsoStructuralBuildCounter = requiresIsometric
|
||||
&& readFileSync(resolve(targetRoot, 'src/houseplan-card.ts'), 'utf8')
|
||||
.includes('private _isoStructuralBuildCount');
|
||||
// #769: a candidate must count its floor-cache builds; a base without them is not judged.
|
||||
const cardSource = readFileSync(resolve(targetRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
const requiresFloorCacheBuildCounters = cardSource.includes('private _floorCacheBuilds');
|
||||
// #814: a target that pools its bodies and tunnels per floor has both judged at zero.
|
||||
const requiresFloorBodyPools = cardSource.includes('private _physicalBodiesPool')
|
||||
&& cardSource.includes('private _openingTunnelPool');
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
@@ -96,9 +111,22 @@ await page.addStyleTag({
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpInstallEpochDiagnostics = ${installEpochDiagnostics.toString()};`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertIsometricCandidate = ${assertIsometricCandidate.toString()};`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpEnsureHarnessEditorRuntime = ${ensureHarnessEditorRuntime.toString()};`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAttributeResizeLongTask = ${attributeResizeLongTask.toString()};`,
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpSwitchCycleGuard = { snapshot: ${floorCacheSnapshot.toString()}, `
|
||||
+ `builds: ${floorCacheBuilds.toString()}, judge: ${judgeSwitchCycle.toString()} };`,
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
@@ -110,12 +138,12 @@ try {
|
||||
|
||||
const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const measuredSample = iteration - warmups;
|
||||
for (const measuredSample of iterations) {
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
|
||||
requiresFloorCacheBuildCounters, requiresFloorBodyPools,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -128,11 +156,17 @@ try {
|
||||
const startLongTaskWindow = () => {
|
||||
const entries = [];
|
||||
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
|
||||
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
|
||||
return {
|
||||
entries: null,
|
||||
stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }),
|
||||
};
|
||||
}
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return {
|
||||
// #778: the raw entries stay out of the report; the resize
|
||||
// attribution reads exactly the task this window judged.
|
||||
entries,
|
||||
stop: async () => {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
entries.push(...observer.takeRecords());
|
||||
@@ -166,6 +200,12 @@ try {
|
||||
throw new Error(`${profile} isometric runtime did not load`);
|
||||
await card.updateComplete;
|
||||
};
|
||||
// #778: phase spans of the resize part's Long Task. Null outside that
|
||||
// part, so every other window runs the wrappers below as before.
|
||||
let attributionSpans = null;
|
||||
const recordSpan = (name, startMs) => {
|
||||
attributionSpans?.push({ name, startMs, endMs: performance.now() });
|
||||
};
|
||||
const forceGc = async () => {
|
||||
if (typeof globalThis.gc !== 'function') return false;
|
||||
globalThis.gc();
|
||||
@@ -174,20 +214,13 @@ try {
|
||||
await frame();
|
||||
return true;
|
||||
};
|
||||
// #769: one size snapshot (demo/performance/switch-cycle-guard.mjs). The
|
||||
// budgeted cacheEntries/cacheGrowth keep the opening wall index as the
|
||||
// presence flag their `openingWallIndex: 1` ceilings were set on; its
|
||||
// real size is in the switch-cycle diagnostic.
|
||||
const guard = window.__hpSwitchCycleGuard;
|
||||
const cacheSnapshot = (card) => ({
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
// #744: the union is pooled per floor, and the inner contours are the
|
||||
// second structural cache of a floor visit; 0/1 above cannot see a
|
||||
// cold floor whose union is rebuilt while its clean floor stays warm.
|
||||
wallUnionPool: card._wallUnionPool?.size ?? 0,
|
||||
innerContour: card._innerContourCache?.size ?? 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
|
||||
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
|
||||
...guard.snapshot(card), openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
});
|
||||
const isoStructuralBuildCount = (card) => Number.isFinite(card._isoStructuralBuildCount)
|
||||
? card._isoStructuralBuildCount : null;
|
||||
@@ -321,27 +354,16 @@ try {
|
||||
// #520 diagnostics: where the extra half second of model readiness goes.
|
||||
// Counts Lit update cycles and model builds; printed, never budgeted.
|
||||
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
|
||||
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
|
||||
// what rebuilds the model a second time (#520).
|
||||
let diagEpoch = 0;
|
||||
Object.defineProperty(card, '_cfgEpoch', {
|
||||
configurable: true,
|
||||
get: () => diagEpoch,
|
||||
set: (next) => {
|
||||
if (next !== diagEpoch) {
|
||||
const frames = (new Error().stack || '').split('\n').slice(1, 4)
|
||||
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
|
||||
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
|
||||
}
|
||||
diagEpoch = next;
|
||||
},
|
||||
});
|
||||
// First eight `_cfgEpoch` bumps with their frames (#520/#812); later
|
||||
// bumps still advance the epoch/counters, without collecting more stacks.
|
||||
window.__hpInstallEpochDiagnostics(card);
|
||||
const diagPerform = card.performUpdate.bind(card);
|
||||
card.performUpdate = function () {
|
||||
const started = performance.now();
|
||||
const result = diagPerform();
|
||||
card.__diag.updates += 1;
|
||||
card.__diag.updateMs += performance.now() - started;
|
||||
recordSpan('update', started);
|
||||
return result;
|
||||
};
|
||||
const diagBuild = card._buildModel.bind(card);
|
||||
@@ -395,8 +417,7 @@ try {
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
if (requiresIsometric && window.__hpAlpha !== true) {
|
||||
if (typeof card._onLabsSnapshot !== 'function')
|
||||
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
|
||||
window.__hpAssertIsometricCandidate(card, profile, 'labs-hook');
|
||||
// Comparison bundles before #448 do not understand hp_alpha. Preserve
|
||||
// the cross-version benchmark only for that base; current candidates
|
||||
// must activate through the real URL/storage contract above.
|
||||
@@ -409,11 +430,8 @@ try {
|
||||
if (!await window.__hpEnsureHarnessEditorRuntime(card))
|
||||
throw new Error('large-house editor runtime did not preload');
|
||||
if (isometric) await ensureIsoRuntime(card);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._effectiveProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
|
||||
}
|
||||
window.__hpAssertCardContract(card, cardContract, profile);
|
||||
if (requiresIsometric) window.__hpAssertIsometricCandidate(card, profile, 'renderer');
|
||||
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
@@ -436,7 +454,11 @@ try {
|
||||
models: card.__diag.models,
|
||||
adopts: card.__diag.adopts,
|
||||
cfgEpoch: card._cfgEpoch,
|
||||
epochs: card.__diag.epochs.slice(0, 8),
|
||||
epochs: card.__diag.epochs.slice(),
|
||||
epochChanges: card.__diag.epochChanges,
|
||||
epochStackCaptures: card.__diag.epochStackCaptures,
|
||||
epochTracesDropped: card.__diag.epochTracesDropped,
|
||||
epochTracesLimit: card.__diag.epochTracesLimit,
|
||||
};
|
||||
const initialProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
@@ -467,7 +489,10 @@ try {
|
||||
physicalPreflightCount++;
|
||||
const started = performance.now();
|
||||
try { return checkSpacePhysicalGeometry(...args); }
|
||||
finally { physicalPreflightMs += performance.now() - started; }
|
||||
finally {
|
||||
physicalPreflightMs += performance.now() - started;
|
||||
recordSpan('preflight', started);
|
||||
}
|
||||
};
|
||||
const willUpdate = card.willUpdate.bind(card);
|
||||
card.willUpdate = (changed) => {
|
||||
@@ -864,6 +889,68 @@ try {
|
||||
editorStage = card.renderRoot.querySelector('.stage');
|
||||
editorRect = editorStage.getBoundingClientRect();
|
||||
editorView = card._viewOr(card._baseVb());
|
||||
// #778: split this part's Long Task into timed phases (see
|
||||
// performance/resize-attribution.mjs). The wrappers only time the live
|
||||
// move and its callbacks, and they and the frame observer are installed
|
||||
// before the yield below, so neither is inside the measured task.
|
||||
const startResizeAttribution = () => {
|
||||
const controller = card._resize;
|
||||
const move = controller?.move;
|
||||
if (typeof move !== 'function')
|
||||
return { stop: () => ({ supported: false, reason: 'no ResizeController move' }) };
|
||||
const spans = [];
|
||||
const frames = [];
|
||||
let shapeError = null;
|
||||
const frameObserver = PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')
|
||||
? new PerformanceObserver((list) => frames.push(...list.getEntries())) : null;
|
||||
frameObserver?.observe({ type: 'long-animation-frame', buffered: false });
|
||||
const timed = (name, run) => function (...args) {
|
||||
const started = performance.now();
|
||||
try { return run.apply(this, args); } finally { recordSpan(name, started); }
|
||||
};
|
||||
const ownMove = Object.prototype.hasOwnProperty.call(controller, 'move');
|
||||
controller.move = function (input) {
|
||||
if (!['project', 'publish', 'measure'].every((key) => typeof input?.[key] === 'function')) {
|
||||
// An unknown move shape is reported, never turned into zero shares.
|
||||
shapeError = 'ResizeController.move input has no project/publish/measure callbacks';
|
||||
return move.call(this, input);
|
||||
}
|
||||
return timed('move', move).call(this, {
|
||||
...input,
|
||||
project: timed('project', input.project),
|
||||
publish: timed('publish', input.publish),
|
||||
measure: timed('labels', input.measure),
|
||||
});
|
||||
};
|
||||
attributionSpans = spans;
|
||||
return {
|
||||
stop: (longTasks) => {
|
||||
frames.push(...(frameObserver?.takeRecords() ?? []));
|
||||
frameObserver?.disconnect();
|
||||
if (ownMove) controller.move = move;
|
||||
else delete controller.move;
|
||||
attributionSpans = null;
|
||||
if (shapeError) return { supported: false, reason: shapeError };
|
||||
return window.__hpAttributeResizeLongTask({
|
||||
longTasks: longTasks?.map((entry) => ({
|
||||
startTime: entry.startTime, duration: entry.duration,
|
||||
})) ?? null,
|
||||
spans,
|
||||
frames: frameObserver ? frames.map((entry) => ({
|
||||
startTime: entry.startTime,
|
||||
duration: entry.duration,
|
||||
renderStart: entry.renderStart,
|
||||
scripts: [...(entry.scripts || [])].map((script) => ({
|
||||
startTime: script.startTime,
|
||||
duration: script.duration,
|
||||
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration,
|
||||
})),
|
||||
})) : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
const resizeAttribution = startResizeAttribution();
|
||||
// A browser delivers pointerdown and pointermove as separate tasks.
|
||||
// Keep the synthetic harness from attributing both handlers to one
|
||||
// impossible long task while still timing every move-side preflight.
|
||||
@@ -893,6 +980,7 @@ try {
|
||||
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
|
||||
editorElapsed += performance.now() - partStarted;
|
||||
editorLongTaskWindows.push(await editorPartLongTasks.stop());
|
||||
deltas.resizeLongTask = resizeAttribution.stop(editorPartLongTasks.entries);
|
||||
|
||||
card._setMode('decor', false);
|
||||
card._decorTool = 'select';
|
||||
@@ -1160,10 +1248,14 @@ try {
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
const switchCycleCachesBefore = cacheSnapshot(card);
|
||||
const switchCycleCachesBefore = guard.snapshot(card);
|
||||
const switchCycleBuildsBefore = isoStructuralBuildCount(card);
|
||||
const switchCycleFloorBuildsBefore = guard.builds(card);
|
||||
if (requiresFloorCacheBuildCounters && !switchCycleFloorBuildsBefore)
|
||||
throw new Error(`${profile} floor-cache build counters are absent`);
|
||||
const switchCycleSwitches = 12;
|
||||
const switchCycle = await duration(async () => {
|
||||
for (let index = 0; index < 12; index++) {
|
||||
for (let index = 0; index < switchCycleSwitches; index++) {
|
||||
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
||||
await card.updateComplete;
|
||||
// A user cannot produce twelve tab clicks in one JavaScript task.
|
||||
@@ -1172,19 +1264,38 @@ try {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
}
|
||||
});
|
||||
// #735 guard: a warmed cycle builds nothing. Any grown hot cache or a
|
||||
// structural 2.5D build inside the window means a cold floor visit
|
||||
// leaked back into switchCycleMs. Caches an older base lacks read as 0
|
||||
// and cannot grow; its build counter is null and is not judged.
|
||||
const switchCycleCachesAfter = cacheSnapshot(card);
|
||||
// #735 guard on build counters (#769): a warmed cycle builds nothing in
|
||||
// the pooled families and at most one single-slot build per switch; no
|
||||
// structural 2.5D build either. Sizes are only a diagnostic: a cold
|
||||
// build in a full LRU evicts and leaves every size as it was. A base
|
||||
// without the counters reads null and is not judged.
|
||||
const switchCycleCachesAfter = guard.snapshot(card);
|
||||
const switchCycleBuildsAfter = isoStructuralBuildCount(card);
|
||||
const switchCycleBuilt = Object.keys(switchCycleCachesAfter)
|
||||
.filter((key) => switchCycleCachesAfter[key] > switchCycleCachesBefore[key])
|
||||
.map((key) => `${key} +${switchCycleCachesAfter[key] - switchCycleCachesBefore[key]}`);
|
||||
if (switchCycleBuildsBefore != null && switchCycleBuildsAfter !== switchCycleBuildsBefore)
|
||||
switchCycleBuilt.push(`isoStructuralBuilds +${switchCycleBuildsAfter - switchCycleBuildsBefore}`);
|
||||
if (switchCycleBuilt.length)
|
||||
throw new Error(`${profile} switchCycle built a floor inside the window: ${switchCycleBuilt.join(', ')}`);
|
||||
const switchCycleVerdict = guard.judge({
|
||||
before: switchCycleFloorBuildsBefore, after: guard.builds(card), switches: switchCycleSwitches,
|
||||
isoBefore: switchCycleBuildsBefore, isoAfter: switchCycleBuildsAfter, pooled: requiresFloorBodyPools,
|
||||
});
|
||||
if (!switchCycleVerdict.ok)
|
||||
throw new Error(`${profile} switchCycle built a floor inside the window: ${switchCycleVerdict.failures.join(', ')}`);
|
||||
// #814: the cost of a warm lookup (key + hit) on the shown floor, untimed
|
||||
// and outside every Long Task window: the opening wall index key reads
|
||||
// rooms, walls and cuts afresh; the bodies key is the floor record key.
|
||||
const keyCost = (() => {
|
||||
const space = card._spaceModel?.();
|
||||
const cuts = typeof card._openCuts === 'function' ? card._openCuts() : null;
|
||||
const perCall = (read) => {
|
||||
if (!space || typeof read !== 'function') return null;
|
||||
read();
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < 50; index++) read();
|
||||
return Number(((performance.now() - started) / 50).toFixed(4));
|
||||
};
|
||||
return {
|
||||
openingWallIndexMs: perCall(cuts && card._openingWallIndexFor
|
||||
&& (() => card._openingWallIndexFor(space, cuts))),
|
||||
physicalBodiesMs: perCall(card._physicalBodiesR && (() => card._physicalBodiesR(space))),
|
||||
};
|
||||
})();
|
||||
|
||||
// #743 structural probe, untimed and outside every Long Task window: six
|
||||
// warm switches round the floors (the cycle ended on the last floor, so
|
||||
@@ -1250,6 +1361,13 @@ try {
|
||||
panZoomMs: panZoom.ms,
|
||||
settingsDialogMs: settingsDialog.ms,
|
||||
switchCycleMs: switchCycle.ms,
|
||||
switchCycleBuilds: {
|
||||
supported: switchCycleVerdict.supported,
|
||||
pooled: requiresFloorBodyPools,
|
||||
families: switchCycleVerdict.builds,
|
||||
caches: { before: switchCycleCachesBefore, after: switchCycleCachesAfter },
|
||||
keyCost,
|
||||
},
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
@@ -1299,6 +1417,7 @@ try {
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
|
||||
requiresFloorCacheBuildCounters, requiresFloorBodyPools,
|
||||
});
|
||||
// #520: диагностика печатается в лог прогона и в запись не попадает.
|
||||
const { bootDiag, ...measured } = row;
|
||||
@@ -1307,8 +1426,26 @@ try {
|
||||
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
|
||||
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
|
||||
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
|
||||
+ ` epochChanges=${bootDiag.epochChanges} stackCaptures=${bootDiag.epochStackCaptures}`
|
||||
+ ` tracesDropped=${bootDiag.epochTracesDropped} traceLimit=${bootDiag.epochTracesLimit}`
|
||||
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
|
||||
}
|
||||
// #778: the resize Long Task split, printed for the job log; the same
|
||||
// numbers stay in the record as interactionDiagnostics.resizeLongTask.
|
||||
const resizeLongTask = measured.interactionDiagnostics?.resizeLongTask;
|
||||
if (resizeLongTask?.supported && resizeLongTask.longTaskMs > 0) {
|
||||
const part = (name, value) => `${name} ${value ?? '—'}`;
|
||||
console.log(`#778 resize long task sample ${row.sample}: ${resizeLongTask.longTaskMs} ms = ${[
|
||||
part('preflight', resizeLongTask.preflightMs), part('project', resizeLongTask.projectOtherMs),
|
||||
part('publish', resizeLongTask.publishMs), part('labels', resizeLongTask.labelsMs),
|
||||
part('move', resizeLongTask.moveOtherMs), part('update', resizeLongTask.updateMs),
|
||||
part('other', resizeLongTask.otherMs),
|
||||
].join(' + ')} (geometry moves ${resizeLongTask.geometryMoves}; after the task:`
|
||||
+ ` render ${resizeLongTask.frameRenderMs ?? '—'} ms; forced style/layout in it`
|
||||
+ ` ${resizeLongTask.forcedStyleLayoutMs ?? '—'} ms)`);
|
||||
} else if (resizeLongTask && !resizeLongTask.supported) {
|
||||
console.log(`#778 resize long task sample ${row.sample}: not attributed (${resizeLongTask.reason})`);
|
||||
}
|
||||
if (measuredSample >= 0) rows.push(measured);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// #803: real pointer-to-paint measurements, including validation and DOM work.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { launch, finish, check } from './serve.mjs';
|
||||
import { wallNodeFixture, NODE_CASES } from './performance/wall-node-fixture.mjs';
|
||||
import { commitWallSegmentModel } from '../test-build/wall-segment-model.js';
|
||||
import { applyNodeMove, prepareNodeMove, structuralWallNodes } from '../test-build/wall-node-move.js';
|
||||
const initial = wallNodeFixture();
|
||||
initial.spaces[0].view_box = [-1.4, -0.2, 6.5, 2.5];
|
||||
const { page, browser } = await launch({ width: 1440, height: 1000 });
|
||||
let writeCount = 0;
|
||||
await page.exposeFunction('nodeBenchmarkWire', m => {
|
||||
if (m.type === 'houseplan/config/get') return { config: initial, rev: 1, can_write: true, wall_node_move_api: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
||||
if (m.type === 'houseplan/wall/node_move') {
|
||||
const source = initial.spaces[0], nodes = structuralWallNodes(source);
|
||||
const node = nodes.find(n => n.point.every((v, i) => Math.abs(v - m.intent.point[i]) < 1e-9));
|
||||
const candidate = applyNodeMove(prepareNodeMove(source, node, nodes, m.intent.split_ids), m.intent.target, m.intent.axis);
|
||||
assert.equal(candidate.ok, true); writeCount++;
|
||||
return { config: commitWallSegmentModel({ ...initial, spaces: [candidate.space] }).config, rev: 2, ok: true };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const oldCard = window.__card, hass = window.__mkHass(), old = hass.callWS.bind(hass);
|
||||
hass.devices = {}; hass.entities = {}; hass.states = {};
|
||||
hass.callWS = async m => m.type.endsWith('registry/list') ? [] : (await window.nodeBenchmarkWire(m)) ?? old(m);
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', language: 'en' });
|
||||
oldCard.remove(); document.getElementById('host').appendChild(card); window.__card = card; card.hass = hass;
|
||||
});
|
||||
await page.waitForFunction(() => window.__card?._booting === false && window.__card._space === 'nodes-large');
|
||||
console.log('200-room fixture adopted');
|
||||
await page.evaluate(async () => { await window.__hpTest.setMode('plan'); await window.__hpTest.setTool('select'); });
|
||||
console.log('Select ready');
|
||||
await page.waitForFunction(() => window.__card.renderRoot.querySelector('.hp-node-handle'));
|
||||
const screen = p => page.evaluate(p => {
|
||||
const svg = window.__card.renderRoot.querySelector('.hp-node-handle').ownerSVGElement;
|
||||
const q = svg.createSVGPoint(); q.x = p[0] * 1000; q.y = p[1] * 1000;
|
||||
const out = q.matrixTransform(svg.getScreenCTM()); return { x: out.x, y: out.y };
|
||||
}, p);
|
||||
await page.evaluate(() => {
|
||||
window.__nodeProfile = { moves: [], tasks: [], start: 0, last: 0, pending: false, down: [], upValidationCpuMs: 0, upToWireMs: 0 };
|
||||
new PerformanceObserver(list => window.__nodeProfile.tasks.push(...list.getEntries().map(e => ({ start: e.startTime, duration: e.duration }))))
|
||||
.observe({ type: 'longtask', buffered: false });
|
||||
window.__card.renderRoot.addEventListener('pointermove', e => {
|
||||
if (!window.__card._editorRuntime.nodeMove.dragging) return;
|
||||
const p = window.__nodeProfile; p.last = performance.now(); p.pending = true;
|
||||
window.__card.updateComplete.then(() => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
p.moves.push(performance.now() - p.last); p.pending = false;
|
||||
})));
|
||||
}, { capture: true });
|
||||
// Measurement-only wrappers call the exact original production callbacks,
|
||||
// with the same arguments/result. No candidate, input or geometry is faked.
|
||||
const port = window.__card._editorRuntime.nodeMove.port, validate = port.validate, write = port.write;
|
||||
port.validate = (...args) => { const start = performance.now(); const result = validate(...args);
|
||||
if (args[2]) window.__nodeProfile.upValidationCpuMs += performance.now() - start; return result; };
|
||||
port.write = (...args) => { const profile = window.__nodeProfile;
|
||||
const before = window.__card.hass.callWS.bind(window.__card.hass);
|
||||
window.__card.hass.callWS = async m => {
|
||||
if (m.type !== 'houseplan/wall/node_move') return before(m);
|
||||
profile.upToWireMs = performance.now() - profile.upStart;
|
||||
const start = performance.now();
|
||||
try { return await before(m); }
|
||||
finally { profile.wireWaitMs = performance.now() - start; }
|
||||
};
|
||||
return write(...args);
|
||||
};
|
||||
window.__card.renderRoot.addEventListener('pointerup', () => { window.__nodeProfile.upStart = performance.now(); }, { capture: true });
|
||||
});
|
||||
const warmups = Number(process.env.HP_NODE_WARMUPS || 3), series = Number(process.env.HP_NODE_SERIES || 20);
|
||||
const moves = Number(process.env.HP_NODE_MOVES || 120), results = [], cold = [], downCpu = [];
|
||||
const cpu = await page.context().newCDPSession(page);
|
||||
await cpu.send('Performance.enable', { timeDomain: 'threadTicks' });
|
||||
const cpuTime = async () => {
|
||||
const result = await cpu.send('Performance.getMetrics');
|
||||
const metric = result.metrics.find(m => m.name === 'TaskDuration');
|
||||
assert.ok(metric && Number.isFinite(metric.value), 'main-thread CPU metric available');
|
||||
return metric.value * 1000;
|
||||
};
|
||||
for (let s = -warmups; s < series; s++) {
|
||||
const test = NODE_CASES[(s + warmups) % NODE_CASES.length], start = await screen(test.point);
|
||||
console.log('begin', s, test.name, start);
|
||||
await page.mouse.move(start.x, start.y);
|
||||
const beforeDown = await cpuTime(); await page.mouse.down();
|
||||
downCpu.push((await cpuTime()) - beforeDown);
|
||||
assert.equal(await page.evaluate(() => window.__card._editorRuntime.nodeMove.dragging), true, `${test.name} pointer captured`);
|
||||
await page.evaluate(() => { const p = window.__nodeProfile; p.moves = []; p.tasks = []; p.start = performance.now(); });
|
||||
for (let i = 0; i < moves; i++) {
|
||||
const at = await screen(test.raw(i + s * 11 + 200)); await page.mouse.move(at.x, at.y);
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
}
|
||||
const result = await page.evaluate(() => {
|
||||
const p = window.__nodeProfile, stop = performance.now();
|
||||
const editor = window.__card._editorRuntime.nodeMove;
|
||||
return { latencies: p.moves, tasks: p.tasks.filter(t => t.start >= p.start && t.start <= stop), duration: stop - p.start,
|
||||
visible: !!editor.preview, invalid: editor.invalid, target: editor.session?.target,
|
||||
node: editor.session?.plan.node.point, axis: editor.session?.axis };
|
||||
});
|
||||
assert.equal(result.visible, true, `${test.name} actual changed candidate ${JSON.stringify(result)}`);
|
||||
assert.equal(result.invalid, null, `${test.name} validated candidate`);
|
||||
await page.keyboard.press('Escape'); await page.mouse.up();
|
||||
if (s === -warmups) cold.push(...result.latencies);
|
||||
if (s >= 0) { results.push({ kind: test.name, ...result }); console.log('series', s, test.name, result.latencies.length, Math.max(0, ...result.tasks.map(t => t.duration))); }
|
||||
}
|
||||
assert.equal(writeCount, 0, 'cancelled series never wrote');
|
||||
const commitCase = NODE_CASES[0], commitStart = await screen(commitCase.point), commitTarget = await screen(commitCase.raw(230));
|
||||
await page.mouse.move(commitStart.x, commitStart.y); await page.mouse.down(); await page.mouse.move(commitTarget.x, commitTarget.y);
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
// Chromium's main-thread CPU clock excludes network/Node fake-HA waiting. It
|
||||
// includes flush, validation, adoption, history and the first settled DOM frame.
|
||||
const beforeCpu = await cpuTime(), beforeUp = performance.now();
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(() => !window.__card._editorRuntime.nodeMove.busy && !window.__card._editorRuntime.nodeMove.dragging, { timeout: 60000 });
|
||||
assert.equal(writeCount, 1, 'valid release reaches the real writer exactly once');
|
||||
await page.evaluate(() => window.__hpTest.settled());
|
||||
const pointerupCommitCpuMs = (await cpuTime()) - beforeCpu, pointerupCompleteWallMs = performance.now() - beforeUp;
|
||||
await cpu.detach();
|
||||
const terminal = await page.evaluate(() => ({
|
||||
pointerupValidationCpuMs: window.__nodeProfile.upValidationCpuMs, pointerupToWireMs: window.__nodeProfile.upToWireMs,
|
||||
wireWaitMs: window.__nodeProfile.wireWaitMs }));
|
||||
const quantile = (values, q) => [...values].sort((a, b) => a - b)[Math.min(values.length - 1, Math.ceil(values.length * q) - 1)] || 0;
|
||||
const latencies = results.flatMap(r => r.latencies);
|
||||
const report = { viewport: [1440, 1000], dpr: 1, roomCount: 200, warmups, series, moves,
|
||||
fixtureFingerprint: createHash('sha256').update(JSON.stringify(initial)).digest('hex'),
|
||||
pointerToPaintMs: { p50: quantile(latencies, .5), p95: quantile(latencies, .95), max: Math.max(0, ...latencies) },
|
||||
coldFirstGesturePointerToPaintMs: { p50: quantile(cold, .5), p95: quantile(cold, .95), max: Math.max(0, ...cold) },
|
||||
pointerDownCpuMs: { first: downCpu[0], p95: quantile(downCpu, .95), max: Math.max(...downCpu) },
|
||||
pointerupValidationCpuMs: terminal.pointerupValidationCpuMs,
|
||||
pointerupToWireMs: terminal.pointerupToWireMs,
|
||||
pointerupCommitCpuMs, terminalCpuMetric: 'CDP Performance.TaskDuration (threadTicks)',
|
||||
pointerupCompleteWallMs, wireWaitMs: terminal.wireWaitMs,
|
||||
pointerupCommitWithoutNetworkWallMs: pointerupCompleteWallMs - terminal.wireWaitMs,
|
||||
longTaskMaxMs: Math.max(0, ...results.flatMap(r => r.tasks.map(t => t.duration))),
|
||||
longTaskCountP95: quantile(results.map(r => r.tasks.length), .95),
|
||||
longTaskTotalP95Ms: quantile(results.map(r => r.tasks.reduce((sum, t) => sum + t.duration, 0)), .95) };
|
||||
check('node drag single long task <=150ms', report.longTaskMaxMs <= 150);
|
||||
check('node drag p95 long task count <=3', report.longTaskCountP95 <= 3);
|
||||
check('node drag p95 long task total <=300ms', report.longTaskTotalP95Ms <= 300);
|
||||
await finish(browser, report);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { wallNodeFixture, NODE_CASES } from './performance/wall-node-fixture.mjs';
|
||||
import { applyNodeMove, prepareNodeMove, resolveNodeMoveSnap, structuralWallNodes } from '../test-build/wall-node-move.js';
|
||||
const config = wallNodeFixture(), space = config.spaces[0];
|
||||
const began = performance.now(), nodes = structuralWallNodes(space), coldGraphMs = performance.now() - began;
|
||||
const plans = NODE_CASES.map(c => {
|
||||
const node = nodes.find(n => Math.hypot(n.point[0] - c.point[0], n.point[1] - c.point[1]) < 1e-8);
|
||||
return prepareNodeMove(space, node, nodes, { 'partition:x-v': 'partition-00000000-0000-4000-8000-000000000001',
|
||||
'partition:x-h': 'partition-00000000-0000-4000-8000-000000000002' });
|
||||
});
|
||||
const samples = [], outcomes = new Set();
|
||||
for (let series = -3; series < 20; series++) for (let i = 0; i < 120; i++) {
|
||||
const index = i % plans.length, plan = plans[index];
|
||||
const start = performance.now();
|
||||
const snap = resolveNodeMoveSnap(plan, NODE_CASES[index].raw(i + series + 3), 0.0002, null);
|
||||
const result = applyNodeMove(plan, snap.point, snap.axis);
|
||||
if (!result.ok) throw new Error(`${NODE_CASES[index].name} refused: ${result.reason}`);
|
||||
outcomes.add(JSON.stringify([index, snap.point]));
|
||||
if (series >= 0) samples.push(performance.now() - start);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
const percentile = q => samples[Math.min(samples.length - 1, Math.ceil(q * samples.length) - 1)];
|
||||
const out = { profile: 'wall-node-candidate-v1', roomCount: 200, warmups: 3, series: 20, movesPerSeries: 120,
|
||||
distinctTargets: outcomes.size, fixtureFingerprint: createHash('sha256').update(JSON.stringify(config)).digest('hex'),
|
||||
coldGraphMs, candidateMs: { p50: percentile(0.5), p95: percentile(0.95), max: samples.at(-1) } };
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
if (out.candidateMs.p95 > 16 || outcomes.size < 100) process.exitCode = 1;
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"matrixVersion": 71,
|
||||
"acceptedAt": "2026-10-05T12:14:58.027Z",
|
||||
"sourceFingerprint": "14a902be33654296da3bbc915dcdd2c5b918936f24526ddc7a2e4b45a73afe25",
|
||||
"matrixVersion": 73,
|
||||
"acceptedAt": "2026-10-07T20:16:32.701Z",
|
||||
"sourceFingerprint": "e270340092f7e34e87f84df9b2d6b4a34d4ffadf0039174d8c3c89cfa4573b69",
|
||||
"chromium": "151.0.7922.34",
|
||||
"capturedOn": "linux",
|
||||
"acceptedOn": "linux",
|
||||
@@ -10,41 +10,18 @@
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"chromium": "151.0.7922.34",
|
||||
"buildFingerprint": "14a902be33654296da3bbc915dcdd2c5b918936f24526ddc7a2e4b45a73afe25",
|
||||
"ci": null
|
||||
},
|
||||
"localAttestation": {
|
||||
"schema": "houseplan-golden-wsl/v1",
|
||||
"sha256": "c0d5ed83d3bf2591a21fff465403ce4ab71c10df781a78736cf690a578a5df52",
|
||||
"artifactSha256": "5d2082d6a19c7f4ecce6ea5bdb40da9ec76d804052847b9863f770213dd0b0c0",
|
||||
"createdAt": "2026-10-05T12:14:24.651Z",
|
||||
"source": {
|
||||
"buildFingerprint": "e270340092f7e34e87f84df9b2d6b4a34d4ffadf0039174d8c3c89cfa4573b69",
|
||||
"ci": {
|
||||
"repository": "Matysh/houseplan-card",
|
||||
"branch": "issue/798-provider-zigbee-routes",
|
||||
"commit": "22828495bb3ca3c038a87b68327d068e60874d9d",
|
||||
"tree": "0959cf050c960f44d6ada12c31e76335dd380430",
|
||||
"remoteSha": "22828495bb3ca3c038a87b68327d068e60874d9d",
|
||||
"clean": true,
|
||||
"status": ""
|
||||
},
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"kernel": "6.18.33.2-microsoft-standard-WSL2",
|
||||
"wsl": true,
|
||||
"distro": "Ubuntu",
|
||||
"filesystem": "ext2/ext3"
|
||||
},
|
||||
"toolchain": {
|
||||
"node": "22.23.2",
|
||||
"npm": "10.9.8",
|
||||
"playwright": "1.62.0",
|
||||
"chromiumExecutableSha256": "0b20b130e7edd9dd51873be867761295fe0cfad490c2b9a64f95bd3cfc08fa71"
|
||||
"run": 37675448130,
|
||||
"attempt": 1,
|
||||
"sha": "334e0c23788d9da952a217f5e86b080e89eb8934"
|
||||
}
|
||||
},
|
||||
"localAttestation": null,
|
||||
"foreignCapture": null,
|
||||
"witnesses": {
|
||||
"count": 107,
|
||||
"count": 111,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
@@ -163,6 +140,11 @@
|
||||
"openings-hidden-view-dark": "209a6a52993a6840d85126f59f408fac68dcb9b7117e489161de96d63c834fae",
|
||||
"lighting-glow-sun-dark": "9021fa10f6606d2307f75f8024473be3e2d6e94d1c48ab149551c2bbdf129578",
|
||||
"device-value-badge-positions-dark": "f4ae3548d65e8fd0377b7c2b446065d922473c40f85537a8ce99d3219978223c",
|
||||
"device-battery-board-light": "ca8d98e6fa4f634c920f4d184277cfee2133b1b906b3cfb78e160a89ab4d5c12",
|
||||
"device-battery-board-dark": "d0f4d338a34eef50965c89716df285344bcfb6a60a078372d3a8b1bb9855d733",
|
||||
"device-battery-board-medium": "25d924e54d0d0c09569678560c00869ac3a39dfc38c59910adc86ad221e8b2e7",
|
||||
"device-battery-mobile-dark": "f968127a092cd5081a18864ce883f791e398136b5cbc0fcd319eab6a855c7cc8",
|
||||
"device-battery-zigbee-overlap-dark": "ef5211df5694a04a62eb2d1e4cfc5fe0468a34a92d1a0365e1fc7d6737db5aa2",
|
||||
"device-value-face-cover-source-dark": "18aa90881fe7794c98c52142b73fb1db33bf57d390c24b6286b7b7db6312bcfa",
|
||||
"device-icon-state-table-light": "0e426d536bd89354d51bd90ef62c08bdf8d36f27132d5f1f22b64c79850266df",
|
||||
"device-icon-state-table-dark": "c993ee850b38af1325f1290d45e516a19c4bc6c3e4db864cdc1cc61276ce6d18",
|
||||
@@ -229,7 +211,7 @@
|
||||
"support-phone-success-dark-en": "dcf2cb53e904dd3196052f5e0001b8aee712db9c9c0e4f8cd2982645e4bf7dbd",
|
||||
"support-relay-error-light-en": "adf30f96a926a1730c9ad34d18b4234d9b19140f11d56c8fb7508976f366c11d",
|
||||
"support-tablet-preview-dark-ru": "a69ba86bf069d9beba94eedf6cc3b385b026caeee66348e4ef42c1a072b6df6a",
|
||||
"device-ripple-color-popover-mobile-ru": "8fc31025d32d87e5d3d3df896937d182577cd5169d520e7cf5fc267e7d080776",
|
||||
"device-ripple-color-popover-mobile-ru": "3f74934eac123e25ef0e9aed18b470e06271f250e53a26cac15d8882fa260f43",
|
||||
"space-room-color-popover-desktop-ru": "0e4654978e004cb9242ccf4add8022b57459b6a8524f141160ae49aa46787fe9",
|
||||
"backup-full-preview-desktop-en": "2a71dd52bce193a5ddca1ee0404f91754787206cbe01af0b0bfb3871016ed289",
|
||||
"backup-plan-only-export-desktop-en": "d7ae8031e1147ee8d3d15f35d8cd5bc65aa24fc880ad2feafdd7c8713b76c0c5",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user