mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-08 07:29:13 +00:00
Compare commits
217
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d3c13378 | ||
|
|
961e4e1aaa | ||
|
|
55a8430d85 | ||
|
|
5380f7afa3 | ||
|
|
e7527cb76d | ||
|
|
6d048b1d8e | ||
|
|
0bbaca24d4 | ||
|
|
4c5808499d | ||
|
|
a7397d1940 | ||
|
|
c36dca25cf | ||
|
|
a1d7c34cc2 | ||
|
|
91df175208 | ||
|
|
c748cf0d97 | ||
|
|
6b45df6db6 | ||
|
|
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 | ||
|
|
7feda5b6c8 | ||
|
|
285a6dc436 | ||
|
|
52876b0573 | ||
|
|
cdfe02e64d | ||
|
|
07c885435a | ||
|
|
67af859e8c | ||
|
|
4a7abf531b | ||
|
|
379de706d2 | ||
|
|
1faed2af7d | ||
|
|
343449e694 |
@@ -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
|
||||
|
||||
@@ -49,6 +49,10 @@ async def _async_flush_runtime(
|
||||
writers are independently guarded so one failed store cannot prevent the
|
||||
other from reaching disk during shutdown.
|
||||
"""
|
||||
zigbee = getattr(entry.runtime_data, "zigbee_coordinator", None)
|
||||
if zigbee is not None:
|
||||
await zigbee.async_close()
|
||||
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
if recorder is not None:
|
||||
try:
|
||||
@@ -111,6 +115,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
|
||||
entry.runtime_data = data
|
||||
|
||||
from .zigbee_topology import ZigbeeScanCoordinator
|
||||
data.zigbee_coordinator = ZigbeeScanCoordinator(hass, entry)
|
||||
|
||||
from .radar import RadarCoordinator
|
||||
data.radar_coordinator = RadarCoordinator(hass, data)
|
||||
await data.radar_coordinator.async_setup()
|
||||
|
||||
@@ -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.1"
|
||||
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
@@ -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);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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-pPShiPsn.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
File diff suppressed because one or more lines are too long
@@ -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
-2563
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
+29
File diff suppressed because one or more lines are too long
-27
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__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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-pPShiPsn.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-P8XODJa_.js";const M="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-D9Bcdufv.js"):import(new URL(`./led-strip-field-D9Bcdufv.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__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";import{eR as a,eS as e,eG as n}from"./houseplan-card-pPShiPsn.js";function c(n,c){return new a([{code:"en",dictionary:n},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a",console.warn,e)}function d(a){return function(a,e){return{state:n=>{const c=a.state(n);return"pending"!==c&&e.some(a=>"pending"===a.state(n))?"pending":c},dictionary:e=>a.dictionary(e),ensure:n=>Promise.all([a,...e].map(a=>a.ensure(n))).then(()=>{})}}(n,a)}export{c as n,d 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__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";import{aG as e,l as t,aJ as s,A as a,eF as o,eG as i,eH as n,E as c,eQ as l,c as r}from"./houseplan-card-pPShiPsn.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 c;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:l(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);
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";import{c as a,A as s}from"./houseplan-card-pPShiPsn.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-C4YDg3Cx.js";import"./form-kit.styles-BC5W6ya6.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">
|
||||
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"
|
||||
-1
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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-pPShiPsn.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
@@ -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__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";export{e as dictionary,r as fingerprint};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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 kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · 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="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";export{e as dictionary,n as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
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 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";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 peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · 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="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";export{e as dictionary,n as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
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};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";var a={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются родители и следующие узлы по сведениям интеграции. Наведение ничего не запрашивает.",help:"Показывает родителя конечного устройства или активный следующий узел роутера к координатору по данным интеграции. Исходящая стрелка не более одной, входящие — устройства, идущие через этот узел. Обычные соседи скрыты. Все линии сплошные. Цвет линии и стрелки — LQI 0–255: красный при 0, жёлтый при 128, зелёный при 255. Без LQI они серые с чёрной обводкой. Подпись на конце стрелки указывает цель на другом пространстве или не на плане. «Нет данных о маршруте» означает отсутствие однозначных сведений об исходящем маршруте, а не недоступность устройства. Это последние записи интеграции, не путь пакета в реальном времени и не гарантия всей цепочки. Кеш ZHA может быть старым уже при чтении; устаревшие и неполные данные отмечаются. Связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · неполные данные",status_no_links:"Получено: {time} · подтверждённые маршруты не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"Устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство",route_device_not_on_plan_named:"Устройства нет на плане ({name})",route_unknown:"Нет данных о маршруте",route_stale:"Данные устарели",route_partial:"Неполные данные",status_cache:"Кеш интеграции; время радиосканирования неизвестно"};const e="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";export{a as dictionary,e as fingerprint};
|
||||
+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
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";import{n as t,d as n,e}from"./zigbee-topology-Cs-828J3.js";import{aP as o}from"./houseplan-card-pPShiPsn.js";import"./namespace-language-Cj9GZ2Dl.js";const i=new WeakMap;function r(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=i.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},i.set(n,e)),e}function a(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function c(t){return Object.assign(new Error(t),{code:t})}function l(t,n,e){const o=t.inflight.get(n);if(o)return o;const i=t.states[n],r=void 0===i?.obtainedAt?{}:{obtainedAt:i.obtainedAt,partial:i.partial,stale:i.stale};a(t,n,{...r,phase:"loading"});const l=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw c("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],a(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{a(t,n,{...r,...void 0===r.obtainedAt?{}:{stale:!0},phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,l),l}function u(t){const n=r(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function f(t,n){const e=r(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw c("permission")}function d(n){const e=r(n);return e?l(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw c("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function h(t){return null!==t&&"object"==typeof t?t:null}function m(t){const n=h(t),o=n?.payload??t;if("string"!=typeof o)return o;if(o.length>e)return null;try{return JSON.parse(o)}catch{return null}}async function g(t,n){if(n<=0)throw t.catch(()=>{}),c("timeout");let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(c("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function w(t,e,i=15e4){const a=r(t);if(!a)return Promise.resolve();const s=o(e);return l(a,`z2m:${s||String(e)}`,async()=>{if(p(t),!s)throw c("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw c("unsupported");const r=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),a=Date.now()+Math.max(1,i);let l=!1,u=!1,f=null,d=null,w=null;const y=new Promise(t=>{f=t}),b=new Promise((t,n)=>{d=t,w=n});b.catch(()=>{});const v=[],_=t=>{try{t()}catch{}},S=()=>!l&&Date.now()<a,P=async(t,n)=>{if(!S())throw c("timeout");const i=o.call(e,n,{type:"mqtt/subscribe",topic:`${s}/${t}`}).then(t=>{"function"==typeof t&&(l?_(t):v.push(t))});await g(i,a-Date.now())};try{if(await P("bridge/info",t=>{S()&&!0===h(t)?.retain&&m(t)&&f?.()}),await P("bridge/response/networkmap",t=>{if(!S()||!u||!0===h(t)?.retain)return;const n=m(t);null!==n?n&&function(t){const n=h(t),e=n?.transaction??h(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===r&&d?.(n):w?.(c("invalid_payload"))}),await g(y,Math.min(4e3,a-Date.now())),!S())throw c("timeout");u=!0;const e=t.callService("mqtt","publish",{topic:`${s}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!0,transaction:r}),qos:0,retain:!1}),[,o]=await g(Promise.all([e,b]),a-Date.now());if(!S())throw c("timeout");const i=h(o)?.status;if(i&&"ok"!==i)throw c("provider");return n(o,s)}finally{l=!0,u=!1;for(const t of v)_(t)}})}export{d as readZhaTopology,w as refreshZ2mTopology,f as subscribeZigbeeTopology,u as zigbeeTopologyRuntimeSnapshot};
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="dc3dfa138b65dd4dbfbcf5c445aaca4447e2f81c3885f5c63a9f380a1bfa2a1a";try{await import("./houseplan-assets/houseplan-card-pPShiPsn.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
@@ -2,7 +2,8 @@
|
||||
"domain": "houseplan",
|
||||
"name": "House Plan",
|
||||
"after_dependencies": [
|
||||
"lovelace"
|
||||
"lovelace",
|
||||
"mqtt"
|
||||
],
|
||||
"codeowners": [
|
||||
"@Matysh"
|
||||
@@ -20,5 +21,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.80.0-beta.1"
|
||||
"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
|
||||
@@ -112,6 +112,8 @@ class HouseplanData:
|
||||
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
|
||||
# Runtime-only #485 authority; never serialized by HouseplanStore.
|
||||
radar_coordinator: Any | None = None
|
||||
# #800: MQTT jobs and last-good maps live only for this loaded entry.
|
||||
zigbee_coordinator: Any | None = None
|
||||
|
||||
|
||||
HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
@@ -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,418 @@
|
||||
"""#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_by_ref.get(parent_refs.get(_ref(a), _ref(a)))
|
||||
old_b = old_by_ref.get(parent_refs.get(_ref(b), _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):
|
||||
# Preserve a parent's fixed junction when its child is new,
|
||||
# including a formerly collinear finite common endpoint.
|
||||
previous = None
|
||||
if old_a and old_b:
|
||||
previous = _intersection(old_a, old_b) or next(
|
||||
(end for end in [old_a["a"], old_a["b"]]
|
||||
if _same(end, hit) and _on(end, old_b)), 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,
|
||||
@@ -258,6 +259,7 @@ async def _resolved_write_pair(
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
"""Register the WS commands."""
|
||||
from .radar_websocket import async_register as async_register_radar
|
||||
from .zigbee_websocket import async_register as async_register_zigbee
|
||||
|
||||
websocket_api.async_register_command(hass, ws_layout_get)
|
||||
websocket_api.async_register_command(hass, ws_trail_get)
|
||||
@@ -269,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)
|
||||
@@ -288,6 +291,7 @@ def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_support_preview_discard)
|
||||
websocket_api.async_register_command(hass, ws_support_submit)
|
||||
async_register_radar(hass)
|
||||
async_register_zigbee(hass)
|
||||
|
||||
|
||||
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
|
||||
@@ -1331,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(
|
||||
@@ -1478,9 +1492,12 @@ 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 {}),
|
||||
**({"zigbee_scan_api": 1} if rt.zigbee_coordinator is not None
|
||||
and not rt.zigbee_coordinator.closed else {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1804,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 ----------------
|
||||
|
||||
|
||||
@@ -1865,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 = (
|
||||
@@ -1898,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
|
||||
@@ -1940,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 = (
|
||||
@@ -1980,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"
|
||||
@@ -2010,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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Bounded, entry-owned Zigbee2MQTT requests; never a polling radio scanner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from time import monotonic, time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
MAX_TOPICS = 8
|
||||
MAX_LISTENERS = 32
|
||||
MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
|
||||
MAX_NODES = 1000
|
||||
MAX_LINKS = 6000
|
||||
CANCEL_AFTER_MS = 600_000
|
||||
TRANSPORT_TIMEOUT = 10.0
|
||||
INFO_TIMEOUT = 4.0
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZigbeeScanError(Exception):
|
||||
"""A stable, already localized WebSocket command refusal."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def normalize_base_topic(value: object) -> str:
|
||||
"""Match the frontend's exact-topic normalization, without a saved-topic gate."""
|
||||
if not isinstance(value, str):
|
||||
raise ZigbeeScanError("invalid_data")
|
||||
topic = re.sub(r"/{2,}", "/", value.strip().strip("/"))
|
||||
# JS counts UTF-16 code units; reject lone surrogates as invalid MQTT UTF-8.
|
||||
try:
|
||||
length = len(topic.encode("utf-16-le")) // 2
|
||||
topic.encode("utf-8")
|
||||
except UnicodeError as err:
|
||||
raise ZigbeeScanError("invalid_data") from err
|
||||
if not topic or length > 180 or re.search(r"[#+\x00-\x1f\x7f]", topic):
|
||||
raise ZigbeeScanError("invalid_data")
|
||||
return topic
|
||||
|
||||
|
||||
def _parse_payload(payload: object) -> dict[str, Any] | None:
|
||||
"""Bound bytes before decoding; uncorrelatable garbage must not end a job."""
|
||||
try:
|
||||
if isinstance(payload, str):
|
||||
payload = payload.encode("utf-8")
|
||||
if not isinstance(payload, bytes) or len(payload) > MAX_PAYLOAD_BYTES:
|
||||
return None
|
||||
value = json.loads(payload)
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
pending = [(value, 0)]
|
||||
inspected = 0
|
||||
while pending:
|
||||
item, depth = pending.pop()
|
||||
inspected += 1
|
||||
if depth > 32 or inspected > 100_000:
|
||||
return None
|
||||
if isinstance(item, dict):
|
||||
pending.extend((child, depth + 1) for child in item.values())
|
||||
elif isinstance(item, list):
|
||||
pending.extend((child, depth + 1) for child in item)
|
||||
elif isinstance(item, float) and not math.isfinite(item):
|
||||
return None
|
||||
return value
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
return None
|
||||
|
||||
|
||||
def _valid_map(message: dict[str, Any]) -> bool:
|
||||
"""Check the raw-map shape, not routing evidence (that remains TypeScript)."""
|
||||
data = message.get("data")
|
||||
value = data.get("value", data) if isinstance(data, dict) else data
|
||||
if value is None:
|
||||
value = message.get("value", message)
|
||||
if isinstance(value, str):
|
||||
value = _parse_payload(value)
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
nodes, links = value.get("nodes"), value.get("links")
|
||||
if not isinstance(nodes, list) or len(nodes) > MAX_NODES:
|
||||
return False
|
||||
if not isinstance(links, list) or len(links) > MAX_LINKS:
|
||||
return False
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
return False
|
||||
ieee = node.get("ieeeAddr", node.get("ieee_address", node.get("ieee")))
|
||||
if not isinstance(ieee, str) or not re.fullmatch(
|
||||
r"(?:0x)?[0-9a-fA-F]{16}|(?:[0-9a-fA-F]{2}[:-]){7}[0-9a-fA-F]{2}", ieee,
|
||||
):
|
||||
return False
|
||||
routes_count = 0
|
||||
for link in links:
|
||||
if not isinstance(link, dict):
|
||||
return False
|
||||
for side in ("source", "target"):
|
||||
endpoint = link.get(f"{side}IeeeAddr", link.get(side))
|
||||
if isinstance(endpoint, bool) or not isinstance(endpoint, (dict, str, int)):
|
||||
return False
|
||||
if not endpoint and endpoint != 0:
|
||||
return False
|
||||
routes = link.get("routes", [])
|
||||
if not isinstance(routes, list) or any(not isinstance(route, dict) for route in routes):
|
||||
return False
|
||||
routes_count += len(routes)
|
||||
if routes_count > MAX_LINKS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Job:
|
||||
topic: str
|
||||
job_id: str
|
||||
started_at: int
|
||||
started: float
|
||||
info: asyncio.Future[None]
|
||||
phase: str = "loading"
|
||||
stage: str = "connecting"
|
||||
finished: float | None = None
|
||||
error: str | None = None
|
||||
stale: bool = False
|
||||
result: dict[str, Any] | None = None
|
||||
obtained_at: int | None = None
|
||||
response_active: bool = False
|
||||
task: asyncio.Task[None] | None = None
|
||||
cleanups: list[Callable[[], None]] = field(default_factory=list)
|
||||
|
||||
|
||||
class ZigbeeScanCoordinator:
|
||||
"""One in-memory slot per topic, shared by all browsers of the HA entry."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.session_id = str(uuid4())
|
||||
self.revision = 0
|
||||
self.closed = False
|
||||
self._jobs: dict[str, _Job] = {}
|
||||
self._listeners: set[Callable[[dict[str, Any]], None]] = set()
|
||||
self._operations: set[asyncio.Task] = set()
|
||||
|
||||
def _active(self, job: _Job) -> bool:
|
||||
return not self.closed and self._jobs.get(job.topic) is job and job.phase == "loading"
|
||||
|
||||
def _state(self, job: _Job) -> dict[str, Any]:
|
||||
provider: dict[str, Any] = {
|
||||
"topic": job.topic, "job_id": job.job_id, "phase": job.phase,
|
||||
"stage": job.stage, "started_at": job.started_at,
|
||||
"elapsed_ms": max(0, int(((job.finished if job.finished is not None else monotonic()) - job.started) * 1000)),
|
||||
"cancel_after_ms": CANCEL_AFTER_MS,
|
||||
}
|
||||
if job.error is not None:
|
||||
provider["error"] = job.error
|
||||
if job.result is not None:
|
||||
provider["result"] = job.result
|
||||
provider["obtained_at"] = job.obtained_at
|
||||
provider["stale"] = job.stale
|
||||
return {"kind": "state", "session_id": self.session_id,
|
||||
"revision": self.revision, "provider": provider}
|
||||
|
||||
def snapshot(self, topic: str) -> dict[str, Any]:
|
||||
"""Current public state; no MQTT work, no timestamp reset."""
|
||||
if self.closed:
|
||||
raise ZigbeeScanError("not_ready")
|
||||
job = self._jobs.get(normalize_base_topic(topic))
|
||||
if job is None:
|
||||
raise ZigbeeScanError("invalid_data")
|
||||
return self._state(job)
|
||||
|
||||
def initial_events(self) -> list[dict[str, Any]]:
|
||||
"""Bound each event to one map; never aggregate eight maps into one frame."""
|
||||
return [{"kind": "reset", "session_id": self.session_id,
|
||||
"revision": self.revision, "topics": list(self._jobs)},
|
||||
*(self._state(job) for job in self._jobs.values())]
|
||||
|
||||
def add_listener(self, listener: Callable[[dict[str, Any]], None]) -> Callable[[], None]:
|
||||
if self.closed:
|
||||
raise ZigbeeScanError("not_ready")
|
||||
if len(self._listeners) >= MAX_LISTENERS:
|
||||
raise ZigbeeScanError("capacity_exceeded")
|
||||
self._listeners.add(listener)
|
||||
return lambda: self._listeners.discard(listener)
|
||||
|
||||
def _emit(self, event: dict[str, Any]) -> None:
|
||||
for listener in tuple(self._listeners):
|
||||
try:
|
||||
listener(event)
|
||||
except Exception: # noqa: BLE001 - one disconnected UI must not interrupt a shared job
|
||||
self._listeners.discard(listener)
|
||||
|
||||
def _changed(self, job: _Job) -> None:
|
||||
self.revision += 1
|
||||
self._emit(self._state(job))
|
||||
|
||||
def start(self, base_topic: str) -> dict[str, Any]:
|
||||
"""Reserve before creating any task: concurrent WS clients join one job."""
|
||||
if self.closed:
|
||||
raise ZigbeeScanError("not_ready")
|
||||
topic = normalize_base_topic(base_topic)
|
||||
previous = self._jobs.get(topic)
|
||||
if previous is not None and previous.phase == "loading":
|
||||
return self._state(previous)
|
||||
# A transport which acknowledges cancellation late still owns a bounded
|
||||
# slot until its cleanup returns; repeated failed starts cannot grow it.
|
||||
if len(self._operations) >= MAX_TOPICS:
|
||||
raise ZigbeeScanError("capacity_exceeded")
|
||||
if previous is None and len(self._jobs) >= MAX_TOPICS:
|
||||
candidate = next((item for item in self._jobs.values() if item.phase != "loading"), None)
|
||||
if candidate is None:
|
||||
raise ZigbeeScanError("capacity_exceeded")
|
||||
del self._jobs[candidate.topic]
|
||||
self.revision += 1
|
||||
self._emit({"kind": "removed", "session_id": self.session_id,
|
||||
"revision": self.revision, "topic": candidate.topic})
|
||||
job = _Job(topic, f"houseplan-{uuid4()}", int(time() * 1000), monotonic(),
|
||||
self.hass.loop.create_future(),
|
||||
stale=previous.stale if previous else False,
|
||||
result=previous.result if previous else None,
|
||||
obtained_at=previous.obtained_at if previous else None)
|
||||
self._jobs.pop(topic, None)
|
||||
self._jobs[topic] = job
|
||||
job.task = self.entry.async_create_background_task(
|
||||
self.hass, self._run(job), "House Plan Zigbee map", eager_start=False,
|
||||
)
|
||||
self._changed(job)
|
||||
return self._state(job)
|
||||
|
||||
def cancel(self, base_topic: str, job_id: str) -> dict[str, Any]:
|
||||
if self.closed:
|
||||
raise ZigbeeScanError("not_ready")
|
||||
topic = normalize_base_topic(base_topic)
|
||||
job = self._jobs.get(topic)
|
||||
if job is None or job.job_id != job_id:
|
||||
raise ZigbeeScanError("conflict")
|
||||
# A repeated cancellation or a success/cancel race never rolls back a final state.
|
||||
if job.phase != "loading":
|
||||
return self._state(job)
|
||||
if (monotonic() - job.started) * 1000 < CANCEL_AFTER_MS:
|
||||
raise ZigbeeScanError("conflict")
|
||||
self._finish(job, "cancelled")
|
||||
return self._state(job)
|
||||
|
||||
@staticmethod
|
||||
def _cleanup(cleanup: Callable[[], None]) -> None:
|
||||
try:
|
||||
cleanup()
|
||||
except Exception: # noqa: BLE001 - cleanup remains idempotent across unload and transport failure
|
||||
_LOGGER.debug("House Plan Zigbee transport cleanup failed")
|
||||
|
||||
def _add_cleanup(self, job: _Job, cleanup: Callable[[], None]) -> None:
|
||||
if self._active(job):
|
||||
job.cleanups.append(cleanup)
|
||||
else:
|
||||
self._cleanup(cleanup)
|
||||
|
||||
def _release(self, job: _Job) -> None:
|
||||
job.response_active = False
|
||||
cleanups, job.cleanups = job.cleanups, []
|
||||
for cleanup in cleanups:
|
||||
self._cleanup(cleanup)
|
||||
if not job.info.done():
|
||||
job.info.cancel()
|
||||
|
||||
def _finish(self, job: _Job, phase: str, error: str | None = None,
|
||||
result: dict[str, Any] | None = None) -> None:
|
||||
if not self._active(job):
|
||||
return
|
||||
job.phase, job.error, job.finished = phase, error, monotonic()
|
||||
if result is not None:
|
||||
job.result, job.obtained_at = result, int(time() * 1000)
|
||||
job.stale = False
|
||||
elif job.result is not None:
|
||||
job.stale = True
|
||||
self._release(job)
|
||||
# Including when called synchronously inside publish: a terminal provider
|
||||
# reply must not wait another ten seconds for the publish acknowledgement.
|
||||
if job.task is not None and not job.task.done():
|
||||
job.task.cancel()
|
||||
self._changed(job)
|
||||
|
||||
async def _operation(self, job: _Job, action: Callable[[], Awaitable],
|
||||
seconds: float, *, subscription: bool = False):
|
||||
"""Bound transport even if a cancelled subscribe later returns an unsubscribe."""
|
||||
async def perform():
|
||||
result = await action()
|
||||
if subscription and callable(result):
|
||||
self._add_cleanup(job, result)
|
||||
return result
|
||||
|
||||
task = self.entry.async_create_background_task(
|
||||
self.hass, perform(), "House Plan Zigbee transport", eager_start=False,
|
||||
)
|
||||
self._operations.add(task)
|
||||
|
||||
@callback
|
||||
def settled(done: asyncio.Task) -> None:
|
||||
self._operations.discard(done)
|
||||
if not done.cancelled():
|
||||
done.exception() # consume a late transport exception after the job ended
|
||||
|
||||
task.add_done_callback(settled)
|
||||
try:
|
||||
done, _pending = await asyncio.wait({task}, timeout=max(0, seconds))
|
||||
if not done:
|
||||
raise TimeoutError
|
||||
return task.result()
|
||||
finally:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
async def _subscribe(self, job: _Job, topic: str, handler: Callable) -> None:
|
||||
deadline = self.hass.loop.time() + TRANSPORT_TIMEOUT
|
||||
await self._operation(job, lambda: mqtt.async_subscribe(
|
||||
self.hass, topic, handler, qos=0, encoding=None,
|
||||
), TRANSPORT_TIMEOUT, subscription=True)
|
||||
# Optional on the declared HA floor (2024.6). This acknowledges HA's
|
||||
# processing of subscriptions, not provider acceptance or radio progress.
|
||||
processed = getattr(mqtt, "async_on_subscribe_done", None)
|
||||
if callable(processed) and self._active(job):
|
||||
ready = self.hass.loop.create_future()
|
||||
|
||||
@callback
|
||||
def on_ready() -> None:
|
||||
if not ready.done():
|
||||
ready.set_result(None)
|
||||
|
||||
remove = processed(self.hass, topic, 0, on_ready)
|
||||
self._add_cleanup(job, remove)
|
||||
async with asyncio.timeout(max(0, deadline - self.hass.loop.time())):
|
||||
await ready
|
||||
|
||||
async def _run(self, job: _Job) -> None:
|
||||
try:
|
||||
if not mqtt.mqtt_config_entry_enabled(self.hass):
|
||||
self._finish(job, "error", "unsupported")
|
||||
return
|
||||
if not mqtt.is_connected(self.hass):
|
||||
self._finish(job, "error", "connection")
|
||||
return
|
||||
|
||||
@callback
|
||||
def connection_changed(connected: bool) -> None:
|
||||
if not connected:
|
||||
self._finish(job, "error", "connection")
|
||||
|
||||
self._add_cleanup(job, mqtt.async_subscribe_connection_status(
|
||||
self.hass, connection_changed,
|
||||
))
|
||||
response_topic = f"{job.topic}/bridge/response/networkmap"
|
||||
info_topic = f"{job.topic}/bridge/info"
|
||||
|
||||
@callback
|
||||
def receive_response(message) -> None:
|
||||
if (not self._active(job) or not job.response_active
|
||||
or message.topic != response_topic or message.retain):
|
||||
return
|
||||
value = _parse_payload(message.payload)
|
||||
if value is None:
|
||||
return
|
||||
data = value.get("data")
|
||||
transaction = value.get("transaction", data.get("transaction") if isinstance(data, dict) else None)
|
||||
if transaction != job.job_id:
|
||||
return
|
||||
if value.get("status") not in (None, "ok"):
|
||||
self._finish(job, "error", "provider")
|
||||
elif not _valid_map(value):
|
||||
self._finish(job, "error", "invalid_payload")
|
||||
else:
|
||||
self._finish(job, "ready", result=value)
|
||||
|
||||
@callback
|
||||
def receive_info(message) -> None:
|
||||
if (self._active(job) and message.topic == info_topic and message.retain
|
||||
and not job.info.done() and _parse_payload(message.payload) is not None):
|
||||
job.info.set_result(None)
|
||||
|
||||
# Response first also improves the old-HA retained-info preflight;
|
||||
# retained info alone is not claimed to be a strict broker SUBACK.
|
||||
await self._subscribe(job, response_topic, receive_response)
|
||||
await self._subscribe(job, info_topic, receive_info)
|
||||
async with asyncio.timeout(INFO_TIMEOUT):
|
||||
await job.info
|
||||
if not self._active(job):
|
||||
return
|
||||
job.response_active = True
|
||||
await self._operation(job, lambda: mqtt.async_publish(
|
||||
self.hass, f"{job.topic}/bridge/request/networkmap",
|
||||
json.dumps({"type": "raw", "routes": True, "transaction": job.job_id}),
|
||||
qos=0, retain=False,
|
||||
), TRANSPORT_TIMEOUT)
|
||||
if self._active(job):
|
||||
job.stage = "waiting"
|
||||
self._changed(job)
|
||||
# Only MQTT, explicit cancel, transport loss or entry teardown
|
||||
# ends this wait. 600 seconds is a cancel threshold, not a deadline.
|
||||
await self.hass.loop.create_future()
|
||||
except asyncio.CancelledError:
|
||||
if self._active(job):
|
||||
self._finish(job, "error", "connection")
|
||||
raise
|
||||
except TimeoutError:
|
||||
self._finish(job, "error", "timeout")
|
||||
except Exception: # noqa: BLE001 - contain optional MQTT errors without logging network payloads
|
||||
self._finish(job, "error", "connection")
|
||||
finally:
|
||||
self._release(job)
|
||||
|
||||
async def async_close(self) -> None:
|
||||
"""Stop entry-owned work, clear volatile maps, never restart a radio scan."""
|
||||
if self.closed:
|
||||
return
|
||||
self.closed = True
|
||||
# Reload does not disconnect HA WebSockets. Tell their observers that
|
||||
# this session and its volatile cache are gone before dropping them.
|
||||
self.revision += 1
|
||||
self._emit({"kind": "closed", "session_id": self.session_id,
|
||||
"revision": self.revision})
|
||||
tasks = [job.task for job in self._jobs.values() if job.task is not None]
|
||||
for job in self._jobs.values():
|
||||
self._release(job)
|
||||
for task in (*tasks, *self._operations):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._jobs.clear()
|
||||
self._listeners.clear()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Admin-only WebSocket observers and explicit actions for background Z2M jobs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .store import get_data
|
||||
from .zigbee_topology import ZigbeeScanCoordinator, ZigbeeScanError
|
||||
|
||||
|
||||
def _coordinator(hass: HomeAssistant, connection, msg_id: int) -> ZigbeeScanCoordinator | None:
|
||||
runtime = get_data(hass)
|
||||
coordinator = getattr(runtime, "zigbee_coordinator", None)
|
||||
if not isinstance(coordinator, ZigbeeScanCoordinator) or coordinator.closed:
|
||||
connection.send_error(msg_id, "not_ready", "not_ready")
|
||||
return None
|
||||
return coordinator
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_zigbee_subscribe)
|
||||
websocket_api.async_register_command(hass, ws_zigbee_start)
|
||||
websocket_api.async_register_command(hass, ws_zigbee_cancel)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/zigbee/subscribe"})
|
||||
@websocket_api.require_admin
|
||||
def ws_zigbee_subscribe(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
coordinator = _coordinator(hass, connection, msg["id"])
|
||||
if coordinator is None:
|
||||
return
|
||||
|
||||
@callback
|
||||
def publish(event: dict[str, Any]) -> None:
|
||||
# Permission can be revoked during a long-lived browser session.
|
||||
if connection.user is None or not connection.user.is_admin:
|
||||
remove()
|
||||
connection.subscriptions.pop(msg["id"], None)
|
||||
return
|
||||
connection.send_event(msg["id"], event)
|
||||
|
||||
try:
|
||||
remove = coordinator.add_listener(publish)
|
||||
except ZigbeeScanError as err:
|
||||
connection.send_error(msg["id"], err.code, err.code)
|
||||
return
|
||||
# HA calls this on unsubscribe/disconnect. It never cancels the shared job.
|
||||
connection.subscriptions[msg["id"]] = remove
|
||||
connection.send_result(msg["id"], {"session_id": coordinator.session_id,
|
||||
"revision": coordinator.revision})
|
||||
for event in coordinator.initial_events():
|
||||
publish(event)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): "houseplan/zigbee/start",
|
||||
vol.Required("base_topic"): vol.All(str, vol.Length(min=1, max=1024)),
|
||||
})
|
||||
@websocket_api.require_admin
|
||||
def ws_zigbee_start(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
coordinator = _coordinator(hass, connection, msg["id"])
|
||||
if coordinator is None:
|
||||
return
|
||||
try:
|
||||
result = coordinator.start(msg["base_topic"])
|
||||
except ZigbeeScanError as err:
|
||||
connection.send_error(msg["id"], err.code, err.code)
|
||||
return
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command({
|
||||
vol.Required("type"): "houseplan/zigbee/cancel",
|
||||
vol.Required("base_topic"): vol.All(str, vol.Length(min=1, max=1024)),
|
||||
vol.Required("job_id"): vol.All(str, vol.Length(min=1, max=80)),
|
||||
})
|
||||
@websocket_api.require_admin
|
||||
def ws_zigbee_cancel(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||
coordinator = _coordinator(hass, connection, msg["id"])
|
||||
if coordinator is None:
|
||||
return
|
||||
try:
|
||||
result = coordinator.cancel(msg["base_topic"], msg["job_id"])
|
||||
except ZigbeeScanError as err:
|
||||
connection.send_error(msg["id"], err.code, err.code)
|
||||
return
|
||||
connection.send_result(msg["id"], result)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user