Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
370df0708c | ||
|
|
bcc68f0da2 | ||
|
|
574ae94840 | ||
|
|
90e8ea4685 | ||
|
|
ee351dcb84 | ||
|
|
2b841d94e4 | ||
|
|
c175a2ab1a | ||
|
|
020739ebca | ||
|
|
ffdd69e277 | ||
|
|
319ee70010 | ||
|
|
5c893c7ff3 | ||
|
|
077e9c1b1b | ||
|
|
0471482c2e | ||
|
|
db887cd7f0 | ||
|
|
7feda5b6c8 | ||
|
|
285a6dc436 | ||
|
|
52876b0573 | ||
|
|
cdfe02e64d | ||
|
|
07c885435a | ||
|
|
67af859e8c | ||
|
|
4a7abf531b | ||
|
|
379de706d2 | ||
|
|
1faed2af7d | ||
|
|
343449e694 | ||
|
|
9722153e01 | ||
|
|
0f77b921f6 | ||
|
|
e69b3f2009 | ||
|
|
7341ecf9c5 | ||
|
|
fd406acea2 | ||
|
|
33022ff946 | ||
|
|
55ac10be3c | ||
|
|
22828495bb | ||
|
|
370c082f31 | ||
|
|
0d64547643 | ||
|
|
2ac221d28b | ||
|
|
d19bac3966 | ||
|
|
aef02229c6 | ||
|
|
865312e0b0 | ||
|
|
841bad4e79 | ||
|
|
9ebc430983 | ||
|
|
0a098c1e51 | ||
|
|
cf1c90fb5b | ||
|
|
7fb20c658b | ||
|
|
05c8e164c2 | ||
|
|
9deb1dcfd6 | ||
|
|
a5e06b0527 | ||
|
|
14f5b90c24 | ||
|
|
4de49e0db2 | ||
|
|
80cba30895 | ||
|
|
96f89717bd | ||
|
|
d8949cfffc | ||
|
|
7777b8a0a8 | ||
|
|
45aeadb38e | ||
|
|
b07ef67dcb | ||
|
|
d58a5d0dad | ||
|
|
181b61ae8e | ||
|
|
24e48935c3 | ||
|
|
6b0d7d66dd | ||
|
|
0588fa8f0a | ||
|
|
019cfa5c5a | ||
|
|
5fff48f7b1 | ||
|
|
a74a50d331 | ||
|
|
416d2fae71 | ||
|
|
08bc58cc0e | ||
|
|
c142e4f7b5 | ||
|
|
ad10a5b099 | ||
|
|
76310fc981 | ||
|
|
0f466743c3 | ||
|
|
35ef231191 | ||
|
|
fab28af26f | ||
|
|
a54429d4a3 | ||
|
|
9598768167 | ||
|
|
aa1105468b | ||
|
|
42d1a8a9dc | ||
|
|
8b88a6f606 | ||
|
|
4c7e9815f3 | ||
|
|
1deb29e163 | ||
|
|
3048ac9195 | ||
|
|
654cb9d404 | ||
|
|
8dbfe5e6b2 | ||
|
|
706eb02d70 | ||
|
|
4962de1d5c | ||
|
|
31b4ddda55 | ||
|
|
072fb108cf | ||
|
|
24671d4358 | ||
|
|
6607a34973 | ||
|
|
e9e6eba5d1 | ||
|
|
a20ef2b9b0 | ||
|
|
e447364636 | ||
|
|
b40e8ff58d | ||
|
|
ef3e7a5440 | ||
|
|
403bcca680 | ||
|
|
91b57ae60e | ||
|
|
3d2c5ab3ee | ||
|
|
5d3f29c249 | ||
|
|
ec9d968c8a | ||
|
|
8915e49a58 | ||
|
|
d515c42177 | ||
|
|
ade3078d53 | ||
|
|
d21fba11d6 | ||
|
|
ed238059e1 | ||
|
|
8237bbc1cc | ||
|
|
05d62f053a | ||
|
|
3a1cd7b1ad | ||
|
|
fb3071d3c1 | ||
|
|
870237f3fb | ||
|
|
9fa5efbdac | ||
|
|
73b0cfff5b | ||
|
|
13d8df5554 | ||
|
|
9e1a2f4a07 | ||
|
|
16069d1dcc | ||
|
|
23f822475b | ||
|
|
ceee249210 | ||
|
|
2022e183a5 |
@@ -8,15 +8,14 @@ name: "Мутационный гейт · тело (#623)"
|
||||
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
|
||||
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
|
||||
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
|
||||
# Мутанты, задетые диффом, конвейер ревью и слияние гоняют отдельно на своём
|
||||
# кандидате (#510); кандидат беты, `full=true` и ночной Validate их не
|
||||
# запрашивают (#601) — ночью достаточно этого полного реестра.
|
||||
# С #709 ревью, слияние, кандидат беты, `full=true` и ночной Validate
|
||||
# мутантов не запрашивают — ночью достаточно этого полного реестра.
|
||||
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
|
||||
# обычными юнитами: test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
|
||||
# tsc), а реестр режется на чересполосные шарды — полный прогон
|
||||
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
|
||||
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
|
||||
#
|
||||
@@ -110,20 +109,20 @@ jobs:
|
||||
fi
|
||||
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)"
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 10)"
|
||||
needs: material
|
||||
if: needs.material.outputs.reuse != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
# Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из
|
||||
# 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард
|
||||
# 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у
|
||||
# `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`,
|
||||
# `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их
|
||||
# равными. Бандл собирают только браузерные гварды. Час — потолок против
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
# #795: ночь 05.10 (1164 мутанта) заняла 42–60 мин на шести шардах;
|
||||
# четвёртый отменён по потолку. Десять шардов оставляют 116–117 мутантов
|
||||
# вместо 194. Оценка по длительностям той ночи — максимум около 39 мин
|
||||
# с запасом на setup; это прогноз, фактическое время проверяет следующая ночь.
|
||||
# Матрицу, делители runner/evidence и имя job держит mutation-gate.test.mjs.
|
||||
# Бандл собирают только браузерные гварды. Час — потолок против
|
||||
# зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал
|
||||
# снова делить, и отчёт (#472) называет такой шард прерванным, не «ok».
|
||||
timeout-minutes: 60
|
||||
@@ -178,7 +177,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p artifacts/mutation-shard-${{ matrix.shard }}
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/10 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
|
||||
# Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт
|
||||
# `cancelled`, и агрегатор отвергает такой шард как неполный — лог без
|
||||
# строк FAIL сам по себе зелёным не считается.
|
||||
@@ -191,7 +190,7 @@ jobs:
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ job.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--shard=${{ matrix.shard }} --shards=6 \
|
||||
--shard=${{ matrix.shard }} --shards=10 \
|
||||
--outcome=${{ steps.gate.outcome }}
|
||||
- name: Сохранить лог и identity шарда
|
||||
if: always()
|
||||
@@ -202,7 +201,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Результат нельзя приписывать material, пока не доказаны все шесть шардов —
|
||||
# Результат нельзя приписывать material, пока не доказаны все десять шардов —
|
||||
# каждый с identity и с дошедшим до конца прогоном (#549, #604).
|
||||
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
|
||||
evidence:
|
||||
@@ -228,13 +227,13 @@ jobs:
|
||||
- name: Проверить полноту и identity
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs --verify-only \
|
||||
--logs=artifacts/mutation-logs --shards=6 \
|
||||
--logs=artifacts/mutation-logs --shards=10 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ job.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }}
|
||||
|
||||
# #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все шесть шардов
|
||||
# #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все десять шардов
|
||||
# доказаны на одном material. Красный или неполный прогон маркера не оставляет,
|
||||
# и следующая ночь гонит реестр заново.
|
||||
green_marker:
|
||||
@@ -306,7 +305,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--require-evidence --logs=artifacts/mutation-logs --shards=6 \
|
||||
--require-evidence --logs=artifacts/mutation-logs --shards=10 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ job.workflow_sha }} \
|
||||
|
||||
@@ -83,7 +83,9 @@ jobs:
|
||||
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
|
||||
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
labels=$(printf '%s\n' "$current" | paste -sd, -)
|
||||
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
|
||||
# Не grep -q: раннее закрытие pipe даёт printf SIGPIPE, а pipefail
|
||||
# превращает найденную метку в ложный отказ (#793). Читаем до конца.
|
||||
has() { printf '%s\n' "$current" | grep -Fx -- "$1" > /dev/null; }
|
||||
BLOCKED=false; EXHAUSTED=false
|
||||
has blocked && BLOCKED=true
|
||||
has review-4 && EXHAUSTED=true
|
||||
@@ -331,6 +333,9 @@ jobs:
|
||||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
# #765: SHA снимка скриптов конвейера — одна версия на заход: model_review
|
||||
# снимает расход скриптом из того же коммита dev.
|
||||
tools_sha: ${{ steps.tools.outputs.sha }}
|
||||
track: ${{ steps.track.outputs.track }}
|
||||
mutants: ${{ steps.track.outputs.mutants }}
|
||||
full: ${{ steps.track.outputs.full }}
|
||||
@@ -383,6 +388,29 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# #765 (продолжение #749): тело читается из dev (@dev, #623), значит, и
|
||||
# управляющие скрипты подготовки — из dev. Рабочая копия после «Перейти на
|
||||
# ветку задачи» — материал: ветка show/ship с чистым слиянием до ревью не
|
||||
# ребейзится (§10.4) и может нести scripts/, отставшие на дни или
|
||||
# подменённые. Раньше якорь тела issue, reuse (#499), Validate-gate (#510)
|
||||
# и проверку ТЗ (#517) исполняли скрипты ветки — материал сам участвовал в
|
||||
# решении о своём допуске к ревью. Снимок один на заход: SHA закрепляется
|
||||
# здесь, до любого git fetch трека и ребейза, и выходом `tools_sha` уходит
|
||||
# в model_review. validate.yml в снимке — по той же причине, что в #749:
|
||||
# ci-proof.mjs (validate-gate) читает его по пути от себя. Git, дифф и
|
||||
# документы по-прежнему судятся в рабочей копии — снимок только исполняет.
|
||||
- name: Скрипты конвейера — из dev (#765)
|
||||
id: tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
sha=$(git rev-parse origin/dev)
|
||||
tools="$RUNNER_TEMP/dev-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive "$sha" scripts .github/workflows/validate.yml | tar -x -C "$tools"
|
||||
{ echo "dir=$tools"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT"
|
||||
echo "скрипты конвейера: dev ${sha:0:12}"
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
|
||||
@@ -423,9 +451,9 @@ jobs:
|
||||
# ОДИН вызов process-track.mjs по меткам, диффу и комментариям; bash только
|
||||
# исполняет: пишет комментарий из файла скрипта и меняет метки по его
|
||||
# флагу. Выходы (`track`, `mutants`, `full`, `ship`, `risk_note`,
|
||||
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт берётся из dev, как
|
||||
# помощник ребейза: show/ship до ревью не ребейзятся, и ветка, отставшая от
|
||||
# dev, его может ещё не нести.
|
||||
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт — из снимка dev
|
||||
# (#765): show/ship до ревью не ребейзятся, и ветка, отставшая от dev, его
|
||||
# может ещё не нести.
|
||||
- name: "Трек задачи и рамки ship (#696)"
|
||||
id: track
|
||||
env:
|
||||
@@ -435,17 +463,15 @@ jobs:
|
||||
LABELS: ${{ needs.guard.outputs.labels }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
tools="$RUNNER_TEMP/track-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
# Комментарии — для строки владельца «Трек: <x> — решение владельца»
|
||||
# (#707). Не прочитаны — пустой файл: происхождение не установлено.
|
||||
comments="$RUNNER_TEMP/track-comments.json"
|
||||
gh issue view "$NUM" --repo "${{ github.repository }}" --json comments > "$comments" 2>/dev/null \
|
||||
|| : > "$comments"
|
||||
out=$(node "$tools/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
|
||||
out=$(node "$TOOLS/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
|
||||
--branch="$BRANCH" --base=origin/dev --head=HEAD --comments="$comments" --owner="$OWNER" \
|
||||
--out="$RUNNER_TEMP/track" \
|
||||
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")
|
||||
@@ -479,6 +505,7 @@ jobs:
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
TRACK: ${{ steps.track.outputs.track }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
@@ -515,13 +542,10 @@ jobs:
|
||||
# сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если
|
||||
# ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом
|
||||
# другом. Список конфликтов он снимает ДО abort (#364) и печатает в
|
||||
# stdout по строке. Помощник берётся из dev, а не из ветки: ветка,
|
||||
# отставшая от dev, его ещё не несёт.
|
||||
tools="$RUNNER_TEMP/rebase-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
# stdout по строке. Помощник — из снимка dev (#765), а не из ветки:
|
||||
# ветка, отставшая от dev, его ещё не несёт.
|
||||
code=0
|
||||
files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
|
||||
files=$(node "$TOOLS/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
|
||||
# Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой —
|
||||
# сбой самого помощника, конфликтом ветки он не выдаётся.
|
||||
if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then
|
||||
@@ -564,7 +588,7 @@ jobs:
|
||||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"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" \
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
|
||||
--branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \
|
||||
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
--comment="$refusal" --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
@@ -621,6 +645,7 @@ jobs:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
@@ -646,7 +671,8 @@ jobs:
|
||||
body=$(mktemp)
|
||||
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
|
||||
digest=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { pathToFileURL } = require("node:url");
|
||||
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
|
||||
});
|
||||
@@ -676,8 +702,9 @@ jobs:
|
||||
# зелёного вердикта: иначе вызов модели пропускается и находку
|
||||
# «ТЗ менялось» некому напечатать (#517 AC6).
|
||||
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
--issue-body="${ISSUE_BODY}")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
@@ -757,6 +784,7 @@ jobs:
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
# #697: `ci:full`/`ci:golden` — полный набор на материале.
|
||||
FULL: ${{ steps.track.outputs.full }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||||
@@ -770,7 +798,7 @@ jobs:
|
||||
# S7), страховка — process-reconcile. Зелёный или красный завершённый
|
||||
# прогон гейт и без ожидания возвращает сразу.
|
||||
set +e
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
node "$TOOLS/scripts/validate-gate.mjs" --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
--mutants="${MUTANTS:-false}" --full="${FULL:-false}"
|
||||
code=$?
|
||||
set -e
|
||||
@@ -899,13 +927,15 @@ jobs:
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ -z "$DIGEST" ]; then
|
||||
echo "хеша тела нет — сравнивать не с чем"
|
||||
exit 0
|
||||
fi
|
||||
out=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { pathToFileURL } = require("node:url");
|
||||
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const [num, digest] = process.argv.slice(1);
|
||||
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
|
||||
@@ -1001,8 +1031,8 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
needs: [guard, prepare]
|
||||
# #696: `ship` в рамках сливается без модели — пакетное ревью диапазона
|
||||
# перед бетой (PROCESS.md §11.7, ship-review.yml).
|
||||
# #696/#727: `ship` в рамках сливается без модели — пакетное ревью ночью;
|
||||
# перед бетой проверка покрытия и непрочитанная дельта (PROCESS.md §11.7).
|
||||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
@@ -1312,14 +1342,33 @@ jobs:
|
||||
# execution_file — все сообщения сессии, с результатами инструментов —
|
||||
# не выгружается и не печатается: скрипт берёт из него только `result`.
|
||||
# `always()` — расход упавшей сессии хотя бы виден в сводке.
|
||||
#
|
||||
# #765: рабочая копия здесь — материал ревью. На отставшей ветке show/ship
|
||||
# model-usage.mjs нет, и публикация молча писала `reason=missing`; ветка
|
||||
# могла и подменить скрипт. Скрипт берётся из того же коммита dev, что и
|
||||
# снимок подготовки (`tools_sha`). Снимок извлекается здесь, а не отдельным
|
||||
# шагом до Review: его сбой — сбой отчётного шага (continue-on-error), а не
|
||||
# всей стадии. От сессии модели, исполнявшейся на этом раннере, снимок не
|
||||
# защищает — поэтому строка остаётся недоверенным вводом и разбирается
|
||||
# строго при публикации (#556, #737).
|
||||
- name: Снять расход модели
|
||||
id: usage
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
EXEC: ${{ steps.review.outputs.execution_file }}
|
||||
TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }}
|
||||
run: |
|
||||
line=$(node scripts/model-usage.mjs --execution-file="$EXEC")
|
||||
set -euo pipefail
|
||||
if [ -z "$TOOLS_SHA" ]; then
|
||||
echo "::error::нет SHA снимка скриптов из prepare — расход не снят"
|
||||
exit 1
|
||||
fi
|
||||
tools="$RUNNER_TEMP/dev-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git cat-file -e "${TOOLS_SHA}^{commit}" 2>/dev/null || git fetch -q origin "$TOOLS_SHA"
|
||||
git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"
|
||||
line=$(node "$tools/scripts/model-usage.mjs" --execution-file="$EXEC")
|
||||
echo "line=$line" >> "$GITHUB_OUTPUT"
|
||||
echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
name: "Бета: пакетное ревью ship · тело (#716)"
|
||||
|
||||
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
|
||||
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
|
||||
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
|
||||
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
|
||||
# диапазоне есть ship-задачи.
|
||||
# правка в механических рамках и зелёный лёгкий Validate. Их код читается
|
||||
# пакетно ночью; перед бетой — только непрочитанные или изменённые задачи.
|
||||
# Гейт публикации беты (`ship-review.mjs check`) требует покрытие всех ship-задач
|
||||
# диапазона «прошлый тег..кандидат»; ночного документа с тем же патч-набором
|
||||
# достаточно, отдельный документ тега беты нужен не всегда (#727).
|
||||
#
|
||||
# Три job, как у release-review.yml (#551, #556): детерминированный вход,
|
||||
# модель без права записи, детерминированная публикация документа в `dev`.
|
||||
@@ -375,7 +376,11 @@ jobs:
|
||||
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
|
||||
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$MODE" = "nightly" ]; then
|
||||
echo "### Ночное пакетное ревью ship" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "### Предрелизное пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
|
||||
exit 0
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
- overlay
|
||||
- space-default
|
||||
- space-glow
|
||||
- led-strips
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -175,6 +176,14 @@ jobs:
|
||||
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
|
||||
fi
|
||||
;;
|
||||
led-strips)
|
||||
# #780 led-strips-v1: base predates LED strips, so the profile is
|
||||
# judged by its absolute limits inside the runner; the zero-LED
|
||||
# View stays covered by the relative interaction profile above.
|
||||
npm run benchmark:led-strips -- --size=10x5 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-10x5.json
|
||||
npm run benchmark:led-strips -- --size=50x50 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-50x50.json
|
||||
npm run benchmark:led-strips -- --size=none --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-none.json
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown performance profile: $PROFILE"
|
||||
exit 1
|
||||
@@ -217,6 +226,9 @@ jobs:
|
||||
space-glow)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
|
||||
;;
|
||||
led-strips)
|
||||
echo "led-strips-v1: absolute limits and counters already enforced by the runner (budgets-led-strips.json)"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance report
|
||||
|
||||
@@ -608,14 +608,15 @@ patch-id кандидата слияния: вердикт к работе за
|
||||
| ТЗ | строка «что меняется и чем проверить» в теле issue под `## ТЗ` | «Оценка» (§7.2) и до трёх AC в теле issue под `## ТЗ` | полное ТЗ по §7.1 |
|
||||
| Ревью ТЗ | нет | нет | да, лимит 4 цикла |
|
||||
| Локальный гейт | `npm run gate:small` | `npm run gate:small` плюс смоуки `smoke-select` и AC | §8 |
|
||||
| Код-ревью | до слияния нет; пакетное ревью диапазона перед бетой | модель: корректность и AC | документ ревью, как в §2.7 |
|
||||
| Код-ревью | до слияния нет; пакетное ревью ночью, перед бетой — проверка покрытия и непрочитанная дельта (§11.7) | модель: корректность и AC | документ ревью, как в §2.7 |
|
||||
| Лимит циклов код-ревью | — | 2 | 4 |
|
||||
|
||||
**Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых
|
||||
файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за
|
||||
рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели
|
||||
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
|
||||
диапазона перед бетой (§11.7).
|
||||
ночью заранее; перед бетой проверяется покрытие и читается непрочитанная дельта
|
||||
(§11.7).
|
||||
|
||||
**Проверка риска по изменённым участкам** (#707). Рамки считают строки и файлы,
|
||||
но не видят, что именно тронуто: обработчик `pointerdown` в двенадцать строк
|
||||
@@ -675,7 +676,8 @@ patch-id кандидата слияния: вердикт к работе за
|
||||
`ux-contract`, `perf-touch`, `undocumented`. Шаг решения по вердикту ставит
|
||||
`track:ask`, снимает `track:show` и переводит задачу в `S3-spec` комментарием:
|
||||
критерий словами, документы код-ревью, бюджет. Код остаётся в ветке, полное ТЗ
|
||||
пишется в теле issue, код класса A не пушится до `S5`; бюджет код-ревью не
|
||||
пишется в теле issue; до `S5` запрещён push всей ветки, включая тесты и документы
|
||||
(§11.8), а не только кода класса A; бюджет код-ревью не
|
||||
обнуляется (§4). Подтверждённый владельцем `show` конвейер не повышает: задача
|
||||
получает `blocked` и вопрос владельцу с вариантом по умолчанию «повысить до
|
||||
`ask`». `reclassify` без критерия из списка, на `ask` или на ревью ТЗ —
|
||||
@@ -1327,16 +1329,27 @@ Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly`
|
||||
origin/dev scripts .github/workflows/validate.yml` и зовёт из него каждый
|
||||
repo-скрипт: `review-result-gate`, `review-doc-guard`, `reviews-index`,
|
||||
`merge-candidate`, `process-track route`, `status-label`. Снимок один на job —
|
||||
одна версия на весь заход, а не смесь по шагам. Так же уже работают шаг трека
|
||||
(#707), страж ребейза (#698) и разбор отказа push (#723). Рабочая копия ветки
|
||||
одна версия на весь заход, а не смесь по шагам. Job `prepare` (#765) делает
|
||||
то же до перехода на ветку задачи, но архивом **закреплённого** SHA
|
||||
(`git rev-parse origin/dev` один раз — `git fetch` трека и ребейза его не
|
||||
сдвигают): из снимка идут шаг трека (#707), страж ребейза (#698) с разбором
|
||||
отказа push (#723), хеш тела issue для якоря, reuse зелёного
|
||||
вердикта (#499), Validate-гейт (#510) и проверка изменившегося ТЗ (#517).
|
||||
Прежде четыре последних исполняли скрипты ветки — материал сам решал, допущен
|
||||
ли он к ревью: подменённый `review-doc-guard.mjs` выдавал себе `reuse=true`.
|
||||
SHA уходит выходом `tools_sha` в `model_review`. Рабочая копия ветки
|
||||
задачи — только материал: git-команды, документ и пути судятся в ней. Ветка
|
||||
`show`/`ship` с чистым слиянием до ревью не ребейзится и может нести
|
||||
`scripts/`, отставшие от `dev` на дни: её `review-doc-guard.mjs` молча
|
||||
проглотил бы неизвестный флаг, её `merge-candidate.mjs` слил бы по-старому.
|
||||
`validate.yml` в снимке обязателен: `workflow-jobs.mjs` читает его по пути от
|
||||
себя, без него доказательство Validate кандидата — `failed (#622)`. Исключение
|
||||
— job `model_review`: ревьюер исполняет тесты и скрипты материала, это его
|
||||
работа, а не конвейера. Следствие для задач, меняющих сам конвейер: их
|
||||
себя, без него доказательство Validate кандидата — `failed (#622)`. В job
|
||||
`model_review` скрипты материала исполняет ревьюер — это его работа, а не
|
||||
конвейера; детерминированный шаг «Снять расход модели» (#737) — работа
|
||||
конвейера, и `model-usage.mjs` он берёт архивом `tools_sha` (#765): на
|
||||
отставшей ветке скрипта нет, и публикация молча писала `reason=missing`. От
|
||||
сессии модели на том же раннере снимок не защищает, поэтому строка расхода
|
||||
по-прежнему недоверенный ввод и разбирается строго. Следствие для задач, меняющих сам конвейер: их
|
||||
слияние судит версия `dev`, новая начинает действовать со следующей задачи.
|
||||
Правка контракта доказательства Validate (имена потребляемых job
|
||||
`validate.yml`, политики `ci-proof.mjs`, #622) пишется совместимой с
|
||||
@@ -1400,7 +1413,7 @@ limit`) и своей логики трека не держит; ответ comp
|
||||
|---|---|---|---|
|
||||
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | лёгкий, как у `ship` (#709) |
|
||||
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
|
||||
| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
|
||||
| Ревью модели | нет — пакетное ревью ночью; перед бетой проверка покрытия и непрочитанная дельта (§11.7) | корректность и AC; окружение — по нужде | полное |
|
||||
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | как у `ship` (#709) |
|
||||
|
||||
Мутантов по диффу нет ни на одном треке (#709, §2.7). Рамки `ship`
|
||||
@@ -1470,7 +1483,8 @@ merge. Timeout/cancel/failure называет конкретную стадию
|
||||
|
||||
**Расход модели** (#737). Шаг `Review` отдаёт `execution_file` — все сообщения
|
||||
сессии SDK, а журнал Actions расход намеренно прячет. Шаг «Снять расход модели»
|
||||
сразу после `Review` (`if: always()`, `continue-on-error`) берёт из файла только
|
||||
сразу после `Review` (`if: always()`, `continue-on-error`) — шаг конвейера, а не
|
||||
ревьюера, со скриптом из снимка `dev` (#765) — берёт из файла только
|
||||
последнее сообщение `result` (`scripts/model-usage.mjs`) и отдаёт одну строку
|
||||
выходом job `usage`: `<!-- hp:usage input_tokens=N output_tokens=N
|
||||
cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` — суммы
|
||||
@@ -1703,7 +1717,8 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
|
||||
Это исключение из правила «каждое изменение проходит код-ревью» (§5, §7.1).
|
||||
Второе такое место — трек `ship` (§5): его ревью не отменяется, а переносится
|
||||
на пакетное ревью диапазона перед бетой. Здесь же исключение относится только к
|
||||
на пакетное ревью ночью и проверку покрытия с чтением непрочитанной дельты перед
|
||||
бетой (§11.7). Здесь же исключение относится только к
|
||||
окну между `S8-merged` и выпуском.
|
||||
|
||||
### 11.5 Независимое ревью линии перед стабильным релизом
|
||||
@@ -1772,7 +1787,9 @@ action ревью пускает ровно этого бота (`allowed_bots`,
|
||||
выпуск после такого отказа, исправляют причину и получают новый совместимый
|
||||
полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата.
|
||||
|
||||
### 11.7 Пакетное ревью `ship` перед бетой
|
||||
<a id="117-пакетное-ревью-ship-перед-бетой"></a>
|
||||
|
||||
### 11.7 Пакетное ревью `ship`: ночью и перед бетой
|
||||
|
||||
Решение владельца 2026-09-28, issue #696.
|
||||
|
||||
@@ -1781,7 +1798,9 @@ action ревью пускает ровно этого бота (`allowed_bots`,
|
||||
до того, как она уйдёт пользователям беты. Одна сессия на все ship-задачи
|
||||
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
|
||||
|
||||
**Шаг.** Перед публикацией беты — `ship-review.yml`
|
||||
**Шаг.** Ночью код читается заранее (режим ниже). Перед публикацией беты
|
||||
проверяется покрытие; непрочитанные или изменённые после ревью задачи читает
|
||||
`ship-review.yml`
|
||||
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N` или кнопка
|
||||
«Run workflow» в Actions). Это тонкий вызывающий файл, он лежит и в `main`:
|
||||
без этого GitHub запуск не даёт. Тело `_ship-review.yml` читается из `dev` при
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.79.0-beta.2"
|
||||
VERSION = "1.80.0-beta.4"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
|
||||
@@ -86,6 +86,16 @@ def _lattice_points(value: Any) -> None:
|
||||
_lattice_point(point)
|
||||
|
||||
|
||||
def _scalar_points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
if not isinstance(point, list):
|
||||
continue
|
||||
for index in range(min(2, len(point))):
|
||||
point[index] = canonicalize_number(point[index])
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
@@ -182,6 +192,12 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
_lattice_point(span.get("a"))
|
||||
_lattice_point(span.get("b"))
|
||||
|
||||
for strip in _records(space.get("led_strips")):
|
||||
# #780: LED strips snap to physical wall faces, like stairs and
|
||||
# furniture: remove JSON noise only, never pull a face contact
|
||||
# onto a lattice node.
|
||||
_scalar_points(strip.get("points"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_scalar_fields(marker, ("angle",))
|
||||
|
||||
|
||||
@@ -1,466 +1,626 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004",
|
||||
"fingerprint": "2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 300565,
|
||||
"initialViewGzipBytes": 301040,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 302087,
|
||||
"initialPanelGzipBytes": 302564,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2323,
|
||||
"initialPanelOnlyGzipBytes": 2327,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-erCrvaVF.js",
|
||||
"houseplan-assets/de-d9TwEFPV.js",
|
||||
"houseplan-assets/editor-C_sFCxgH.js",
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/fr-CWO2BzcK.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpILp2jS.js",
|
||||
"houseplan-assets/iso-scene-render-R0PpaYmJ.js",
|
||||
"houseplan-assets/live-interaction-runtime-zvKxzkbX.js",
|
||||
"houseplan-assets/moon-runtime-DXG94qOm.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js",
|
||||
"houseplan-assets/pdf-export-ByjBIvRJ.js",
|
||||
"houseplan-assets/space-editor-Ch0tH8G9.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js",
|
||||
"houseplan-assets/zigbee-topology-DZ_-WbFy.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js"
|
||||
"houseplan-assets/backdrop-pick-rLk02S_E.js",
|
||||
"houseplan-assets/de-Df0SMMtx.js",
|
||||
"houseplan-assets/editor-Ber3pzUs.js",
|
||||
"houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/fr-9DZosQeR.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-BTD4QxJr.js",
|
||||
"houseplan-assets/iso-scene-render-D0hnGVLf.js",
|
||||
"houseplan-assets/led-strip-editor-AS9V253I.js",
|
||||
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
|
||||
"houseplan-assets/led-strip-runtime-CYCZOpBw.js",
|
||||
"houseplan-assets/live-interaction-runtime-CX7iT0NT.js",
|
||||
"houseplan-assets/live-tip-placement-Cmnwu_lZ.js",
|
||||
"houseplan-assets/moon-runtime-qD4uu5bk.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js",
|
||||
"houseplan-assets/pdf-export-BLeQ2whw.js",
|
||||
"houseplan-assets/space-editor-CSjiCH5v.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js",
|
||||
"houseplan-assets/zigbee-topology-CRIMYnAF.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js"
|
||||
],
|
||||
"lazyGzipBytes": 464846,
|
||||
"lazyGzipBytes": 485830,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-erCrvaVF.js",
|
||||
"houseplan-assets/editor-C_sFCxgH.js",
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js",
|
||||
"houseplan-assets/zigbee-topology-DZ_-WbFy.js"
|
||||
"houseplan-assets/backdrop-pick-rLk02S_E.js",
|
||||
"houseplan-assets/editor-Ber3pzUs.js",
|
||||
"houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js",
|
||||
"houseplan-assets/zigbee-topology-CRIMYnAF.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 239369,
|
||||
"lazyEditorGzipBytes": 243803,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-erCrvaVF.js",
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js"
|
||||
"houseplan-assets/backdrop-pick-rLk02S_E.js",
|
||||
"houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 28396,
|
||||
"lazyOnboardingGzipBytes": 28679,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-d9TwEFPV.js",
|
||||
"houseplan-assets/fr-CWO2BzcK.js"
|
||||
"houseplan-assets/de-Df0SMMtx.js",
|
||||
"houseplan-assets/fr-9DZosQeR.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 56069,
|
||||
"lazyLocaleGzipBytes": 53867,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/iso-scene-render-R0PpaYmJ.js"
|
||||
"houseplan-assets/iso-scene-render-D0hnGVLf.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 12345,
|
||||
"lazyIsometricGzipBytes": 12348,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 16942,
|
||||
"lazyFurnitureArtGzipBytes": 16939,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
|
||||
"houseplan-assets/pdf-export-ByjBIvRJ.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
|
||||
"houseplan-assets/pdf-export-BLeQ2whw.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 126940,
|
||||
"lazyPdfGzipBytes": 126937,
|
||||
"lazyMoonFiles": [
|
||||
"houseplan-assets/moon-runtime-DXG94qOm.js"
|
||||
"houseplan-assets/moon-runtime-qD4uu5bk.js"
|
||||
],
|
||||
"lazyMoonGzipBytes": 11716,
|
||||
"lazyMoonGzipBytes": 11715,
|
||||
"lazyLedFiles": [
|
||||
"houseplan-assets/led-strip-field-DYtgd8wB.js",
|
||||
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
|
||||
"houseplan-assets/led-strip-runtime-CYCZOpBw.js"
|
||||
],
|
||||
"lazyLedGzipBytes": 10684,
|
||||
"lazyLedEditorFiles": [
|
||||
"houseplan-assets/led-strip-editor-AS9V253I.js",
|
||||
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js"
|
||||
],
|
||||
"lazyLedEditorGzipBytes": 10705,
|
||||
"lazyNamespaceLocaleFiles": [
|
||||
"houseplan-assets/settings-de-Cu0ARW9H.js",
|
||||
"houseplan-assets/settings-fr-BIO-QwTT.js",
|
||||
"houseplan-assets/settings-ru-gpeXJk2y.js",
|
||||
"houseplan-assets/support-de-CH0Mth3e.js",
|
||||
"houseplan-assets/support-fr-5un_aGUp.js",
|
||||
"houseplan-assets/support-ru-BUP8Z3Kb.js",
|
||||
"houseplan-assets/topology-de-DoQ2W7CB.js",
|
||||
"houseplan-assets/topology-fr-4M_CQtH4.js",
|
||||
"houseplan-assets/topology-ru-DLnNeJR5.js"
|
||||
"houseplan-assets/led-de-DAcR1G-Q.js",
|
||||
"houseplan-assets/led-fr-_e9yQX3D.js",
|
||||
"houseplan-assets/led-ru-BcCmiv05.js",
|
||||
"houseplan-assets/settings-de-D2uOhVdg.js",
|
||||
"houseplan-assets/settings-fr-Dkv1OQnI.js",
|
||||
"houseplan-assets/settings-ru-Dm61M7J6.js",
|
||||
"houseplan-assets/support-de-CuOKWMOQ.js",
|
||||
"houseplan-assets/support-fr-Cg72SrF2.js",
|
||||
"houseplan-assets/support-ru-DE0eoEHZ.js",
|
||||
"houseplan-assets/tools-de-CkfSvdjK.js",
|
||||
"houseplan-assets/tools-fr-CLTqQchi.js",
|
||||
"houseplan-assets/tools-ru-uXSPneqf.js",
|
||||
"houseplan-assets/topology-de-BrwHoXfr.js",
|
||||
"houseplan-assets/topology-fr-gjGrARkP.js",
|
||||
"houseplan-assets/topology-ru-DSq63N19.js"
|
||||
],
|
||||
"lazyNamespaceLocaleGzipBytes": 22672,
|
||||
"lazyNamespaceLocaleGzipBytes": 31767,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-erCrvaVF.js",
|
||||
"sha256": "58bdb0ca63168828ebdd1fe564ba18c096ee124ae01aa9795465497e3469c868",
|
||||
"rawBytes": 55329,
|
||||
"gzipBytes": 18026,
|
||||
"path": "houseplan-assets/backdrop-pick-rLk02S_E.js",
|
||||
"sha256": "141b16e51a818ad137bbf001efb6bed28a20791e8d992b0430edd79bfa3d0bec",
|
||||
"rawBytes": 54425,
|
||||
"gzipBytes": 17699,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js"
|
||||
"houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/settings-de-Cu0ARW9H.js",
|
||||
"houseplan-assets/settings-fr-BIO-QwTT.js",
|
||||
"houseplan-assets/settings-ru-gpeXJk2y.js"
|
||||
"houseplan-assets/settings-de-D2uOhVdg.js",
|
||||
"houseplan-assets/settings-fr-Dkv1OQnI.js",
|
||||
"houseplan-assets/settings-ru-Dm61M7J6.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-d9TwEFPV.js",
|
||||
"sha256": "350157130d9bc87112a94a0062e6fdc5c1061efe8816be115a3c7836f54225d7",
|
||||
"rawBytes": 93620,
|
||||
"gzipBytes": 28263,
|
||||
"path": "houseplan-assets/de-Df0SMMtx.js",
|
||||
"sha256": "ce067613e40e29223f9a0e46516035e7bf153bc30145083239945e82096803ab",
|
||||
"rawBytes": 89873,
|
||||
"gzipBytes": 27164,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-C_sFCxgH.js",
|
||||
"sha256": "cb88aa19af05a9921272bd04e9023fdd75f0fbe63a8e490f7fd3c22652847c76",
|
||||
"path": "houseplan-assets/editor-Ber3pzUs.js",
|
||||
"sha256": "9a8614dc89d1f5df419b66152f97872ac3a1cee8aff47bbc190e28ca3dd87317",
|
||||
"rawBytes": 3833,
|
||||
"gzipBytes": 1582,
|
||||
"gzipBytes": 1585,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"sha256": "88f80b20957ade215c2028113fab371dfb8588d93d19774cfebcc03f38ab6e6c",
|
||||
"rawBytes": 25600,
|
||||
"gzipBytes": 5055,
|
||||
"path": "houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"sha256": "a6fccd3fb4ad315ba51316e958d65b49cf3e4d51707ddd98e47019e83b8467f2",
|
||||
"rawBytes": 1104,
|
||||
"gzipBytes": 579,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-CWO2BzcK.js",
|
||||
"sha256": "ca7a4fc1e696690c6e971c94f46d856816583cd247fe66a64ec4ae371e196631",
|
||||
"rawBytes": 96021,
|
||||
"gzipBytes": 27806,
|
||||
"path": "houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"sha256": "d67cd5482adce817b474e68ba31e1d3c0ddec55ff8f7c912e78395a265bb881d",
|
||||
"rawBytes": 25605,
|
||||
"gzipBytes": 5059,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
|
||||
"sha256": "1c9a0ca09ec566715248c691386bfd17e23d3ae821a0c21608f9513602c72095",
|
||||
"path": "houseplan-assets/fr-9DZosQeR.js",
|
||||
"sha256": "14738a9cb6f99a1b7c56b425696dd354bb0589c58f569db280bab7aa313dab08",
|
||||
"rawBytes": 92266,
|
||||
"gzipBytes": 26703,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
|
||||
"sha256": "75937cebabec17ca652b22606bfe3ae511df5a8e44921d05d4f84fe3ab353e74",
|
||||
"rawBytes": 52035,
|
||||
"gzipBytes": 16942,
|
||||
"gzipBytes": 16939,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"sha256": "1a9aa573e7c525eaa850feecfa59c215ed926706908872937424881b5799f501",
|
||||
"rawBytes": 1058576,
|
||||
"gzipBytes": 299764,
|
||||
"path": "houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"sha256": "2891b5898e812f6433322a6b750077f5c543a6308c2b04e19e349f3003101830",
|
||||
"rawBytes": 1059200,
|
||||
"gzipBytes": 300237,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-d9TwEFPV.js",
|
||||
"houseplan-assets/editor-C_sFCxgH.js",
|
||||
"houseplan-assets/fr-CWO2BzcK.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CpILp2jS.js",
|
||||
"houseplan-assets/iso-scene-render-R0PpaYmJ.js",
|
||||
"houseplan-assets/live-interaction-runtime-zvKxzkbX.js",
|
||||
"houseplan-assets/moon-runtime-DXG94qOm.js",
|
||||
"houseplan-assets/pdf-export-ByjBIvRJ.js",
|
||||
"houseplan-assets/space-editor-Ch0tH8G9.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js"
|
||||
"houseplan-assets/de-Df0SMMtx.js",
|
||||
"houseplan-assets/editor-Ber3pzUs.js",
|
||||
"houseplan-assets/fr-9DZosQeR.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-BTD4QxJr.js",
|
||||
"houseplan-assets/iso-scene-render-D0hnGVLf.js",
|
||||
"houseplan-assets/led-strip-editor-AS9V253I.js",
|
||||
"houseplan-assets/led-strip-runtime-CYCZOpBw.js",
|
||||
"houseplan-assets/live-interaction-runtime-CX7iT0NT.js",
|
||||
"houseplan-assets/moon-runtime-qD4uu5bk.js",
|
||||
"houseplan-assets/pdf-export-BLeQ2whw.js",
|
||||
"houseplan-assets/space-editor-CSjiCH5v.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
|
||||
"sha256": "a9aae4b426f09d16563c0fef14db3190c1d71f7ca8eb603fff9824d6fa632fdc",
|
||||
"rawBytes": 690723,
|
||||
"gzipBytes": 193056,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
|
||||
"sha256": "2706f9bbc0201e8902598eb20463ee9e417520e40ebb9536be857098f7b56ca3",
|
||||
"rawBytes": 698926,
|
||||
"gzipBytes": 195577,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-erCrvaVF.js",
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js",
|
||||
"houseplan-assets/zigbee-topology-DZ_-WbFy.js"
|
||||
"houseplan-assets/backdrop-pick-rLk02S_E.js",
|
||||
"houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js",
|
||||
"houseplan-assets/zigbee-topology-CRIMYnAF.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/support-de-CH0Mth3e.js",
|
||||
"houseplan-assets/support-fr-5un_aGUp.js",
|
||||
"houseplan-assets/support-ru-BUP8Z3Kb.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js"
|
||||
"houseplan-assets/support-de-CuOKWMOQ.js",
|
||||
"houseplan-assets/support-fr-Cg72SrF2.js",
|
||||
"houseplan-assets/support-ru-DE0eoEHZ.js",
|
||||
"houseplan-assets/tools-de-CkfSvdjK.js",
|
||||
"houseplan-assets/tools-fr-CLTqQchi.js",
|
||||
"houseplan-assets/tools-ru-uXSPneqf.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
|
||||
"sha256": "ddb0ec7750f3269ba2a7d8d259f69f640ae4ddc7cf3bce378c70daece30d21c6",
|
||||
"rawBytes": 15886,
|
||||
"gzipBytes": 4907,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
|
||||
"sha256": "715f8a6fecb9d2772c0184834a4e7fd673fb8ba8e2b19f25e084d5139ab63ff9",
|
||||
"rawBytes": 15930,
|
||||
"gzipBytes": 4932,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-erCrvaVF.js",
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js"
|
||||
"houseplan-assets/backdrop-pick-rLk02S_E.js",
|
||||
"houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CpILp2jS.js",
|
||||
"sha256": "123c7685bcfb9a2e4b47fcd4ecbc19b2e7acc463afbfea4a00678dcf94287250",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 3765,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-BTD4QxJr.js",
|
||||
"sha256": "d4a06ecef69eef43807011f234e9ea4f41525512e3c46cd192f71c7530da5797",
|
||||
"rawBytes": 16715,
|
||||
"gzipBytes": 5525,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js",
|
||||
"houseplan-assets/zigbee-topology-DZ_-WbFy.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/live-tip-placement-Cmnwu_lZ.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js",
|
||||
"houseplan-assets/zigbee-topology-CRIMYnAF.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-R0PpaYmJ.js",
|
||||
"sha256": "729b5d1c02300c80614c8fd14c28d9ca9ea310d0824c9630327e38d4b651bea5",
|
||||
"path": "houseplan-assets/iso-scene-render-D0hnGVLf.js",
|
||||
"sha256": "6865d06163a910a4f641d0bb29bc99cb8215902708b83427de0b466c4d796914",
|
||||
"rawBytes": 36700,
|
||||
"gzipBytes": 12345,
|
||||
"gzipBytes": 12348,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-zvKxzkbX.js",
|
||||
"sha256": "a02ff870b438bd2070f53a164cbcf69efda578358fb14a32c32221e67fd43657",
|
||||
"rawBytes": 8370,
|
||||
"gzipBytes": 3277,
|
||||
"path": "houseplan-assets/led-de-DAcR1G-Q.js",
|
||||
"sha256": "622c426b50781de7a4be6aadb935cadcc7fa774179b2ef409f65fa64e9064e65",
|
||||
"rawBytes": 1907,
|
||||
"gzipBytes": 922,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-fr-_e9yQX3D.js",
|
||||
"sha256": "1f10f0098bb13ce8ef24f60a1dda556b0aa6f47239a0bfa124d016fc92ede20d",
|
||||
"rawBytes": 1886,
|
||||
"gzipBytes": 907,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-ru-BcCmiv05.js",
|
||||
"sha256": "8c367a70a2fd03504863c42afb168803905dd7f16145e489e227f77c0b61e7fe",
|
||||
"rawBytes": 2525,
|
||||
"gzipBytes": 1107,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-strip-editor-AS9V253I.js",
|
||||
"sha256": "4f25c8551b2db798fc062c5f9e0a9733d8a18bd6f7d3d03da73c33753623403c",
|
||||
"rawBytes": 21704,
|
||||
"gzipBytes": 7381,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/led-de-DAcR1G-Q.js",
|
||||
"houseplan-assets/led-fr-_e9yQX3D.js",
|
||||
"houseplan-assets/led-ru-BcCmiv05.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-strip-field-DYtgd8wB.js",
|
||||
"sha256": "47fd14662b9836229b7f2aa55fb027f4aa56313b0825be70f5b1a6a674541d17",
|
||||
"rawBytes": 8066,
|
||||
"gzipBytes": 3383,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/led-strip-geometry-CFq4tPIv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/moon-runtime-DXG94qOm.js",
|
||||
"sha256": "8f3839a242d5e6293134ffa5a10edfe63516d0f89e3097dfe68ff07f2111e246",
|
||||
"path": "houseplan-assets/led-strip-geometry-CFq4tPIv.js",
|
||||
"sha256": "d59b35b494d23921b63faf4f584f1ab9dea3342e5a43b7399964e3d4732d40fe",
|
||||
"rawBytes": 7055,
|
||||
"gzipBytes": 2914,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-strip-runtime-CYCZOpBw.js",
|
||||
"sha256": "2675363403be48fddf536c7101422e2492b1e4172aa701851a15e46ce1f80ce2",
|
||||
"rawBytes": 11248,
|
||||
"gzipBytes": 4387,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/led-strip-geometry-CFq4tPIv.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/led-strip-field-DYtgd8wB.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-CX7iT0NT.js",
|
||||
"sha256": "744e65ad2f843f7f6e09176ff449b5b4b83e935856a98b2ec128aebdf93ebc8d",
|
||||
"rawBytes": 9694,
|
||||
"gzipBytes": 3782,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/floating-surface-CAk--Y2g.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/live-tip-placement-Cmnwu_lZ.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-tip-placement-Cmnwu_lZ.js",
|
||||
"sha256": "597bb1bc933908ff200057dd7ba941f3b5a974c9a2090acc56b0f715da39ad6f",
|
||||
"rawBytes": 1191,
|
||||
"gzipBytes": 683,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/moon-runtime-qD4uu5bk.js",
|
||||
"sha256": "c70c2405448d5aa0444ed2ad0ef9446a568e06981c9e14665a53c7c0af030604",
|
||||
"rawBytes": 30292,
|
||||
"gzipBytes": 11716,
|
||||
"gzipBytes": 11715,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/namespace-language-CPNZBROg.js",
|
||||
"sha256": "068f3469028297daea7cf745fc8be94a99aade466532b25d92b3b33928a6242e",
|
||||
"path": "houseplan-assets/namespace-language-qIVz1CjX.js",
|
||||
"sha256": "85197049ab95cd2928a157d8045508cc2e6340dcd8963079dfda1b743b9e910f",
|
||||
"rawBytes": 675,
|
||||
"gzipBytes": 408,
|
||||
"gzipBytes": 410,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-ByjBIvRJ.js",
|
||||
"sha256": "e94672f04c50bdba29636ab9b5a206ace0d600486ea1ad5d87be9a9183666fee",
|
||||
"path": "houseplan-assets/pdf-export-BLeQ2whw.js",
|
||||
"sha256": "05ce799acc1bb24dc0d283a44beb1bfed7c5791637ebc9c9140c7bda5652b7df",
|
||||
"rawBytes": 236714,
|
||||
"gzipBytes": 109998,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-de-Cu0ARW9H.js",
|
||||
"sha256": "a3755fbf40c148aa9848bf4a5191a27c636977c774dc01f137ccc2470c7a0ee5",
|
||||
"path": "houseplan-assets/settings-de-D2uOhVdg.js",
|
||||
"sha256": "5185e2fbba6b5ca8384a5b9f8024210dd4f7eae44a208255f8fe142012075890",
|
||||
"rawBytes": 8886,
|
||||
"gzipBytes": 3406,
|
||||
"gzipBytes": 3410,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-fr-BIO-QwTT.js",
|
||||
"sha256": "abeee9f1cef76177568d2994cca968e07a42040ebd0a6bbc89e81d6eb7eb760e",
|
||||
"path": "houseplan-assets/settings-fr-Dkv1OQnI.js",
|
||||
"sha256": "a1a6ce7597b21f3e8b6bbf41c374dc3fab4e5ae4baf9181d270c90a6fa58f45c",
|
||||
"rawBytes": 9297,
|
||||
"gzipBytes": 3379,
|
||||
"gzipBytes": 3384,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-ru-gpeXJk2y.js",
|
||||
"sha256": "8e05df136db81011ac4c884665d889dc773e12523937215c5f078f71bf882752",
|
||||
"path": "houseplan-assets/settings-ru-Dm61M7J6.js",
|
||||
"sha256": "e1efb00b777f3f386ce2296f96c76cc1970460dc5b9915cf5bca0d667e814f2b",
|
||||
"rawBytes": 12284,
|
||||
"gzipBytes": 3897,
|
||||
"gzipBytes": 3900,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/space-editor-Ch0tH8G9.js",
|
||||
"sha256": "8136fb4e381881486a4f6aaec92e2cfc3d01dd5f9ea51f2c0dca1ebec9141a3e",
|
||||
"path": "houseplan-assets/space-editor-CSjiCH5v.js",
|
||||
"sha256": "1939fa609e45366525ac5e61cee84cb1aeae7d3a99711fa1744908fda7e9cde6",
|
||||
"rawBytes": 2619,
|
||||
"gzipBytes": 1124,
|
||||
"gzipBytes": 1127,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-4by4BwDV.js",
|
||||
"sha256": "c0a480f80507425774e9a71d465014d825b7a9b877d75f20438ee43986af1325",
|
||||
"rawBytes": 14862,
|
||||
"gzipBytes": 3423,
|
||||
"path": "houseplan-assets/summary-panel-editor-BDa-rBgX.js",
|
||||
"sha256": "abccfc8cfad10984cdbfe5b7b8f416c84c11727640b3fe707b3bb03b168ecc8a",
|
||||
"rawBytes": 14867,
|
||||
"gzipBytes": 3427,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js"
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-DmEWv1wn.js",
|
||||
"sha256": "7ed391b93d0da3dcc1aaf6fc0c47cd5e0ee7106f6697f16143cb3f2d5dc25e80",
|
||||
"path": "houseplan-assets/summary-panel-metrics-BiXNDvWo.js",
|
||||
"sha256": "c0a6e64d7805b99c83dd58b3704814dea5b21e47a9c5a4f3c61d0f5582f1ae55",
|
||||
"rawBytes": 2701,
|
||||
"gzipBytes": 1340,
|
||||
"gzipBytes": 1344,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js",
|
||||
"sha256": "9fbaea7e6dae38df1f12013f9eaef0b02936fec9ac5866a8f214b92ac1214050",
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js",
|
||||
"sha256": "b978d47f4babb0564c97a7b3a2d0e505a43a81eab4f49792acb3b39be38d6564",
|
||||
"rawBytes": 73476,
|
||||
"gzipBytes": 20488,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-4by4BwDV.js",
|
||||
"houseplan-assets/summary-panel-metrics-DmEWv1wn.js"
|
||||
"houseplan-assets/summary-panel-editor-BDa-rBgX.js",
|
||||
"houseplan-assets/summary-panel-metrics-BiXNDvWo.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-de-CH0Mth3e.js",
|
||||
"sha256": "f835d35a37d84f9972a2673f24105de7768d34e597553b22971ffbf708b36279",
|
||||
"path": "houseplan-assets/support-de-CuOKWMOQ.js",
|
||||
"sha256": "0250ec7cbbcca4f972f612b68bc8dd19fe7ebf6b83014e521c06291d53814416",
|
||||
"rawBytes": 6167,
|
||||
"gzipBytes": 2393,
|
||||
"gzipBytes": 2397,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-fr-5un_aGUp.js",
|
||||
"sha256": "537afde2367d66766509cfd53664f76cb77c1ef689ec07ba59abd285a75c0a9f",
|
||||
"path": "houseplan-assets/support-fr-Cg72SrF2.js",
|
||||
"sha256": "c67ff5b8ddc5c490e292199debc279ccf65c46cecdec2e105ecc7ee06e9c5a90",
|
||||
"rawBytes": 6333,
|
||||
"gzipBytes": 2427,
|
||||
"gzipBytes": 2433,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-ru-BUP8Z3Kb.js",
|
||||
"sha256": "d965e7e334b56a19d1175ba3c4b791e2585f45917582e3210904c1eb8705894f",
|
||||
"path": "houseplan-assets/support-ru-DE0eoEHZ.js",
|
||||
"sha256": "534214af44ba8e80b6449ad3bc42ecdb6f43103f293fd7d19deae60d1481a52e",
|
||||
"rawBytes": 8376,
|
||||
"gzipBytes": 2874,
|
||||
"gzipBytes": 2877,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-de-DoQ2W7CB.js",
|
||||
"sha256": "708ceeb7a1c7c843a438f7cb9f9e5a310f4fad8a172c6e6be1ba375c270bc21a",
|
||||
"rawBytes": 2698,
|
||||
"gzipBytes": 1332,
|
||||
"path": "houseplan-assets/tools-de-CkfSvdjK.js",
|
||||
"sha256": "205d81de2c9b9b79516e88e7284592c0726e5f4b3a9dd63242cc028b267ee8ce",
|
||||
"rawBytes": 4130,
|
||||
"gzipBytes": 1377,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-fr-4M_CQtH4.js",
|
||||
"sha256": "72f6e5c98a4a1c9fce80de9d99193059c687045aae3c4b3b03a87b610469cd94",
|
||||
"rawBytes": 2718,
|
||||
"gzipBytes": 1351,
|
||||
"path": "houseplan-assets/tools-fr-CLTqQchi.js",
|
||||
"sha256": "3a3d842ec5befbc252261ec46afefd8cb28b2ab1d805220a55933ddd38831a2c",
|
||||
"rawBytes": 4136,
|
||||
"gzipBytes": 1359,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-ru-DLnNeJR5.js",
|
||||
"sha256": "1cb2e1f8984a997e20d3ea05b2749379e3c773e41bf7670226ac0141fca13ccf",
|
||||
"rawBytes": 3669,
|
||||
"gzipBytes": 1613,
|
||||
"path": "houseplan-assets/tools-ru-uXSPneqf.js",
|
||||
"sha256": "46afb767891262c52bc51658de2db852264b911eb864f03f91a5f10d2573301b",
|
||||
"rawBytes": 5633,
|
||||
"gzipBytes": 1696,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DZ_-WbFy.js",
|
||||
"sha256": "b2deb6084e835c4864160259cf445a24f4340d54c0db574cc4e9635d47e4f6a1",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 4300,
|
||||
"path": "houseplan-assets/topology-de-BrwHoXfr.js",
|
||||
"sha256": "85ed4bb11a34972e23ba51a7bb74a70f7fc30b228f7b140a7a87c780cdfa93aa",
|
||||
"rawBytes": 4237,
|
||||
"gzipBytes": 1889,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-fr-gjGrARkP.js",
|
||||
"sha256": "67b40ff27301c3aefd69dee454f5e90ff7edf43da084434ce850c3960e7bb65b",
|
||||
"rawBytes": 4321,
|
||||
"gzipBytes": 1871,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-ru-DSq63N19.js",
|
||||
"sha256": "21126337f66f9e2af553a999af9cb33a72a3d780daa86b1da8c2b5f92ffb6b85",
|
||||
"rawBytes": 5880,
|
||||
"gzipBytes": 2238,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-CRIMYnAF.js",
|
||||
"sha256": "1fee965bb50d8d80417cae2e9a6b070a8fe1311b42171b300d83768f911eb9c4",
|
||||
"rawBytes": 15905,
|
||||
"gzipBytes": 5955,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/topology-de-DoQ2W7CB.js",
|
||||
"houseplan-assets/topology-fr-4M_CQtH4.js",
|
||||
"houseplan-assets/topology-ru-DLnNeJR5.js"
|
||||
"houseplan-assets/topology-de-BrwHoXfr.js",
|
||||
"houseplan-assets/topology-fr-gjGrARkP.js",
|
||||
"houseplan-assets/topology-ru-DSq63N19.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js",
|
||||
"sha256": "2f82578f02c24a55de11c7591a421c19694eca62a23cc5f71ca1acf9abe8e5a2",
|
||||
"rawBytes": 3983,
|
||||
"gzipBytes": 1788,
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js",
|
||||
"sha256": "1d76b7f08b7659d6f3a05ba551acc4698ab3a05be495077ac498b212dadb039b",
|
||||
"rawBytes": 7883,
|
||||
"gzipBytes": 2880,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js",
|
||||
"houseplan-assets/namespace-language-CPNZBROg.js",
|
||||
"houseplan-assets/zigbee-topology-DZ_-WbFy.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js",
|
||||
"houseplan-assets/namespace-language-qIVz1CjX.js",
|
||||
"houseplan-assets/zigbee-topology-CRIMYnAF.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "acb67dca9358ea65c86cc0a5e6f1b6eabe364e2cf3b5ed4b67b1bf5f5b8d1c9a",
|
||||
"sha256": "b1334be19eb372421b9b2886a9f79a7d86bf68f3dd856cda830865436059ad15",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 801,
|
||||
"gzipBytes": 803,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "4b0022acf6b29c9904be5c5d9c69bc0ba92ee4a26e6daa6e09bc93345d8af354",
|
||||
"sha256": "e1e064634e82387201d3666c7264d26f9aae2df27bd9b1229d8a872738f4ad53",
|
||||
"rawBytes": 6165,
|
||||
"gzipBytes": 2323,
|
||||
"gzipBytes": 2327,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-DGdbxgxC.js"
|
||||
"houseplan-assets/houseplan-card-BOhGeQlv.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{aG as e,l as o,aJ as t,eD as s,A as a,eE as i,eF as l,eG as n,E as r,c}from"./houseplan-card-DGdbxgxC.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 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,h={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")},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),d=e=>c`<ha-form
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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-BOhGeQlv.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=>h[e.name]||e.name}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${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(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);
|
||||
${d(h.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
@@ -0,0 +1 @@
|
||||
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),f=Math.max(n,h.top+h.height-i),r=Math.max(0,m-o),M=Math.max(0,f-n),u=Math.min(Math.max(0,a.width),r),d=Math.min(Math.max(0,a.height),M);let p=t.left;p+u>m&&(p=t.right-u),p=Math.min(Math.max(o,p),Math.max(o,m-u));const x=t.bottom+e,s=t.top-e-d;let b="bottom",l=x;return x+d>f&&s>=n?(b="top",l=Math.min(Math.max(n,s),Math.max(n,f-d))):l=Math.min(Math.max(n,x),Math.max(n,f-d)),{left:Math.round(p),top:Math.round(l),side:b,maxWidth:Math.round(r),maxHeight:Math.round(M)}}function h(t){const a=t.HTMLElement?.prototype;return"function"==typeof a?.showPopover&&"function"==typeof a?.hidePopover}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{h as a,t as f,a as p};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,n as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,a as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,d as fingerprint};
|
||||
@@ -0,0 +1,47 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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-BOhGeQlv.js";import{L as g,v as w,s as $,a as y,b as _,p as k,c as b,d as C,e as P}from"./led-strip-geometry-CFq4tPIv.js";const M="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-DYtgd8wB.js"):import(new URL(`./led-strip-field-DYtgd8wB.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=_(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=y/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},y/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 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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:f=8,edge:n=8}=o;if(![e.x,e.y,i.width,i.height,f,n].every(Number.isFinite)||i.width<=0||i.height<=0||f<0||n<0||!t(h)||r.some(o=>!t(o)))return null;const s=h.left+n,d=h.right-n-i.width,l=h.top+n,a=h.bottom-n-i.height;if(![s,d,l,a].every(Number.isFinite)||d<s||a<l)return null;const b=r.filter(t=>t.right>t.left&&t.bottom>t.top).map(t=>({left:t.left-f-i.width,right:t.right+f,top:t.top-f-i.height,bottom:t.bottom+f})),g=(t,o,e)=>Math.max(o,Math.min(e,t)),m=new Set([s,d,g(e.x,s,d)]),p=new Set([l,a,g(e.y,l,a)]);for(const t of b)m.add(g(t.left,s,d)),m.add(g(t.right,s,d)),p.add(g(t.top,l,a)),p.add(g(t.bottom,l,a));const c=[...m].sort((t,o)=>t-o),u=[...p].sort((t,o)=>t-o);let w=null,y=1/0;for(const t of u)for(const o of c){if(b.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};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{eJ as e,eK as a,eF as n}from"./houseplan-card-DGdbxgxC.js";function c(n,c){return new e([{code:"en",dictionary:n},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004",console.warn,a)}function d(e){return function(e,a){return{state:n=>{const c=e.state(n);return"pending"!==c&&a.some(e=>"pending"===e.state(n))?"pending":c},dictionary:a=>e.dictionary(a),ensure:n=>Promise.all([e,...a].map(e=>e.ensure(n))).then(()=>{})}}(n,e)}export{c as n,d as s};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{eR as a,eS as e,eG as n}from"./houseplan-card-BOhGeQlv.js";function o(n,o){return new a([{code:"en",dictionary:n},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83",console.warn,e)}function r(a){return function(a,e){return{state:n=>{const o=a.state(n);return"pending"!==o&&e.some(a=>"pending"===a.state(n))?"pending":o},dictionary:e=>a.dictionary(e),ensure:n=>Promise.all([a,...e].map(a=>a.ensure(n))).then(()=>{})}}(n,a)}export{o as n,r as s};
|
||||
@@ -1,6 +1,6 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{aG as e,l as t,aJ as s,A as a,eE as o,eF as i,eG as n,E as c,eI as l,c as r}from"./houseplan-card-DGdbxgxC.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
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{aG as e,l as t,aJ as s,A as a,eF as o,eG as i,eH as n,E as l,eQ as c,c as r}from"./houseplan-card-BOhGeQlv.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return l;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{...this._config,fit:l(this._config.fit)}}
|
||||
.data=${{...this._config,fit:c(this._config.fit)}}
|
||||
.schema=${this._schema}
|
||||
.computeLabel=${e=>_[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{c as a,A as e}from"./houseplan-card-DGdbxgxC.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-rO8jZiCI.js";import"./form-kit.styles-BBYWe4Py.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{c as a,A as s}from"./houseplan-card-BOhGeQlv.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-Gm80_eMo.js";import"./form-kit.styles-BXaoYN6g.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!e(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(s=>s.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
@@ -10,31 +10,31 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
dismiss-on-scrim aria-busy=${String(d.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor" @click=${()=>m.closeSource()}>
|
||||
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
|
||||
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:s}
|
||||
<section class="summary-general" aria-labelledby="summary-general-title">
|
||||
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
|
||||
<div class="summary-general-grid">
|
||||
${d.localOnly?e:a`<div class="summary-field">
|
||||
${d.localOnly?s:a`<div class="summary-field">
|
||||
<label for="summary-panel-title">${b("summary.panel_title")}</label>
|
||||
<input id="summary-panel-title" type="text" .value=${d.draft.title}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&"title"===y?.path)}
|
||||
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
|
||||
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
|
||||
@input=${a=>m.mutate(s=>{s.title=a.target.value})} />
|
||||
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:s}
|
||||
</div>`}
|
||||
${g}
|
||||
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
|
||||
${d.localOnly?s:a`<label class="summary-switch" for="summary-mobile-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
|
||||
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
|
||||
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
|
||||
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
|
||||
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
|
||||
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
|
||||
@change=${a=>m.mutate(s=>{s.show_on_mobile=a.target.checked})} /></label>`}
|
||||
</div>
|
||||
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
|
||||
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:s}
|
||||
</section>
|
||||
${d.localOnly?e:a`
|
||||
${d.localOnly?s:a`
|
||||
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
|
||||
<header class="summary-blocks-heading">
|
||||
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
|
||||
@@ -42,7 +42,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
|
||||
</header>
|
||||
<div class="summary-editor-blocks">
|
||||
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
|
||||
${d.draft.blocks.map((e,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
|
||||
@dragover=${a=>a.preventDefault()}
|
||||
@drop=${a=>m.drop(a,`block:${t}`)}>
|
||||
<div class="summary-block-head">
|
||||
@@ -57,40 +57,40 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
?disabled=${t===d.draft.blocks.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<input class="summary-block-title" type="text" .value=${s.title}
|
||||
<input class="summary-block-title" type="text" .value=${e.title}
|
||||
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
|
||||
?disabled=${d.busy}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
|
||||
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
|
||||
@input=${a=>m.mutate(s=>{s.blocks[t].title=a.target.value})} />
|
||||
<button class="summary-icon-button summary-visibility" type="button"
|
||||
?disabled=${d.busy} aria-pressed=${String(s.visible)}
|
||||
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
|
||||
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
|
||||
?disabled=${d.busy} aria-pressed=${String(e.visible)}
|
||||
title=${b(e.visible?"summary.hide_block":"summary.show_block")}
|
||||
aria-label=${b(e.visible?"summary.hide_block":"summary.show_block")}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(e.visible?"eye":"eyeOff")}</button>
|
||||
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:s}
|
||||
<div class="summary-scope">
|
||||
<select .value=${s.scope.type} ?disabled=${d.busy}
|
||||
<select .value=${e.scope.type} ?disabled=${d.busy}
|
||||
aria-label=${b("summary.scope")}
|
||||
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
|
||||
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
|
||||
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
|
||||
@change=${a=>m.mutate(s=>{s.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
|
||||
<option value="all" ?selected=${"all"===e.scope.type}>${b("summary.scope_all")}</option>
|
||||
<option value="space" ?selected=${"space"===e.scope.type}>${b("summary.scope_space")}</option>
|
||||
</select>
|
||||
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
|
||||
${"space"===e.scope.type?a`<select .value=${e.scope.space_id}
|
||||
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
|
||||
aria-invalid=${"error"===n?.kind?"true":"false"}
|
||||
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
|
||||
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
|
||||
${c._model.map(e=>a`<option value=${e.id}
|
||||
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
|
||||
</select>`:e}
|
||||
@change=${a=>m.mutate(s=>{const e=s.blocks[t].scope;"space"===e.type&&(e.space_id=a.target.value)})}>
|
||||
${c._model.some(a=>a.id===e.scope.space_id)?s:a`<option value=${e.scope.space_id} selected>${e.scope.space_id}</option>`}
|
||||
${c._model.map(s=>a`<option value=${s.id}
|
||||
?selected=${s.id===e.scope.space_id}>${s.title}</option>`)}
|
||||
</select>`:s}
|
||||
</div>
|
||||
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
|
||||
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:s}
|
||||
</div>
|
||||
<div class="summary-block-body">
|
||||
<div class="summary-editor-values">
|
||||
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
|
||||
${e.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===e.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${e.id}\n${i.id}`;return a`<div class="summary-editor-value"
|
||||
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
|
||||
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
|
||||
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
|
||||
@@ -101,7 +101,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
|
||||
<button class="summary-icon-button" type="button" title=${b("summary.down")}
|
||||
aria-label=${b("summary.down")}
|
||||
?disabled=${c===s.values.length-1||d.busy}
|
||||
?disabled=${c===e.values.length-1||d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
|
||||
</div>
|
||||
<div class="summary-value-label">
|
||||
@@ -109,8 +109,8 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
|
||||
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
|
||||
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
|
||||
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
|
||||
@input=${a=>m.mutate(s=>{s.blocks[t].values[c].label=a.target.value})} />
|
||||
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:s}
|
||||
</div>
|
||||
<div class="summary-source-field">
|
||||
<button class="summary-source" type="button"
|
||||
@@ -119,9 +119,9 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
|
||||
aria-invalid=${"error"===$?.kind?"true":"false"}
|
||||
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
|
||||
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
|
||||
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(e.id,i.id)}}>
|
||||
<span class="summary-source-caption"><strong>${g}</strong>
|
||||
${_?a`<small>${_}</small>`:e}</span>
|
||||
${_?a`<small>${_}</small>`:s}</span>
|
||||
${l("chevron")}
|
||||
</button>
|
||||
${k&&f?a`<div class="summary-source-picker"
|
||||
@@ -135,39 +135,39 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
<div class="summary-source-results" role="listbox"
|
||||
aria-label=${b("summary.select_source")}>
|
||||
<div class="summary-source-group">${b("summary.system_group")}</div>
|
||||
${["device_count","total_area","datetime"].map(e=>a`
|
||||
${["device_count","total_area","datetime"].map(s=>a`
|
||||
<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`system:${e}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
|
||||
${b(`summary.system.${e}`)}
|
||||
aria-selected=${p===`system:${s}`?"true":"false"}
|
||||
@click=${()=>m.setSource(e.id,i.id,`system:${s}`)}>
|
||||
${b(`summary.system.${s}`)}
|
||||
</button>`)}
|
||||
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
|
||||
<button type="button" role="option" class="broken" aria-selected="true"
|
||||
?disabled=${d.busy}
|
||||
@click=${()=>m.setSource(s.id,i.id,p)}>
|
||||
@click=${()=>m.setSource(e.id,i.id,p)}>
|
||||
${g}
|
||||
</button>`:e}
|
||||
</button>`:s}
|
||||
<div class="summary-source-group">${b("summary.entities_group")}</div>
|
||||
${f.entries.map(e=>a`<button type="button" role="option"
|
||||
${f.entries.map(s=>a`<button type="button" role="option"
|
||||
?disabled=${d.busy}
|
||||
aria-selected=${p===`entity:${e.id}`?"true":"false"}
|
||||
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
|
||||
<span>${e.label}</span><small>${e.id}</small>
|
||||
aria-selected=${p===`entity:${s.id}`?"true":"false"}
|
||||
@click=${()=>m.setSource(e.id,i.id,`entity:${s.id}`)}>
|
||||
<span>${s.label}</span><small>${s.id}</small>
|
||||
</button>`)}
|
||||
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
|
||||
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
|
||||
${f.total?s:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
|
||||
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:s}
|
||||
</div>
|
||||
</div>`:e}
|
||||
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
|
||||
</div>`:s}
|
||||
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:s}
|
||||
</div>
|
||||
<button class="summary-icon-button summary-value-remove" type="button"
|
||||
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
|
||||
</div>`})}
|
||||
<button class="summary-add summary-add-value" type="button"
|
||||
?disabled=${s.values.length>=20||d.busy}
|
||||
title=${s.values.length>=20?b("summary.limit_values"):""}
|
||||
?disabled=${e.values.length>=20||d.busy}
|
||||
title=${e.values.length>=20?b("summary.limit_values"):""}
|
||||
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
|
||||
${l("plus")}${b("summary.add_value")}
|
||||
</button>
|
||||
@@ -186,11 +186,11 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
|
||||
${l("plus")}${b("summary.add_block")}
|
||||
</button>
|
||||
</section>
|
||||
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
|
||||
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:s}
|
||||
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
|
||||
?disabled=${d.busy} @click=${()=>m.reload()}>
|
||||
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
|
||||
</button>`:e}
|
||||
</button>`:s}
|
||||
`}
|
||||
</div>
|
||||
<div class="row summary-editor-footer" slot="footer">
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{eM as e,O as t,b5 as n,M as i,af as o,ad as r,ce as s,eN as a,cd as c,b4 as l,b3 as d,b6 as u,eO as f,eP as m,cg as g,eQ as v}from"./houseplan-card-DGdbxgxC.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};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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 l,b4 as c,b3 as d,b6 as u,f1 as f,f2 as m,ch as g,f3 as v}from"./houseplan-card-BOhGeQlv.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,l="entity"===r?e.registry.entities?.[s]:null,c="device"===r?s:l?.device_id,d="device"===r?a?.area_id:l?.area_id||c&&e.registry.devices?.[c]?.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;c&&e.registry.devices?.[c]&&e.spaceIds.has(f)&&(!n.devices.has(c)||"entity"===r&&n.liveEntities.has(s))&&t.add(c)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=c(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(c,d,u=h){try{let f=0;for(const m of d){const d=c.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 l=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(l,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+=l(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};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,r as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,r as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,n as fingerprint};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; 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} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",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"};const n="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";export{e as dictionary,n as fingerprint};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; 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} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",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"};const s="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";export{e as dictionary,s as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";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="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,n as fingerprint};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",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:"другое пространство"};const a="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";export{e as dictionary,a as fingerprint};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{n as t,c as n}from"./zigbee-topology-DZ_-WbFy.js";import{aP as e}from"./houseplan-card-DGdbxgxC.js";import"./namespace-language-CPNZBROg.js";const o=new WeakMap;function i(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=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(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 a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function m(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function h(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,h=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,h=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&m(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=m(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&h?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,h as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";try{await import("./houseplan-assets/houseplan-card-DGdbxgxC.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__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";try{await import("./houseplan-assets/houseplan-card-BOhGeQlv.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)}
|
||||
|
||||
@@ -35,6 +35,7 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .led_strips import led_strip_link_report, plan_only_strips, unbind_strips
|
||||
from .radar_validation import validate_marker_radars
|
||||
from .store import HouseplanData
|
||||
from .validation import (
|
||||
@@ -294,6 +295,10 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
projected["decor"] = [
|
||||
_project_plan_only_decor(shape) for shape in space.get("decor") or []
|
||||
]
|
||||
led_strips = plan_only_strips(space)
|
||||
if led_strips is not None:
|
||||
# #780: plan-only keeps the strip geometry and never a device link.
|
||||
projected["led_strips"] = led_strips
|
||||
if "stairs" in space:
|
||||
projected["stairs"] = [
|
||||
_pick_fields(stair, (
|
||||
@@ -694,6 +699,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
# before an import token can be issued.
|
||||
if model > 0:
|
||||
config_candidate["model_version"] = model
|
||||
# #780: the schema step unbinds strips whose marker is not in the
|
||||
# document; count them before it does, for the import summary.
|
||||
unbound_led_strips = (
|
||||
led_strip_link_report(config_candidate)["unbound"]
|
||||
if isinstance(config_candidate, dict) else 0
|
||||
)
|
||||
config = CONFIG_SCHEMA(config_candidate)
|
||||
config.pop("model_version", None)
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
@@ -730,6 +741,10 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
**(document.get("transfer") or {}),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
}
|
||||
# Computed here, never trusted from the file.
|
||||
document["transfer"].pop("unbound_led_strips", None)
|
||||
if unbound_led_strips:
|
||||
document["transfer"]["unbound_led_strips"] = unbound_led_strips
|
||||
if plan_only:
|
||||
_validate_plan_only_document(document, config, layout, placement)
|
||||
if len(json.dumps(
|
||||
@@ -1608,6 +1623,14 @@ def build_space_merge(
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
|
||||
# #780: an LED strip follows its marker through the same id remap; a
|
||||
# marker that was skipped or virtualised by the duplicate policy is not
|
||||
# transferred, so the strip arrives unbound (geometry kept, no device).
|
||||
unbound_led_strips = unbind_strips(space, remap={
|
||||
old_id: new_id for old_id, new_id in marker_map.items()
|
||||
if old_id not in virtualized_targets
|
||||
})
|
||||
|
||||
output_markers_by_id = {
|
||||
str(marker.get("id")): marker for marker in output_markers
|
||||
if marker.get("id") is not None
|
||||
@@ -1709,6 +1732,7 @@ def build_space_merge(
|
||||
"virtualized": virtualized,
|
||||
"orphan_markers": len(output_markers) - len(marker_map),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
"unbound_led_strips": unbound_led_strips,
|
||||
"repaired_target_refs": repaired_target_refs,
|
||||
"preserved_unresolved_refs": sum(
|
||||
int(value) for value in reference_report["preservedUnresolved"].values()
|
||||
@@ -1914,6 +1938,14 @@ def _materialize_import_candidate(
|
||||
details = {
|
||||
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
|
||||
}
|
||||
# #780: strips unbound by the document itself (counted while parsing)
|
||||
# plus those the transfer unbound (space merge) or the schema step will.
|
||||
transfer = prepared.get("transfer") if isinstance(prepared.get("transfer"), dict) else {}
|
||||
details["unbound_led_strips"] = (
|
||||
int(details.get("unbound_led_strips", 0))
|
||||
+ int(transfer.get("unbound_led_strips", 0) or 0)
|
||||
+ led_strip_link_report(config)["unbound"]
|
||||
)
|
||||
try:
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""LED strips: stored shape, link normalisation and transfer rules (#780).
|
||||
|
||||
A strip is optional geometry of a space::
|
||||
|
||||
space.led_strips = [{"id": str, "points": [[x, y], ...],
|
||||
"marker": str | None, "active": bool (optional)}]
|
||||
|
||||
``active`` is the representation of the bound marker: absent/``True`` draws
|
||||
the strip, ``False`` keeps the shape hidden while the same marker is shown as
|
||||
an ordinary icon. It is neither ``marker.hidden`` nor the power state.
|
||||
|
||||
The module is pure (voluptuous only), so the schema, the write-path
|
||||
normalisation and the import/plan-only projections are exercised by the pure
|
||||
pytest suite without Home Assistant.
|
||||
|
||||
Write-path order (ТЗ #780 §9):
|
||||
|
||||
1. structure, types and limits (``LED_STRIPS_SCHEMA`` inside ``SPACE_SCHEMA``);
|
||||
2. per-space invariants and duplicate links — **before** any normalisation, so
|
||||
two strips pointing at the same missing id are still a conflict;
|
||||
3. normalisation: a link to a marker that is not live in the resulting config
|
||||
becomes an unbound strip (``marker: None, active: True``) with its id and
|
||||
points intact — a client that does not know about strips may delete a bound
|
||||
marker and its save must not fail; a live marker with an empty ``space``
|
||||
adopts the strip's space;
|
||||
4. referential check: a live marker with a non-empty foreign ``space``
|
||||
rejects the whole write.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
MAX_LED_STRIPS = 50
|
||||
MAX_LED_POINTS = 50
|
||||
LED_ID_MAX = 64
|
||||
LED_MARKER_MAX = 500
|
||||
CANVAS_LIMIT = 5000.0
|
||||
|
||||
LED_KEY = "led_strips"
|
||||
|
||||
|
||||
def _coordinate(value: Any) -> float:
|
||||
"""A stored coordinate: a real finite number, never a string or a bool."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise vol.Invalid("LED strip coordinate must be a number")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise vol.Invalid("LED strip coordinate must be finite")
|
||||
if abs(number) > CANVAS_LIMIT:
|
||||
raise vol.Invalid("LED strip coordinate is outside the canvas")
|
||||
return number
|
||||
|
||||
|
||||
def _strict_bool(value: Any) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid("LED strip active must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _marker_ref(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value or len(value) > LED_MARKER_MAX:
|
||||
raise vol.Invalid("LED strip marker must be a non-empty string or null")
|
||||
return value
|
||||
|
||||
|
||||
def _strip_id(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > LED_ID_MAX:
|
||||
raise vol.Invalid("LED strip id must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
_POINT = vol.All([_coordinate], vol.Length(min=2, max=2))
|
||||
|
||||
LED_STRIP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): _strip_id,
|
||||
vol.Required("points"): vol.All(
|
||||
list, vol.Length(min=2, max=MAX_LED_POINTS), [_POINT],
|
||||
),
|
||||
vol.Optional("marker", default=None): _marker_ref,
|
||||
vol.Optional("active"): _strict_bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
LED_STRIPS_SCHEMA = vol.All(list, vol.Length(max=MAX_LED_STRIPS), [LED_STRIP_SCHEMA])
|
||||
|
||||
|
||||
def _same(a: list[Any], b: list[Any]) -> bool:
|
||||
return float(a[0]) == float(b[0]) and float(a[1]) == float(b[1])
|
||||
|
||||
|
||||
def strip_is_closed(points: list[Any]) -> bool:
|
||||
return len(points) >= 4 and _same(points[0], points[-1])
|
||||
|
||||
|
||||
def polyline_length(points: list[Any]) -> float:
|
||||
total = 0.0
|
||||
for index in range(1, len(points)):
|
||||
ax, ay = points[index - 1]
|
||||
bx, by = points[index]
|
||||
total += math.hypot(float(bx) - float(ax), float(by) - float(ay))
|
||||
return total
|
||||
|
||||
|
||||
def _distinct_vertices(points: list[Any]) -> int:
|
||||
seen: set[tuple[float, float]] = set()
|
||||
for point in points:
|
||||
seen.add((float(point[0]), float(point[1])))
|
||||
return len(seen)
|
||||
|
||||
|
||||
def validate_strip_shape(strip: dict[str, Any]) -> None:
|
||||
"""Geometry invariants that the type schema cannot express."""
|
||||
points = strip["points"]
|
||||
if _distinct_vertices(points) < 2 or not polyline_length(points) > 0:
|
||||
raise vol.Invalid("LED strip needs at least two distinct points")
|
||||
if _same(points[0], points[-1]) and len(points) > 2:
|
||||
# A repeated first point closes the strip: three distinct vertices.
|
||||
if _distinct_vertices(points[:-1]) < 3:
|
||||
raise vol.Invalid("a closed LED strip needs three distinct vertices")
|
||||
if strip.get("active") is False and not isinstance(strip.get("marker"), str):
|
||||
raise vol.Invalid("a hidden LED strip shape must belong to a marker")
|
||||
|
||||
|
||||
class LedStripLinkError(ValueError):
|
||||
"""A write that cannot be normalised: duplicate or foreign link."""
|
||||
|
||||
code = "invalid_led_strip"
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
# Marker ids may carry user-controlled HA identifiers: a stable
|
||||
# message without the value, like DuplicateMarkerIdError.
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _live_markers(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
live: dict[str, dict[str, Any]] = {}
|
||||
for marker in config.get("markers") or []:
|
||||
if not isinstance(marker, dict) or marker.get("removed") is True:
|
||||
continue
|
||||
marker_id = marker.get("id")
|
||||
if isinstance(marker_id, str) and marker_id and marker_id not in live:
|
||||
live[marker_id] = marker
|
||||
return live
|
||||
|
||||
|
||||
def _strips(config: dict[str, Any]) -> Iterator[tuple[dict[str, Any], dict[str, Any]]]:
|
||||
for space in config.get("spaces") or []:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if isinstance(strip, dict):
|
||||
yield space, strip
|
||||
|
||||
|
||||
def preserve_led_strips(config: dict[str, Any], previous: dict[str, Any] | None) -> int:
|
||||
"""An ordinary writer that omits ``led_strips`` keeps the stored shapes (#780 r1 H1).
|
||||
|
||||
A previous frontend does not know the field and sends every space without
|
||||
it; the omission is not a deletion. A new client deletes shapes by sending
|
||||
an explicit list (``[]`` removes all of them). Applies to spaces that keep
|
||||
their id; a space the writer removed takes its strips with it. Runs on the
|
||||
ordinary write path before normalisation, so a preserved link to a marker
|
||||
the same write deleted becomes an unbound strip by the usual rule.
|
||||
Returns how many spaces received their stored shapes back.
|
||||
"""
|
||||
stored = {
|
||||
space.get("id"): space[LED_KEY]
|
||||
for space in (previous or {}).get("spaces") or []
|
||||
if isinstance(space, dict) and isinstance(space.get(LED_KEY), list) and space[LED_KEY]
|
||||
}
|
||||
restored = 0
|
||||
for space in config.get("spaces") or []:
|
||||
if not isinstance(space, dict) or LED_KEY in space:
|
||||
continue
|
||||
shapes = stored.get(space.get("id"))
|
||||
if shapes:
|
||||
space[LED_KEY] = copy.deepcopy(shapes)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
def led_strip_link_report(config: dict[str, Any]) -> dict[str, int]:
|
||||
"""What the normaliser would change, without changing anything.
|
||||
|
||||
The write path answers the client with these counters so a new client
|
||||
knows to re-read; an old client ignores the extra keys.
|
||||
"""
|
||||
live = _live_markers(config)
|
||||
unbound = adopted = 0
|
||||
for _space, strip in _strips(config):
|
||||
ref = strip.get("marker")
|
||||
if not isinstance(ref, str) or not ref:
|
||||
continue
|
||||
marker = live.get(ref)
|
||||
if marker is None:
|
||||
unbound += 1
|
||||
elif marker.get("space") in (None, ""):
|
||||
adopted += 1
|
||||
return {"unbound": unbound, "space_adopted": adopted}
|
||||
|
||||
|
||||
def normalize_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and normalise every strip of a whole configuration in place.
|
||||
|
||||
Runs after the type schema and the coordinate canonicalisation, so the
|
||||
shape invariants judge the stored numbers.
|
||||
"""
|
||||
taken: set[str] = set()
|
||||
for space in config.get("spaces") or []:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
ids: set[str] = set()
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if strip["id"] in ids:
|
||||
raise vol.Invalid("LED strip ids must be unique within a space")
|
||||
ids.add(strip["id"])
|
||||
validate_strip_shape(strip)
|
||||
ref = strip.get("marker")
|
||||
if isinstance(ref, str):
|
||||
if ref in taken:
|
||||
raise LedStripLinkError("a marker is bound to more than one LED strip")
|
||||
taken.add(ref)
|
||||
live = _live_markers(config)
|
||||
for space, strip in _strips(config):
|
||||
ref = strip.get("marker")
|
||||
if not isinstance(ref, str):
|
||||
strip["marker"] = None
|
||||
continue
|
||||
marker = live.get(ref)
|
||||
if marker is None:
|
||||
strip["marker"] = None
|
||||
strip["active"] = True
|
||||
continue
|
||||
owner = marker.get("space")
|
||||
if owner in (None, ""):
|
||||
marker["space"] = space["id"]
|
||||
elif owner != space["id"]:
|
||||
raise LedStripLinkError("an LED strip is bound to a marker of another space")
|
||||
return config
|
||||
|
||||
|
||||
def config_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""voluptuous step for CONFIG_SCHEMA: ``LedStripLinkError`` → ``vol.Invalid``."""
|
||||
try:
|
||||
return normalize_led_strip_links(config)
|
||||
except LedStripLinkError as err:
|
||||
raise vol.Invalid(f"{LedStripLinkError.code}: {err.reason}") from err
|
||||
|
||||
|
||||
def unbind_strips(space: dict[str, Any], keep: set[str] | None = None,
|
||||
remap: dict[str, str] | None = None) -> int:
|
||||
"""Transfer rule for imports/copies: remap a link or make the strip unbound.
|
||||
|
||||
``remap`` maps the source marker id to the id it received in the target;
|
||||
a marker that was not transferred (skipped duplicate, plan-only, dropped)
|
||||
leaves an unbound strip with ``active: True``. Returns how many strips
|
||||
became unbound, for the import summary.
|
||||
"""
|
||||
unbound = 0
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if not isinstance(strip, dict):
|
||||
continue
|
||||
ref = strip.get("marker")
|
||||
if not isinstance(ref, str) or not ref:
|
||||
strip["marker"] = None
|
||||
continue
|
||||
target = (remap or {}).get(ref)
|
||||
if target is None and keep is not None and ref in keep:
|
||||
target = ref
|
||||
if target is None:
|
||||
strip["marker"] = None
|
||||
strip["active"] = True
|
||||
unbound += 1
|
||||
else:
|
||||
strip["marker"] = target
|
||||
return unbound
|
||||
|
||||
|
||||
def plan_only_strips(space: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
"""Plan-only export: geometry stays, device links never leak."""
|
||||
if LED_KEY not in space:
|
||||
return None
|
||||
projected = []
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if not isinstance(strip, dict):
|
||||
continue
|
||||
projected.append({
|
||||
"id": strip.get("id"),
|
||||
"points": [list(point) for point in strip.get("points") or []],
|
||||
"marker": None,
|
||||
"active": True,
|
||||
})
|
||||
return projected
|
||||
|
||||
|
||||
def led_strip_counts(config: dict[str, Any]) -> dict[str, int]:
|
||||
"""Support-package counters: no coordinates, names or HA identifiers."""
|
||||
total = unbound = hidden = 0
|
||||
for _space, strip in _strips(config):
|
||||
total += 1
|
||||
if not isinstance(strip.get("marker"), str):
|
||||
unbound += 1
|
||||
elif strip.get("active") is False:
|
||||
hidden += 1
|
||||
return {"led_strips": total, "led_strips_unbound": unbound, "led_strips_hidden": hidden}
|
||||
@@ -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.79.0-beta.2"
|
||||
"version": "1.80.0-beta.4"
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import (
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES,
|
||||
PLAN_MODEL_VERSION,
|
||||
)
|
||||
from .led_strips import led_strip_counts
|
||||
|
||||
PACKAGE_FORMAT = "houseplan-support-package"
|
||||
PACKAGE_VERSION = 1
|
||||
@@ -457,10 +458,21 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
"lifecycle": dict(sorted(lifecycles.items())),
|
||||
"binding": dict(sorted(bindings.items())),
|
||||
},
|
||||
# #780: counters only — no coordinates, room names or HA identifiers.
|
||||
"led_strips": _led_strip_summary(spaces),
|
||||
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
|
||||
}
|
||||
|
||||
|
||||
def _led_strip_summary(spaces: list[Any]) -> dict[str, int]:
|
||||
counts = led_strip_counts({"spaces": [space for space in spaces if isinstance(space, dict)]})
|
||||
return {
|
||||
"total": counts["led_strips"],
|
||||
"unbound": counts["led_strips_unbound"],
|
||||
"hidden": counts["led_strips_hidden"],
|
||||
}
|
||||
|
||||
|
||||
def _binding_kind(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
|
||||
@@ -17,6 +17,11 @@ from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
from custom_components.houseplan.led_strips import (
|
||||
LED_STRIPS_SCHEMA,
|
||||
config_led_strip_links,
|
||||
preserve_led_strips,
|
||||
)
|
||||
from custom_components.houseplan.vacuum_routes import validate_marker_routes
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
@@ -1877,6 +1882,8 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
vol.Remove("segments"): object,
|
||||
# #780: optional LED strip shapes of the space (led_strips.py).
|
||||
vol.Optional("led_strips"): LED_STRIPS_SCHEMA,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
), _space_geometry_invariants)
|
||||
@@ -2268,13 +2275,16 @@ def prepare_ordinary_summary_candidate(
|
||||
readable_entity_ids: set[str],
|
||||
normalize,
|
||||
) -> dict:
|
||||
"""Apply the one summary-panel contract shared by ordinary config writers.
|
||||
"""Apply the one preservation contract shared by ordinary config writers.
|
||||
|
||||
`normalize` owns the writer-specific schema/migration sequence. Preservation
|
||||
must happen before it; change-aware reference validation must happen after
|
||||
it. Authoritative full import/restore intentionally does not use this helper.
|
||||
Preserved: the summary panel namespace (#437) and the LED strip shapes of a
|
||||
space an older writer sent without the field (#780).
|
||||
"""
|
||||
preserve_summary_panel_namespace(candidate, previous)
|
||||
preserve_led_strips(candidate, previous)
|
||||
checked = normalize(candidate)
|
||||
validate_summary_panel_references(checked, previous, readable_entity_ids)
|
||||
return checked
|
||||
@@ -2353,4 +2363,7 @@ CONFIG_SCHEMA = vol.All(
|
||||
),
|
||||
canonicalize_config_geometry,
|
||||
_config_wall_segment_invariants,
|
||||
# #780: after canonicalisation — shape invariants judge stored numbers;
|
||||
# orphan links become unbound strips, foreign/duplicate links reject.
|
||||
config_led_strip_links,
|
||||
)
|
||||
|
||||
@@ -70,6 +70,7 @@ from .import_export import (
|
||||
revalidate_candidate,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .led_strips import led_strip_link_report, preserve_led_strips
|
||||
from .plans import (
|
||||
QuotaError,
|
||||
collect_attachments,
|
||||
@@ -257,6 +258,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)
|
||||
@@ -287,6 +289,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:
|
||||
@@ -1480,6 +1483,8 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
"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 {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1681,6 +1686,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
radar_registry = radar_registry_evidence(hass)
|
||||
|
||||
# #780: what the write path will normalise in LED strip links (an
|
||||
# orphan becomes unbound, an empty marker.space adopts the strip's
|
||||
# space). Reported back so a new client re-reads; old ones ignore it.
|
||||
# An older writer omits the field: its stored shapes are kept first
|
||||
# (r1 H1), so the report also counts their links.
|
||||
preserve_led_strips(msg["config"], data.get("config"))
|
||||
led_report = led_strip_link_report(msg["config"])
|
||||
|
||||
def _validate_config_cpu():
|
||||
def _normalize(candidate):
|
||||
validate_wall_model_transition(candidate, data.get("config"))
|
||||
@@ -1789,7 +1802,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
result: dict[str, Any] = {"ok": True, "rev": new_rev}
|
||||
if any(led_report.values()):
|
||||
result["led_strips"] = led_report
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,493 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* #780 ТЗ §13.2: the `led-strips-v1` performance profile.
|
||||
*
|
||||
* The `large-house-v1` fixture (200 devices, 3 floors, 60 rooms, 100
|
||||
* openings) with 10 or 50 of each floor's existing devices shown as LED
|
||||
* strips that are on: `--size=10x5` (10 strips × 5 points) or `--size=50x50`
|
||||
* (50 strips × 50 points); `--size=none` is the same build and plan without
|
||||
* strips. No icon is added: the converted devices keep their ids, only their
|
||||
* entity becomes a light. Light radius 30 cm (the strip default), Glow on,
|
||||
* viewport 1440×1000, DPR 1, reduced motion. `--warmups` (≥1) samples are
|
||||
* discarded, then `--samples` (≥7) are judged. Every sample is cold: a new
|
||||
* browser with an empty cache mounts a new card; the warm metrics run in the
|
||||
* same page after the module has loaded.
|
||||
*
|
||||
* Metrics (judged on median AND p95 against budgets-led-strips.json):
|
||||
* firstStableRenderMs (mount → stripes and fields of every visible strip,
|
||||
* cold import included), warmSpaceReadyMs (space switch with the module
|
||||
* loaded → stable LED frame), stateUpdateMs (the interaction profile's method:
|
||||
* hass update → every stripe in its new state), panZoomMs (the interaction
|
||||
* profile's camera scenario, benchmark_large_house.mjs) and the longest Long
|
||||
* Task of the camera — the scenario and the 100-step series, retained heap
|
||||
* after 20 A→B→C→A cycles over a warm cycle (same GC protocol).
|
||||
* #789 additionally observes cameraFullCycleLongTaskMaxMs continuously through
|
||||
* full-quality restoration, immediate restart and a 500 ms tail, with the same
|
||||
* camera ceiling. Historical camera windows remain unchanged; each row retains
|
||||
* raw full-cycle Long Tasks and phase boundaries for audit.
|
||||
*
|
||||
* Counters (exact, every sample): geometry/visibility recomputes over 100
|
||||
* unrelated HA ticks, 100 pan/zoom steps and a colour-only change (0); the
|
||||
* three LED caches of the shown space — shapes ≤ 50, visibility ≤ 50,
|
||||
* compact visibility paths ≤ 2500 and retained path text ≤ 4 Mi characters
|
||||
* (≤ 8 MiB UTF-16) — identical after every cycle (growth 0). The true fan
|
||||
* count remains diagnostic: #788 removed lossy endpoint/vertex thinning;
|
||||
* after disconnect 0 retained entries and 0 live LED timers/observers; a
|
||||
* late import after disconnect restores nothing (one extra cold run with the
|
||||
* runtime response delayed); LED chunk requests (none without strips, never
|
||||
* the editor chunk in the View).
|
||||
*
|
||||
* `--merge=a.json,b.json` judges the rows of several partial reports of the
|
||||
* same size as one run — the samples are the same cold samples, only the
|
||||
* process that collected them differs (a runner with a short step limit);
|
||||
* `--warmup-only` collects the discarded warm-up of such a run.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs';
|
||||
import { ledChunkRequestName } from './performance/led-chunk-request.mjs';
|
||||
import { cameraCycleFailures, cameraCycleSampleFailures, finishLedCameraCycle } from './performance/led-camera-cycle.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const warmupOnly = process.argv.includes('--warmup-only');
|
||||
const samples = warmupOnly ? 0 : Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
const warmups = warmupOnly ? 1 : Math.max(0, Math.min(5, Number(valueArg('warmups') ?? 1)));
|
||||
const size = valueArg('size') ?? '10x5';
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const merge = valueArg('merge')?.split(',').filter(Boolean).map((file) => resolve(file)) ?? null;
|
||||
const skipLateImport = process.argv.includes('--no-late-import');
|
||||
const SIZES = { '10x5': [10, 5], '50x50': [50, 50], none: [0, 0] };
|
||||
if (!SIZES[size]) throw new Error(`unknown size: ${size}`);
|
||||
const [STRIPS, POINTS] = SIZES[size];
|
||||
const PROFILE = 'led-strips-v1';
|
||||
const CYCLES = Math.max(1, Math.min(20, Number(valueArg('cycles')) || 20));
|
||||
const MIN_SAMPLES = 7;
|
||||
|
||||
const fixture = makeLedStripsFixture(STRIPS, POINTS);
|
||||
const sourceSha = (() => {
|
||||
try { return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { return null; }
|
||||
})();
|
||||
const budgets = JSON.parse(readFileSync(new URL('./performance/budgets-led-strips.json', import.meta.url), 'utf8'));
|
||||
|
||||
/**
|
||||
* Installed in the page before the card mounts anything: timers, frames and
|
||||
* observers created from an LED chunk (its file name is in the creation
|
||||
* stack) are tracked until they fire, are cleared or disconnected.
|
||||
*/
|
||||
function installLedLifecycleTracker() {
|
||||
if (window.__ledLive) return;
|
||||
const live = { timers: new Set(), frames: new Set(), observers: new Set() };
|
||||
window.__ledLive = live;
|
||||
const fromLed = () => /led-strip-/.test(new Error().stack || '');
|
||||
const nativeTimeout = window.setTimeout, nativeClearTimeout = window.clearTimeout;
|
||||
window.setTimeout = function setTimeout(fn, ms, ...rest) {
|
||||
const mine = fromLed();
|
||||
let id = 0;
|
||||
id = nativeTimeout.call(window, typeof fn === 'function'
|
||||
? (...args) => { live.timers.delete(id); return fn(...args); } : fn, ms, ...rest);
|
||||
if (mine) live.timers.add(id);
|
||||
return id;
|
||||
};
|
||||
window.clearTimeout = function clearTimeout(id) { live.timers.delete(id); return nativeClearTimeout.call(window, id); };
|
||||
const nativeInterval = window.setInterval, nativeClearInterval = window.clearInterval;
|
||||
window.setInterval = function setInterval(fn, ms, ...rest) {
|
||||
const id = nativeInterval.call(window, fn, ms, ...rest);
|
||||
if (fromLed()) live.timers.add(id);
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = function clearInterval(id) { live.timers.delete(id); return nativeClearInterval.call(window, id); };
|
||||
const nativeFrame = window.requestAnimationFrame, nativeCancelFrame = window.cancelAnimationFrame;
|
||||
window.requestAnimationFrame = function requestAnimationFrame(fn) {
|
||||
const mine = fromLed();
|
||||
let id = 0;
|
||||
id = nativeFrame.call(window, (time) => { live.frames.delete(id); return fn(time); });
|
||||
if (mine) live.frames.add(id);
|
||||
return id;
|
||||
};
|
||||
window.cancelAnimationFrame = function cancelAnimationFrame(id) { live.frames.delete(id); return nativeCancelFrame.call(window, id); };
|
||||
for (const name of ['ResizeObserver', 'MutationObserver', 'IntersectionObserver']) {
|
||||
const Native = window[name];
|
||||
if (!Native) continue;
|
||||
window[name] = class extends Native {
|
||||
constructor(callback) {
|
||||
super(callback);
|
||||
if (fromLed()) live.observers.add(this);
|
||||
}
|
||||
disconnect() { live.observers.delete(this); return super.disconnect(); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The page side of one mount: the same fixture host as before, shared by the sample and the late-import run. */
|
||||
function pageHost() {
|
||||
return `
|
||||
window.__finishLedCameraCycle = ${finishLedCameraCycle.toString()};
|
||||
window.__ledHost = (fixture) => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 });
|
||||
let states = fixture.states;
|
||||
const hassBase = {
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: structuredClone(fixture.layout), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
};
|
||||
return {
|
||||
card,
|
||||
get states() { return states; },
|
||||
setStates(next) { states = next; card.hass = { ...hassBase, states }; },
|
||||
};
|
||||
};
|
||||
window.__ledStats = async (card) => {
|
||||
const url = performance.getEntriesByType('resource').map((entry) => entry.name)
|
||||
.find((name) => /led-strip-runtime-[^/]+\\.js/.test(name));
|
||||
if (!url) return { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0, loaded: false };
|
||||
const runtime = await import(url);
|
||||
return { ...runtime.ledStats(card), loaded: true };
|
||||
};
|
||||
window.__ledLiveCounts = () => ({
|
||||
timers: window.__ledLive.timers.size, frames: window.__ledLive.frames.size,
|
||||
observers: window.__ledLive.observers.size,
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
async function sample() {
|
||||
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc']);
|
||||
const chromium = await browser.version();
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const requests = [];
|
||||
page.on('request', (request) => {
|
||||
const name = ledChunkRequestName(request.url());
|
||||
if (name) requests.push(name);
|
||||
});
|
||||
try {
|
||||
await page.evaluate(installLedLifecycleTracker);
|
||||
await page.evaluate(pageHost());
|
||||
const row = await page.evaluate(async ({ fixture, strips, cycles }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const until = async (predicate, timeout = 30000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out');
|
||||
await sleep(5);
|
||||
}
|
||||
};
|
||||
const longTasks = (onEntries) => {
|
||||
const entries = [];
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return async () => {
|
||||
await sleep(0);
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
onEntries?.(entries.map(({ startTime, duration }) => ({ startTime, duration })));
|
||||
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
|
||||
};
|
||||
};
|
||||
const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } };
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = window.__ledHost(fixture);
|
||||
const { card } = host;
|
||||
const root = () => card.renderRoot;
|
||||
const count = (selector) => root()?.querySelectorAll(selector).length ?? 0;
|
||||
const ledStable = (state = 'on') => count(`[data-led-strip][data-state="${state}"]`) === strips
|
||||
&& (state !== 'on' || count('.led-fields [data-led-field]') === strips);
|
||||
const stats = () => window.__ledStats(card);
|
||||
|
||||
const started = performance.now();
|
||||
document.getElementById('host').replaceChildren(card);
|
||||
host.setStates(host.states);
|
||||
await until(() => card._loadOk && card._model?.length === 3);
|
||||
if (strips) await until(() => ledStable());
|
||||
else { await until(() => card._booting === false); await frame(); }
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - started).toFixed(2));
|
||||
|
||||
const warm = performance.now();
|
||||
card._pickSpace('perf-floor-2');
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable());
|
||||
await frame();
|
||||
const warmSpaceReadyMs = Number((performance.now() - warm).toFixed(2));
|
||||
|
||||
const states = host.states;
|
||||
const floorLights = Object.keys(states).filter((id) => id.startsWith('light.perf_led_1_'));
|
||||
const stateStarted = performance.now();
|
||||
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) });
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable('off'));
|
||||
await frame();
|
||||
const stateUpdateMs = Number((performance.now() - stateStarted).toFixed(2));
|
||||
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) });
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable());
|
||||
await frame();
|
||||
|
||||
const before = await stats();
|
||||
// 100 unrelated HA ticks.
|
||||
const sensor = Object.keys(host.states).find((id) => id.startsWith('sensor.'));
|
||||
for (let tick = 0; tick < 100; tick++) {
|
||||
host.setStates({ ...host.states, [sensor]: { ...host.states[sensor], state: String(20 + (tick % 10) / 10) } });
|
||||
await card.updateComplete;
|
||||
}
|
||||
await frame();
|
||||
const afterTicks = await stats();
|
||||
// The camera: the interaction profile's scenario (one wheel step, its
|
||||
// render and frame — benchmark_large_house.mjs), then 100 steps whose
|
||||
// Long Tasks are judged too and whose recomputes are counted.
|
||||
const stage = root().querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true,
|
||||
}));
|
||||
// Keep the old pan/series windows intact. This separate observer also
|
||||
// covers the first quality switch, both restores and the quick restart.
|
||||
const cameraFullCycle = { startTime: performance.now(), entries: [], phases: {} };
|
||||
const stopFullCycle = longTasks(entries => { cameraFullCycle.entries = entries; });
|
||||
const stopPan = longTasks();
|
||||
const panStarted = performance.now();
|
||||
wheel(-120);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const panZoomMs = Number((performance.now() - panStarted).toFixed(2));
|
||||
const panZoomLongTaskMaxMs = await stopPan();
|
||||
const stopSeries = longTasks();
|
||||
// One wheel step per frame, as a real wheel or trackpad delivers them.
|
||||
for (let step = 0; step < 100; step++) {
|
||||
wheel(step % 2 ? 60 : -60);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
await frame();
|
||||
const cameraSeriesLongTaskMaxMs = await stopSeries();
|
||||
cameraFullCycle.phases.seriesEnd = performance.now();
|
||||
Object.assign(cameraFullCycle.phases, await window.__finishLedCameraCycle({
|
||||
fullQuality: () => !card.hasAttribute('data-led-zoom-quality'),
|
||||
input: async () => { wheel(-60); await card.updateComplete; },
|
||||
frame, now: () => performance.now(), sleep,
|
||||
}));
|
||||
const cameraFullCycleLongTaskMaxMs = await stopFullCycle();
|
||||
cameraFullCycle.endTime = performance.now();
|
||||
const afterCamera = await stats();
|
||||
// A colour-only change rebuilds no geometry or visibility.
|
||||
host.setStates({ ...host.states, ...Object.fromEntries(floorLights.map((id) => [id,
|
||||
{ ...host.states[id], attributes: { ...host.states[id].attributes, rgb_color: [255, 120, 80] } }])) });
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const afterColour = await stats();
|
||||
|
||||
// 20 cycles A→B→C→A after one warm cycle; the three caches identical after each.
|
||||
const cycle = async () => {
|
||||
for (const id of ['perf-floor-1', 'perf-floor-2', 'perf-floor-3', 'perf-floor-1']) {
|
||||
card._pickSpace(id);
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable());
|
||||
}
|
||||
await frame();
|
||||
const { shapes, visibility, sources, visibilityPaths, pathChars } = await stats();
|
||||
return { shapes, visibility, sources, visibilityPaths, pathChars };
|
||||
};
|
||||
await cycle();
|
||||
await gc();
|
||||
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
|
||||
const cycleStats = [];
|
||||
for (let index = 0; index < cycles; index++) cycleStats.push(await cycle());
|
||||
await gc();
|
||||
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
|
||||
const liveBeforeDisconnect = window.__ledLiveCounts();
|
||||
card.remove();
|
||||
await frame();
|
||||
await sleep(600);
|
||||
const afterDisconnect = await stats();
|
||||
const liveAfterDisconnect = window.__ledLiveCounts();
|
||||
const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}/${entry.visibilityPaths}/${entry.pathChars}`;
|
||||
return {
|
||||
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, cameraSeriesLongTaskMaxMs,
|
||||
cameraFullCycleLongTaskMaxMs, cameraFullCycle,
|
||||
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
|
||||
counters: strips ? {
|
||||
recomputesOnHaTicks: afterTicks.recomputes - before.recomputes,
|
||||
recomputesOnCamera: afterCamera.recomputes - afterTicks.recomputes,
|
||||
recomputesOnColour: afterColour.recomputes - afterCamera.recomputes,
|
||||
shapes: Math.max(...cycleStats.map((entry) => entry.shapes)),
|
||||
visibility: Math.max(...cycleStats.map((entry) => entry.visibility)),
|
||||
sources: Math.max(...cycleStats.map((entry) => entry.sources)),
|
||||
visibilityPaths: Math.max(...cycleStats.map((entry) => entry.visibilityPaths)),
|
||||
pathChars: Math.max(...cycleStats.map((entry) => entry.pathChars)),
|
||||
cacheGrowthOverCycles: new Set(cycleStats.map(key)).size - 1,
|
||||
} : null,
|
||||
disconnect: {
|
||||
retained: afterDisconnect.shapes + afterDisconnect.visibility + afterDisconnect.sources
|
||||
+ afterDisconnect.visibilityPaths + afterDisconnect.pathChars,
|
||||
liveBefore: liveBeforeDisconnect,
|
||||
live: liveAfterDisconnect.timers + liveAfterDisconnect.frames + liveAfterDisconnect.observers,
|
||||
},
|
||||
};
|
||||
}, { fixture, strips: STRIPS, cycles: CYCLES });
|
||||
row.ledRequests = [...new Set(requests)].sort();
|
||||
return { row, chromium };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The late import: the card is removed while the runtime chunk is still on
|
||||
* the wire (its response is held back); when it lands nothing is rendered,
|
||||
* cached or scheduled for the gone card.
|
||||
*/
|
||||
async function lateImport() {
|
||||
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1, []);
|
||||
try {
|
||||
let held = false;
|
||||
let runtimeUrl = null;
|
||||
await page.route(/led-strip-runtime-[^/]+\.js/, async (route) => {
|
||||
held = true;
|
||||
runtimeUrl = route.request().url();
|
||||
await new Promise((done) => setTimeout(done, 1500));
|
||||
await route.fallback();
|
||||
});
|
||||
await page.evaluate(installLedLifecycleTracker);
|
||||
await page.evaluate(pageHost());
|
||||
const result = await page.evaluate(async ({ fixture }) => {
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
window.__card?.remove?.();
|
||||
const host = window.__ledHost(fixture);
|
||||
const { card } = host;
|
||||
document.getElementById('host').replaceChildren(card);
|
||||
host.setStates(host.states);
|
||||
const started = performance.now();
|
||||
while (!(card._loadOk && card._model?.length === 3) && performance.now() - started < 20000) await sleep(10);
|
||||
await sleep(300);
|
||||
const before = card.renderRoot.querySelectorAll('[data-led-strip]').length;
|
||||
card.remove();
|
||||
window.__lateCard = card;
|
||||
await sleep(3000);
|
||||
return { stripesBeforeLoad: before, stripesAfterLoad: card.renderRoot.querySelectorAll('[data-led-strip]').length };
|
||||
}, { fixture });
|
||||
// The chunk the card asked for, by its URL: the same module instance.
|
||||
const late = await page.evaluate(async (url) => {
|
||||
const runtime = url ? await import(url) : null;
|
||||
return { stats: runtime ? { ...runtime.ledStats(window.__lateCard), loaded: true } : { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, loaded: false },
|
||||
live: window.__ledLiveCounts() };
|
||||
}, runtimeUrl);
|
||||
return { ...result, ...late, held };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
let chromium = null;
|
||||
let late = null;
|
||||
let discarded = 0;
|
||||
if (merge) {
|
||||
for (const file of merge) {
|
||||
const part = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (part.profile !== PROFILE || part.size !== size) throw new Error(`${file} is not ${PROFILE} ${size}`);
|
||||
rows.push(...part.rows);
|
||||
discarded += part.warmups;
|
||||
chromium ??= part.chromium;
|
||||
late ??= part.lateImport ?? null;
|
||||
}
|
||||
} else {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const result = await sample();
|
||||
chromium ??= result.chromium;
|
||||
if (iteration >= warmups) rows.push(result.row);
|
||||
}
|
||||
discarded = warmups;
|
||||
if (STRIPS && !skipLateImport && !warmupOnly) late = await lateImport();
|
||||
}
|
||||
if (warmupOnly) {
|
||||
// A discarded warm-up sample for a later --merge: nothing is judged here.
|
||||
const text = `${JSON.stringify({ profile: PROFILE, size, sourceSha, chromium, samples: 0, warmups: 1, rows: [], partial: true }, null, 2)}\n`;
|
||||
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
|
||||
console.log(text);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const metric = (name) => {
|
||||
const values = rows.map((row) => row[name]).filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
const at = (q) => values[Math.min(values.length - 1, Math.ceil(q * values.length) - 1)];
|
||||
return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values };
|
||||
};
|
||||
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs',
|
||||
'cameraSeriesLongTaskMaxMs', 'cameraFullCycleLongTaskMaxMs', 'retainedHeapBytes'];
|
||||
const report = {
|
||||
profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium,
|
||||
samples: rows.length, warmups: discarded, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
|
||||
merged: merge ? merge.length : undefined,
|
||||
metrics: Object.fromEntries(METRICS.map((name) => [name, metric(name)])),
|
||||
counters: rows[0]?.counters ?? null,
|
||||
counterSamples: rows.map((row) => row.counters),
|
||||
disconnectSamples: rows.map((row) => row.disconnect),
|
||||
lateImport: late,
|
||||
ledRequests: [...new Set(rows.flatMap((row) => row.ledRequests))].sort(),
|
||||
rows,
|
||||
};
|
||||
const failures = [];
|
||||
const partial = !merge && (samples < MIN_SAMPLES || warmups < 1);
|
||||
if (!partial && (report.samples < MIN_SAMPLES || report.warmups < 1)) {
|
||||
failures.push(`${report.samples} samples after ${report.warmups} warmups: the profile needs ≥ ${MIN_SAMPLES} after ≥ 1`);
|
||||
}
|
||||
const limits = budgets.sizes[size] || {};
|
||||
failures.push(...cameraCycleSampleFailures(rows));
|
||||
failures.push(...cameraCycleFailures(report.metrics.cameraFullCycleLongTaskMaxMs, limits.cameraSeriesLongTaskMaxMs));
|
||||
for (const [name, limit] of Object.entries(limits)) {
|
||||
for (const stat of ['median', 'p95']) {
|
||||
const value = report.metrics[name]?.[stat];
|
||||
if (value == null) { if (name !== 'retainedHeapBytes') failures.push(`${name} ${stat} missing`); continue; }
|
||||
if (value > limit) failures.push(`${name} ${stat} ${value} > ${limit}`);
|
||||
}
|
||||
}
|
||||
for (const [index, disconnect] of report.disconnectSamples.entries()) {
|
||||
if (disconnect.retained !== 0) failures.push(`sample ${index}: ${disconnect.retained} LED cache entries retained after disconnect`);
|
||||
if (disconnect.live !== 0) failures.push(`sample ${index}: ${disconnect.live} LED timers/frames/observers alive after disconnect`);
|
||||
}
|
||||
if (STRIPS) {
|
||||
const caches = budgets.caches;
|
||||
for (const counters of report.counterSamples) {
|
||||
for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) {
|
||||
if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`);
|
||||
}
|
||||
for (const key of ['shapes', 'visibility', 'visibilityPaths', 'pathChars']) {
|
||||
if (!Number.isFinite(counters[key])) failures.push(`${key} cache metric missing`);
|
||||
else if (counters[key] > caches[key]) failures.push(`${key} cache ${counters[key]} > ${caches[key]}`);
|
||||
}
|
||||
}
|
||||
if (late) {
|
||||
if (!late.held || !late.stats.loaded) failures.push('late import: the runtime response was never held or never landed');
|
||||
if (late.stripesAfterLoad !== late.stripesBeforeLoad) failures.push('late import rendered the gone card');
|
||||
const retained = late.stats.shapes + late.stats.visibility + late.stats.sources
|
||||
+ late.stats.visibilityPaths + late.stats.pathChars;
|
||||
if (retained) failures.push(`late import restored ${retained} cache entries`);
|
||||
if (late.live.timers + late.live.frames + late.live.observers) failures.push('late import left LED timers/observers');
|
||||
} else if (!skipLateImport) failures.push('late import not measured');
|
||||
if (!report.ledRequests.includes('led-strip-runtime') || !report.ledRequests.includes('led-strip-field'))
|
||||
failures.push(`LED chunks not loaded: ${report.ledRequests.join(', ')}`);
|
||||
} else if (report.ledRequests.length) failures.push(`no strips but LED chunks requested: ${report.ledRequests.join(', ')}`);
|
||||
if (report.ledRequests.includes('led-strip-editor')) failures.push('the View loaded the LED editor chunk');
|
||||
report.failures = failures;
|
||||
report.partial = partial || undefined;
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
|
||||
console.log(JSON.stringify({ ...report, rows: undefined }, null, 2));
|
||||
if (failures.length) { console.error(`led-strips-v1 ${size}: ${failures.length} failure(s)`); process.exit(1); }
|
||||
@@ -1,13 +1,14 @@
|
||||
/** #54: bounded provider normalization and incident-only hover performance. */
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover } from '../test-build/zigbee-topology.js';
|
||||
import { mapTopologies, normalizeZhaTopology, resolveMappedTopologyHover, resolveProviderUplinks } from '../test-build/zigbee-topology.js';
|
||||
|
||||
const NODES = 500;
|
||||
const SAMPLES = 9;
|
||||
const BUDGETS = { normalizeMs: 80, mapMs: 160, firstHoverMs: 180, repeatedHoverMs: 120 };
|
||||
const hex = (index) => index.toString(16).padStart(16, '0');
|
||||
const rows = Array.from({ length: NODES }, (_, index) => ({
|
||||
ieee: hex(index + 1), device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
|
||||
ieee: hex(index + 1), nwk: index, device_reg_id: `d${index}`, device_type: index ? 'Router' : 'Coordinator',
|
||||
routes: index ? [{ dest_nwk: '0x0000', next_hop: index - 1, route_status: 'Active' }] : [],
|
||||
neighbors: [1, 2, 3, 4, 5, 6].flatMap((offset) => {
|
||||
const other = index + offset;
|
||||
return other < NODES ? [{ ieee: hex(other + 1), lqi: (index * 17 + offset * 13) % 256 }] : [];
|
||||
@@ -39,7 +40,13 @@ const firstHoverMs = measured(() => resolveMappedTopologyHover(mapped, 'main', '
|
||||
const repeatedHoverMs = measured(() => {
|
||||
for (let i = 1; i <= 20; i++) resolveMappedTopologyHover(mapped, 'main', `m${i}`);
|
||||
});
|
||||
const result = { nodes: topology.nodes.length, links: topology.links.length,
|
||||
const confirmedRoutes = [...resolveProviderUplinks(topology).values()].filter((route) => route.kind === 'known').length;
|
||||
const witness = resolveMappedTopologyHover(mapped, 'main', 'm1');
|
||||
if (confirmedRoutes !== NODES - 1 || witness.outgoing !== 'known'
|
||||
|| witness.parentTargets.length !== 1 || witness.lines.length !== 1) {
|
||||
throw new Error('benchmark must exercise a nonempty provider-confirmed graph, outgoing remote target and incoming edge');
|
||||
}
|
||||
const result = { nodes: topology.nodes.length, links: topology.links.length, confirmedRoutes,
|
||||
normalizeMs, mapMs, firstHoverMs, repeatedHoverMs };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
for (const [name, budget] of Object.entries(BUDGETS)) {
|
||||
|
||||
@@ -136,7 +136,53 @@ const applianceSpace = {
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = (includeAppliance = false) => {
|
||||
// #780: the LED strip reference scene — the designer's four strips on a
|
||||
// synthetic plan with the reference floor colour #868D94: a free straight
|
||||
// strip, a strip on the inner face of the bottom wall, a polyline along the
|
||||
// wall faces around a corner and a closed rectangle. The colours are scene
|
||||
// values of the four light sources, not a product palette.
|
||||
const ledRooms = [
|
||||
{ id: 'led-living', name: 'LED living', area: 'golden_led_living',
|
||||
poly: [[0.06, 0.08], [0.56, 0.08], [0.56, 0.62], [0.06, 0.62]] },
|
||||
{ id: 'led-studio', name: 'LED studio', area: 'golden_led_studio',
|
||||
poly: [[0.56, 0.08], [0.94, 0.08], [0.94, 0.62], [0.56, 0.62]] },
|
||||
];
|
||||
// Inner faces of the 20 cm walls (half thickness = 10 cm = 2 cells of 1/240).
|
||||
const LED_FACE = 2 / 240;
|
||||
export const LED_REFERENCE_STRIPS = Object.freeze([
|
||||
{ id: 'led-free', marker: 'demo-led-strip', color: [128, 213, 255],
|
||||
points: [[0.14, 0.26], [0.44, 0.26]] },
|
||||
{ id: 'led-wall', marker: 'demo-led-strip-wall', color: [230, 128, 255],
|
||||
points: [[0.12, round(0.62 - LED_FACE)], [0.50, round(0.62 - LED_FACE)]] },
|
||||
{ id: 'led-corner', marker: 'demo-led-strip-corner', color: [255, 234, 128],
|
||||
points: [[0.64, round(0.08 + LED_FACE)], [round(0.94 - LED_FACE), round(0.08 + LED_FACE)],
|
||||
[round(0.94 - LED_FACE), 0.42]] },
|
||||
{ id: 'led-loop', marker: 'demo-led-strip-loop', color: [88, 255, 88],
|
||||
points: [[0.66, 0.30], [0.84, 0.30], [0.84, 0.52], [0.66, 0.52], [0.66, 0.30]] },
|
||||
]);
|
||||
|
||||
const ledSpace = {
|
||||
id: 'golden-led',
|
||||
title: 'LED strips',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 0.7],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'custom', custom_fill: { c: '#868D94', a: 1 }, glow_enabled: true,
|
||||
show_borders: true, show_names: false, sun_rays: false, bg_mode: 'static',
|
||||
},
|
||||
rooms: ledRooms,
|
||||
walls: wallsFor('led', ledRooms, 20),
|
||||
openings: [
|
||||
{ id: 'led-door', type: 'door', x: 0.56, y: 0.53, angle: 90, length: 0.10 },
|
||||
],
|
||||
partitions: [],
|
||||
wall_columns: [],
|
||||
decor: [],
|
||||
led_strips: LED_REFERENCE_STRIPS.map(({ id, marker, points }) => ({ id, marker, points })),
|
||||
};
|
||||
|
||||
const runtime = (includeAppliance = false, includeLed = false) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
@@ -199,6 +245,15 @@ const runtime = (includeAppliance = false) => {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
|
||||
if (includeLed) {
|
||||
for (const room of ledRooms) areas[room.area] = { area_id: room.area, name: room.name };
|
||||
for (const strip of LED_REFERENCE_STRIPS) {
|
||||
add(strip.marker, 'light', ledRooms[strip.id === 'led-free' || strip.id === 'led-wall' ? 0 : 1].area,
|
||||
0.5, 0.5, 'on', { rgb_color: strip.color, brightness: 255 });
|
||||
layout[strip.marker] = { s: 'golden-led', x: 0.5, y: 0.35 };
|
||||
}
|
||||
}
|
||||
|
||||
if (includeAppliance) {
|
||||
const washerId = 'golden-washer';
|
||||
devices[washerId] = {
|
||||
@@ -247,16 +302,23 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false, ledStrips = false } = {}) => ({
|
||||
config: {
|
||||
spaces: [
|
||||
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
||||
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
|
||||
...(ledStrips ? [structuredClone(ledSpace)] : []),
|
||||
],
|
||||
// A persisted marker is part of the fixture contract for scenarios that
|
||||
// override per-source Glow controls. The device/layout alone are not a
|
||||
// saved marker configuration and must not be silently treated as one.
|
||||
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
|
||||
markers: [
|
||||
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
|
||||
// #780: a strip link writes the marker's explicit space (ТЗ §5).
|
||||
...(ledStrips ? LED_REFERENCE_STRIPS.map(({ marker }) => ({
|
||||
id: marker, binding: `device:${marker}`, space: 'golden-led',
|
||||
})) : []),
|
||||
],
|
||||
settings: {
|
||||
glow_radius_cm: 360,
|
||||
north_deg: 0,
|
||||
@@ -269,7 +331,7 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) =>
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(applianceLifecycle),
|
||||
...runtime(applianceLifecycle, ledStrips),
|
||||
counts: applianceLifecycle ? {
|
||||
...VISUAL_MATRIX_COUNTS,
|
||||
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"matrixVersion": 70,
|
||||
"acceptedAt": "2026-10-01T00:05:17.573Z",
|
||||
"sourceFingerprint": "182c3c9d02d59822dca143cc7626b316b9be5ba20ec8403c282b973832501e45",
|
||||
"matrixVersion": 71,
|
||||
"acceptedAt": "2026-10-05T12:14:58.027Z",
|
||||
"sourceFingerprint": "14a902be33654296da3bbc915dcdd2c5b918936f24526ddc7a2e4b45a73afe25",
|
||||
"chromium": "151.0.7922.34",
|
||||
"capturedOn": "linux",
|
||||
"acceptedOn": "linux",
|
||||
@@ -10,18 +10,41 @@
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"chromium": "151.0.7922.34",
|
||||
"buildFingerprint": "182c3c9d02d59822dca143cc7626b316b9be5ba20ec8403c282b973832501e45",
|
||||
"ci": {
|
||||
"buildFingerprint": "14a902be33654296da3bbc915dcdd2c5b918936f24526ddc7a2e4b45a73afe25",
|
||||
"ci": null
|
||||
},
|
||||
"localAttestation": {
|
||||
"schema": "houseplan-golden-wsl/v1",
|
||||
"sha256": "c0d5ed83d3bf2591a21fff465403ce4ab71c10df781a78736cf690a578a5df52",
|
||||
"artifactSha256": "5d2082d6a19c7f4ecce6ea5bdb40da9ec76d804052847b9863f770213dd0b0c0",
|
||||
"createdAt": "2026-10-05T12:14:24.651Z",
|
||||
"source": {
|
||||
"repository": "Matysh/houseplan-card",
|
||||
"run": 36790529482,
|
||||
"attempt": 1,
|
||||
"sha": "16616f09b897a465dcfa7e40ad543fd28394fa9b"
|
||||
"branch": "issue/798-provider-zigbee-routes",
|
||||
"commit": "22828495bb3ca3c038a87b68327d068e60874d9d",
|
||||
"tree": "0959cf050c960f44d6ada12c31e76335dd380430",
|
||||
"remoteSha": "22828495bb3ca3c038a87b68327d068e60874d9d",
|
||||
"clean": true,
|
||||
"status": ""
|
||||
},
|
||||
"environment": {
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"kernel": "6.18.33.2-microsoft-standard-WSL2",
|
||||
"wsl": true,
|
||||
"distro": "Ubuntu",
|
||||
"filesystem": "ext2/ext3"
|
||||
},
|
||||
"toolchain": {
|
||||
"node": "22.23.2",
|
||||
"npm": "10.9.8",
|
||||
"playwright": "1.62.0",
|
||||
"chromiumExecutableSha256": "0b20b130e7edd9dd51873be867761295fe0cfad490c2b9a64f95bd3cfc08fa71"
|
||||
}
|
||||
},
|
||||
"localAttestation": null,
|
||||
"foreignCapture": null,
|
||||
"witnesses": {
|
||||
"count": 145,
|
||||
"count": 107,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
@@ -112,17 +135,17 @@
|
||||
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
|
||||
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
|
||||
"isometric-opening-symbol-parity-dark": "639e19dddf6d0a8e4f145b6850b0ad5d20d83100fd769e2aad678eb093a33067",
|
||||
"geometry-devices-editor-dark": "1cc18ac3250a0cc20981b090ef7f964555b237e085e3ddbe10cc7b6081342c8c",
|
||||
"geometry-devices-editor-dark": "0744447d86d071d5ad1c66e44ce3fdde40b6f06b36bdab59c5a2b2cd1da72292",
|
||||
"device-inbox-desktop-en-light": "3a1229f1f5134c329ee457a62e555896ffd664d1d6cbd217e4e8248dc0c07dc7",
|
||||
"device-inbox-desktop-ru-dark": "2b0981e7cc134a8f2cfe107c58833e33b862a00a89cadc5033f3b633562f9379",
|
||||
"device-inbox-narrow-ru-dark": "7694119b368baf49fe8c0aeeec46a75bd77c0987266e4a64f5d13a2099e5f098",
|
||||
"geometry-decor-editor-dark": "0fddb5ba477ecad15827215d04d22e9ba1adfacbbfc853712b1e76832182fa5f",
|
||||
"device-inbox-narrow-ru-dark": "694b84a02e4122b973229488440e19e177b3d77dd8e9eb423304c709ffed6ad5",
|
||||
"geometry-decor-editor-dark": "4077eb3f49182b5075f8cfd3c488cd6affa0fa26f7977ebb1e94cf57baabdc4e",
|
||||
"tray-wide-selection-en": "e544ee6b4cde64aae188bac9be9e52c1232a3de8e98e1f68af42d8c7064f0a38",
|
||||
"tray-wide-tool-ru": "d3c49be18142f280c21062612cf06dc8df6e5a076c53aeb2d62da8e3975c8003",
|
||||
"tray-medium-group-en": "6ba416e0ca3cb2fba4a420703318e62d8651053d76ae1936a37537ab486af4a8",
|
||||
"tray-medium-selection-ru": "d5a82d474f75f438c06a340897a9e009e7df0c95c5ef07edd3952f0fe43a0f40",
|
||||
"tray-narrow-palette-en": "d2147c10ec130bead031db16090b0638188a548163ca8dfbfa86e68e71865051",
|
||||
"furniture-categories-light": "8ffc0d18edc76dad428190f7c041ce9fdf9c5cc71a8329116bc1c7ae040a7fd8",
|
||||
"furniture-categories-light": "4fdc9db78f6199abe86d1f982e9b1d0b71316f4ee0cbb8a1966742b141e0e0be",
|
||||
"furniture-variants-dark": "368f863d070b8f2f02cf657731aab0e7d80bdf5a817193709bb2838d429ac4e1",
|
||||
"furniture-variants-light": "a702b38b8bd42919a5ed8753b87489545eb6d6538726e5cc3cb856a586834974",
|
||||
"furniture-placement-preview-light": "8a78103d7cdd2808496f6d73b8a65859c4e6b57ee53a0c18f4d90580945673ba",
|
||||
@@ -185,25 +208,25 @@
|
||||
"large-house-zoom-040-dark": "84f9e93b5fd25caaf031dc7758553c29459d4dda31e086b7d79e904c9b3d6230",
|
||||
"large-house-zoom-250-dark": "fa901d097023320a599c1c80be9f9c65a0ce6245bb7182bc0f3d90d679668c17",
|
||||
"large-house-warm-remount-dark": "6ed51a1c76cd7c62875eadafc169f7c6fc25604e64edabd9594a3664c886987d",
|
||||
"device-dialog-desktop-en": "f5d660f9244712f016c02c560a4bd301f3729edd4ca1e63b3d2cf0fab9c71802",
|
||||
"device-dialog-desktop-en": "1a3d6c9c77b263a438eb896f24282af6998ec3e478267423b5c2fbebda75c9b5",
|
||||
"device-dialog-mobile-ru": "2486302565fb8dadb7758384e592b059df45f6f8b92195738e5a0bf62758c713",
|
||||
"device-dialog-desktop-de": "972d56b0a0307ad7a061175aa0f6147c3170fc64eadb88bafd038835fd2ce331",
|
||||
"device-dialog-desktop-de": "082969eec2b7fd472cd65a9ae162430a07bc0e72661a3b5eaae689f490369efa",
|
||||
"room-temperature-dialog-desktop-en": "cea714dbe38185ea3d936fdb7cb71a0205feb74687d3ade517aed28407abd4a9",
|
||||
"room-temperature-dialog-mobile-ru": "5ca1c4f18eab3fb4c0667f76b0d0340df94f24e0457baca89234e0ef159f62d6",
|
||||
"room-discard-dialog-mobile-ru": "9c85af0cdbd2842808ef5ac8fdb306f621a726b0ac77c1449722c85399b4cb3f",
|
||||
"room-discard-dialog-mobile-ru": "311cc2da1d85191ca26e72928a86f0846b021be85ed5ba83dd02dfe04397c10a",
|
||||
"german-view-mobile-light": "1c14ef6e97f0f7c616fe61ad72f4dfa7d94c605efc3d8b26931016c8c13a4282",
|
||||
"toggle-entity-dialog-desktop-en": "075a714a4f013c99614c4941b1202d9ae411e6382f72cb0d0a98e0f669b3388b",
|
||||
"toggle-entity-dialog-mobile-ru": "c5bf5077a33c1acfa5f744e0ab1c15ce6c2147bc3622a7e8754d972aeedb555b",
|
||||
"toggle-entity-dialog-desktop-en": "4a8de7f02995b992cb2e8110305280db4899f6fe9bbe967bb43398a5b479a694",
|
||||
"toggle-entity-dialog-mobile-ru": "e6e11b5a4f0181d0115199d5c3eac93fa2db19c73bf743713a0821ce6d9fc5d9",
|
||||
"device-help-popover-light-ru": "7645a419f8214dacc6b97be894643d12b2a0486342e568b0b3ee42d74b8cf878",
|
||||
"decor-color-popover-mobile-ru": "bdf90e0e265d41d032db948a737eeedd6e60cdacc9f85c9356aa809894654960",
|
||||
"decor-color-popover-desktop-en": "46b38745da10d4467b510c5a257102395a797e0d3229e7ce8c89e4bbf3b937e7",
|
||||
"general-color-popover-desktop-en": "b54ef50909c2e7b667d321457b17205d5de38dd39b219eef05bee5ef8fb6bef2",
|
||||
"general-color-popover-desktop-en": "8ffdaaff5c859539985cf77304ee0acd4044d8b13eae0d80ea9b5ce1ef9f346f",
|
||||
"settings-help-zoom-200-en-light": "5539d8df2691eee0b8e90fa1fe086380870eeca5d945a34bb71e65f18bb3c22f",
|
||||
"settings-help-zoom-200-ru-dark": "7be1e0c156ae3a8f71d6b55fc75e5a20e5a4404efee674fa4bebdb82004daa80",
|
||||
"support-desktop-empty-light-en": "55ffd7a63b8e4d65b8cb695a7b166e0c4bb2e719ce925b8d28b8e8595897750d",
|
||||
"support-desktop-preview-dark-en": "326c1b1ecbe71e0bdaed6445137ee4145418317bf5d5faf10faa297707fdb924",
|
||||
"support-phone-validation-light-ru": "acc540f9459c9dc38981ccb70e75a5415f17c849d3840340648448884bd2ab31",
|
||||
"support-phone-success-dark-en": "b2edd4415043d673d22dfe89ee16b9c190e7f3f9537371f01665e8e41444a00e",
|
||||
"support-phone-validation-light-ru": "85baca107a2620c3d36c09ca9d4f72e029cf41661970983560efc4066f2e5688",
|
||||
"support-phone-success-dark-en": "dcf2cb53e904dd3196052f5e0001b8aee712db9c9c0e4f8cd2982645e4bf7dbd",
|
||||
"support-relay-error-light-en": "adf30f96a926a1730c9ad34d18b4234d9b19140f11d56c8fb7508976f366c11d",
|
||||
"support-tablet-preview-dark-ru": "a69ba86bf069d9beba94eedf6cc3b385b026caeee66348e4ef42c1a072b6df6a",
|
||||
"device-ripple-color-popover-mobile-ru": "8fc31025d32d87e5d3d3df896937d182577cd5169d520e7cf5fc267e7d080776",
|
||||
@@ -216,6 +239,14 @@
|
||||
"optimize-orphan-references-dark-en": "6a9ab05787bc789af6181e6f2abca19d3cf92ac166aac661ad8deb7c7a3c88bb",
|
||||
"optimize-orphan-references-light-ru": "332e56edb151a5a820fc307d69ffd4060348978572e48704b0a2c14ba9802720",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
|
||||
"led-strip-design-reference-off-light": "1a558111dbeb1962b10f47baad20b4689248a347510261152f0d6196906044fd",
|
||||
"led-strip-design-reference-on-light": "1d6b4c2cfac2e1aeb9ce17ae2b3ddb62707e3f46c2ad3967b3669712a8e577fd",
|
||||
"lighting-led-strip-glow-dark": "e6cdec5a16fde53bd1eca255eff554a5923f710d2b6e24904e46e39d8e5ab4ab",
|
||||
"led-strip-long-zigzag-glow-light": "342749c29bac93a9a53f58921a40a10731fbcfdfff4345e71af81ce4de4dfa61",
|
||||
"led-strip-endcaps-zoom-light": "468293d57b96b4b17f61027467c7c43b25d5a0f91972ba0451998cdc61ea3980",
|
||||
"led-strip-rectangle-door-zoom-light": "00e8d937924bff957ef413f5d8b7c6964e44c96e90b9209ea24158217328b2ac",
|
||||
"led-strip-off-light": "cbb4f4794c871d592917653e8b5713d87a6be6dfe470b2161da760c2bb3cebdc",
|
||||
"iso-led-strip-dark": "6605a62b15a47da09920743793b5c7f8cb2768610b6d57b739fa91eeef883676"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 380 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 396 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 68 KiB |
@@ -43,7 +43,9 @@ const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
? makeLargeHouseFixture({ includeStairs: false })
|
||||
: makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle });
|
||||
: makeVisualMatrixFixture({
|
||||
applianceLifecycle: !!scenario.applianceLifecycle, ledStrips: !!scenario.ledStrips,
|
||||
});
|
||||
|
||||
const themeVars = {
|
||||
dark: {
|
||||
@@ -626,6 +628,17 @@ export function prepareGoldenFixture(scenario) {
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
return space;
|
||||
};
|
||||
if (scenario.ledStripOverride) {
|
||||
const space = requireSpace();
|
||||
if (space.id !== 'golden-led' || !Array.isArray(scenario.ledStripOverride.points)
|
||||
|| scenario.ledStripOverride.points.length < 2) {
|
||||
throw new Error(`invalid golden ledStripOverride: ${scenario.id}`);
|
||||
}
|
||||
space.led_strips = [{
|
||||
id: 'led-owner-repro', marker: 'demo-led-strip-loop',
|
||||
points: structuredClone(scenario.ledStripOverride.points),
|
||||
}];
|
||||
}
|
||||
if (scenario.stage3Fixture) {
|
||||
const contract = scenario.stage3Fixture;
|
||||
const space = requireSpace();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 70;
|
||||
export const GOLDEN_MATRIX_VERSION = 71;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -266,6 +266,8 @@ export const STAGE6_ACCEPTANCE_SCENARIOS = Object.freeze([
|
||||
},
|
||||
]);
|
||||
|
||||
const LED_STRIPS_OFF = Object.fromEntries(['light.demo_led_strip', 'light.demo_led_strip_wall', 'light.demo_led_strip_corner', 'light.demo_led_strip_loop'].map((id) => [id, { state: 'off' }]));
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
...STAGE6_ACCEPTANCE_SCENARIOS,
|
||||
// #663 AC3/AC10/AC12: both stair geometries, both rise directions and
|
||||
@@ -1314,4 +1316,41 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'card-editor-invalid-default-floor-dark-ru', fixture: 'visual',
|
||||
cardEditorInvalidDefaultFloor: 'removed-floor', language: 'ru', theme: 'dark',
|
||||
viewport: { width: 900, height: 760 }, ...page },
|
||||
// #780: LED strips. The two design references keep all four strips of the
|
||||
// designer's composition at once (free, wall face, corner polyline, closed
|
||||
// loop) on the reference floor #868D94; the others pin the dark Glow field,
|
||||
// the off stripe in the light theme and the raised 2.5D stripe.
|
||||
{ id: 'led-strip-design-reference-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
|
||||
viewport: { width: 1000, height: 760 }, ...stage },
|
||||
{ id: 'led-strip-design-reference-on-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 }, ...stage },
|
||||
{ id: 'lighting-led-strip-glow-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', fillMode: 'none', language: 'en', theme: 'dark', viewport: { width: 1000, height: 760 }, ...stage },
|
||||
{ id: 'led-strip-long-zigzag-glow-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
|
||||
// A free diagonal joins a wall-face run and returns to free floor. Before
|
||||
// #785 the active visibility clip kept only the wall fans and dropped both
|
||||
// free diagonals because SVG clip paths ignore open-path strokes.
|
||||
ledStripOverride: { points: [[0.12, 0.15], [0.551667, 0.35], [0.551667, 0.55], [0.16, 0.45]] }, ...stage },
|
||||
{ id: 'led-strip-endcaps-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
|
||||
// #786: at 4× the free end and the end inside one Glow radius of masonry
|
||||
// must keep the same smooth circular rim; the wall still clips its side.
|
||||
zoom: 4, zoomCenter: [500, 380],
|
||||
ledStripOverride: { points: [[0.43, 0.35], [0.535, 0.35]] }, ...stage },
|
||||
{ id: 'led-strip-rectangle-door-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
|
||||
// #787: four stored corners must remain four straight visible sides. The
|
||||
// right side crosses led-door: the opening is optically open, but neither
|
||||
// the stripe nor its emitter line may step back to the wall axis there.
|
||||
zoom: 1.15, zoomCenter: [310, 350],
|
||||
ledStripOverride: { points: [[0.068333, 0.088333], [0.551667, 0.088333],
|
||||
[0.551667, 0.611667], [0.068333, 0.611667], [0.068333, 0.088333]] }, ...stage },
|
||||
{ id: 'led-strip-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', fillMode: 'none', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
|
||||
viewport: { width: 1000, height: 760 }, ...stage },
|
||||
{ id: 'iso-led-strip-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
|
||||
mode: 'view', projection: 'iso', fillMode: 'none', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 760 }, ...stage },
|
||||
]);
|
||||
|
||||