Compare commits

...
Author SHA1 Message Date
Matysh d50efcb56e docs(release): refresh screenshot fingerprint for v1.80.0-beta.5
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
All eleven documentation frames are pixel-identical in the pinned Linux environment; update only their source fingerprint for the release candidate.

Issue: #792

Issue: #804

Issue: #776

User-Visible: no

Release: v1.80.0-beta.5
2026-10-06 17:30:47 +03:00
Matysh 25f463a933 Release v1.80.0-beta.5 candidate
Ship battery indicators, resilient room creation from partial wall faces, and
deterministic late smoke-error accounting. Rebuild the committed bundle and
tighten the beta ratchets.

Issue: #792
Issue: #804
Issue: #776
User-Visible: yes
Release: v1.80.0-beta.5
2026-10-06 17:22:40 +03:00
claude[bot] 63b6b48d03 docs(reviews): индекс после сдвига каталога (#776)
Issue: #776
User-Visible: no
2026-10-06 14:06:44 +00:00
claude[bot] 323fa70f96 docs: review document for #776
Issue: #776
User-Visible: no
2026-10-06 14:06:44 +00:00
Matyshandclaude[bot] 3e98ac4dde fix(test): include late page errors in smoke teardown verdict
Count delivered page errors after awaited browser close. Exercise the public
harness lifecycle with deterministic negative subprocess cases and retain the
unchanged Chromium probes for the real transport. Move the premature-snapshot
mutation witness to the deterministic Node suite.

Issue: #776
User-Visible: no
2026-10-06 14:06:44 +00:00
claude[bot] 3ee8663bfa docs: review document for #774
Issue: #774
User-Visible: no
2026-10-06 14:04:02 +00:00
claude[bot] 7015a2c454 docs: review document for #774
Issue: #774
User-Visible: no
2026-10-06 13:51:49 +00:00
claude[bot] aa62145621 docs(reviews): индекс после сдвига каталога (#792)
Issue: #792
User-Visible: no
2026-10-06 13:32:55 +00:00
claude[bot] 602305417b docs: review document for #792
Issue: #792
User-Visible: no
2026-10-06 13:32:51 +00:00
Matysh 97e6504e9c chore(golden): accept focused battery Zigbee overlap reference
Review the actual 420x140 frame from the complete attested WSL capture of
published 88bdd513e. The caption covers 70/70 sampled battery pixels;
coreChanged=0 and exposedRoute=49. All 203 existing scenes passed and their
PNG bytes remain untouched. Accept exactly the single declared new scene.

Issue: #792
User-Visible: no
Release: v1.80.0-beta.4
Baseline-Reviewed-Local: sha256:4918901746d50a9b8876c6683f9d4e71ce6782c2c02c680c836d0cd1af1b0765
2026-10-06 16:10:02 +03:00
Matysh 88bdd513ec test(golden): cover battery and Zigbee caption overlap
Address the in-scope Medium from CODE-REVIEW-792-r1 with a deterministic
golden scenario that uses actual topology rendering and overlapping battery
ink. Keep the existing battery boards and their reviewed images unchanged.
Align the English guide's setting name with the shipped locale (Low).

Issue: #792
User-Visible: no
2026-10-06 16:04:07 +03:00
claude[bot] b94112b8c4 docs: review document for #792
Issue: #792
User-Visible: no
2026-10-06 12:52:30 +00:00
Matysh 3c091e7ef8 fix(settings): preserve established Display option order
Append battery visibility after the existing tooltip, radar and volumetric
controls. The established 2.5D setting remains third. Extend the existing
browser oracle to allow exactly the new fourth option while preserving
its original first-three ordering requirement.

Full Validate 37461167422 exposed the ordering regression. It was reproduced
locally before the change; volumetric_setting, general_settings_form and
settings_dialog_cards all pass after it. Record the final gzip measurement.

Issue: #792
User-Visible: yes
2026-10-06 15:22:16 +03:00
Matysh 9951e6ba10 chore(golden): accept three MDI battery indicator scenes
Full 203-scene capture from published d4d48bb040
on pinned WSL/ext4 toolchain: 200 unchanged scenes and three new scenes;
105 environment witnesses passed (required floor: 10).
Reviewed light/dark boards and mobile framing against owner-approved MDI
glyphs and the designer's sizes, gaps and colors. Correct measured gzip
documentation by three bytes after the final source fingerprint settled.

Issue: #792
User-Visible: no
Release: v1.80.0-beta.4
Baseline-Reviewed-Local: sha256:7e238b1840401f791e00e53f5c77388006a0b04cc8d4e535775b3a5dc9f9db03
2026-10-06 15:04:00 +03:00
Matysh d4d48bb040 feat(devices): show own-device battery state with standard HA icons
Resolve one stable battery source independently of the functional device face,
include battery-only ticks in immutable render snapshots, and add a default-on
shared preference. Keep LED strips excluded and Zigbee captions above the
passive, out-of-flow indicator. Use the owner's accepted four MDI icons in the
designer's frame geometry and colours.

Include resolver/backend/browser/pixel contracts and three new golden scenes.
Recalibrate the owner-approved absolute/raw size budgets; preserve the existing
initial View rolling ceiling and band. Lazy extraction remains separate #805.

Issue: #792
User-Visible: yes
2026-10-06 14:43:19 +03:00
claude[bot] d787c02687 docs: review document for #792
Issue: #792
User-Visible: no
2026-10-06 10:56:30 +00:00
claude[bot] b9145d3e85 docs(reviews): индекс после сдвига каталога (#804)
Issue: #804
User-Visible: no
2026-10-06 10:14:27 +00:00
claude[bot] 6651d854d0 docs: review document for #804
Issue: #804
User-Visible: no
2026-10-06 10:14:23 +00:00
Matysh e8e5718ecd test(editor): verify room undo and canonical opening metadata
Issue: #804
User-Visible: no
2026-10-06 12:52:59 +03:00
Matysh b3f5aefa20 fix(editor): preserve residual wall identity when creating room
Issue: #804
User-Visible: yes
2026-10-06 12:52:59 +03:00
claude[bot] de2029b7f9 docs: review document for #804
Issue: #804
User-Visible: no
2026-10-06 09:47:47 +00:00
Matysh 370df0708c Release v1.80.0-beta.4 candidate after compatibility check
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Preserve room:false on pointer device tooltips. The prior candidate was not
published; task CI and WSL regression checks passed again after the repair.

Issue: #802
User-Visible: no
Release: v1.80.0-beta.4
2026-10-05 22:19:47 +03:00
claude[bot] bcc68f0da2 docs: accept derived artifacts on dev for v1.80.0-beta.4
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37362267763
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 19:19:24 +00:00
Matysh 574ae94840 Merge issue/802-zigbee-hover-labels into dev
Owner-authorized local review; task CI and WSL acceptance are green.

Issue: #802
User-Visible: no
2026-10-05 22:16:38 +03:00
Matysh 90e8ea4685 docs(review): record local acceptance of Zigbee caption layout
Issue: #802
User-Visible: no
2026-10-05 22:05:55 +03:00
Matysh ee351dcb84 test(zigbee): retain projection fixture promise until completion
Use bounded Playwright polling for the RAF-backed facade operation, following
the existing preloader smoke precedent. No config retries or relaxed assertions.

Issue: #802
User-Visible: no
2026-10-05 22:05:04 +03:00
Matysh 2b841d94e4 fix(zigbee): preserve the device tooltip discriminator
Full release CI caught room becoming undefined instead of false after the
pointer-tooltip path stopped calling _showTip. Preserve the existing shape;
keep and strengthen the room-tooltip-toggle regression assertion.

Issue: #802
User-Visible: no
2026-10-05 22:00:23 +03:00
Matysh c175a2ab1a Release v1.80.0-beta.4 candidate
Zigbee destination names and readable, non-overlapping hover diagnostics.
Local self-review authorized by the owner; WSL acceptance is green.

Issue: #802
User-Visible: yes
Release: v1.80.0-beta.4
2026-10-05 21:45:34 +03:00
claude[bot] 020739ebca docs: accept derived artifacts on dev for v1.80.0-beta.4
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37357972443
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 18:44:35 +00:00
Matysh ffdd69e277 Merge issue/802-zigbee-hover-labels into dev
Owner-authorized local review; task CI and WSL acceptance are green.

Issue: #802
User-Visible: no
2026-10-05 21:42:00 +03:00
Matysh 319ee70010 docs(review): record local acceptance of Zigbee caption layout
Issue: #802
User-Visible: no
2026-10-05 21:41:56 +03:00
Matysh 5c893c7ff3 docs(review): record local acceptance of Zigbee caption layout
Issue: #802
User-Visible: no
2026-10-05 21:39:44 +03:00
Matysh 077e9c1b1b test(zigbee): verify transformed cards and page scrolling
Issue: #802
User-Visible: no
2026-10-05 21:37:45 +03:00
Matysh 0471482c2e test(zigbee): isolate arrow raster probe from pointer tooltip
Keep outline pixel thresholds unchanged; real tooltip coexistence is tested separately.

Issue: #802
User-Visible: no
2026-10-05 21:34:27 +03:00
Matysh db887cd7f0 fix(zigbee): readable remote captions and collision-aware device tooltips
Keep diagnostic captions and the matching pointer tooltip readable, including
late overlay paint, camera/resize changes and narrow-card fallback. Preserve
focus, touch and device actions. Add pure placement and real-mouse witnesses.

Issue: #802
User-Visible: yes
2026-10-05 21:30:15 +03:00
Matysh 7feda5b6c8 Release v1.80.0-beta.3 candidate
Issue: #800
User-Visible: yes
Release: v1.80.0-beta.3
2026-10-05 20:24:52 +03:00
claude[bot] 285a6dc436 docs: accept derived artifacts on dev for v1.80.0-beta.3
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37347936626
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 17:24:35 +00:00
Matysh 52876b0573 Merge issue #800: background Zigbee scans (owner-approved local review) 2026-10-05 20:22:18 +03:00
Matysh cdfe02e64d docs(review): record independent green #800 r3 2026-10-05 20:21:55 +03:00
Matysh 07c885435a test(zigbee): settle capability replies before age fixture (#800)
Issue: #800
User-Visible: no
2026-10-05 20:18:08 +03:00
Matysh 67af859e8c fix(zigbee): allow explicit retry after transient reload failure (#800)
Issue: #800
User-Visible: yes
2026-10-05 20:12:25 +03:00
Matysh 4a7abf531b fix(zigbee): restore observers after integration reload (#800)
Issue: #800
User-Visible: yes
2026-10-05 20:08:37 +03:00
Matysh 379de706d2 feat(zigbee): keep route scans in background HA jobs (#800)
Issue: #800
User-Visible: yes
2026-10-05 19:57:28 +03:00
Matysh 1faed2af7d Release v1.80.0-beta.2 candidate
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Express beta approved by owner: targeted Zigbee units, typecheck/build and topology smoke; full exact-SHA Validate remains required.

Release: v1.80.0-beta.2
Issue: #799
User-Visible: yes
2026-10-05 16:38:45 +03:00
claude[bot] 343449e694 docs: accept derived artifacts on dev for v1.80.0-beta.2
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37317971759
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 13:37:18 +00:00
Matysh 9722153e01 docs(review): record independent local review for #799
Issue: #799
User-Visible: no
2026-10-05 16:34:50 +03:00
Matysh 0f77b921f6 fix(zigbee): allow slow route scans without extending transport stalls
Issue: #799
User-Visible: yes
2026-10-05 16:33:06 +03:00
Matysh e69b3f2009 Release v1.80.0-beta.1 candidate: record owner-approved ship review
The owner explicitly authorized manual ship review with an independent agent
after the external model failed before reading code. #767 is green, H/M/L 0.
Product, versions and generated bundle remain identical to 7341ecf9.
Full exact-SHA Validate is required again before publication.

Release: v1.80.0-beta.1
Issue: #767
User-Visible: no
2026-10-05 16:02:08 +03:00
Matysh 7341ecf9c5 Release v1.80.0-beta.1 candidate
Prepare the next beta from integrated dev: provider-confirmed Zigbee routes,
LED zoom optimization and on-core colour, plus test/process fixes.
Synchronize seven version fields, both changelogs, release notes, status,
generated bundles and measured ratchets. No new feature source in this commit.

Local checks: bundle:release PASS; 117 selected unit tests PASS;
smoke_zigbee_topology_hover (547 checks), smoke_led_strip_tube and
smoke_led_zoom_quality PASS; check-docs --screenshots=strict PASS;
release-contract v1.80.0-beta.1 PASS. Full exact-SHA CI required before tag.
Derived docs: run 37310388990, unchanged PNGs; fingerprint-only commit fd406acea.
Batch ship review for #767 remains required before publication.

Release: v1.80.0-beta.1
Issue: #767
Issue: #789
Issue: #790
Issue: #791
Issue: #793
Issue: #794
Issue: #795
Issue: #798
User-Visible: yes
2026-10-05 15:37:57 +03:00
claude[bot] fd406acea2 docs: accept derived artifacts on dev for v1.80.0-beta.1
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37310388990
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-05 12:35:58 +00:00
claude[bot] 33022ff946 docs(reviews): индекс после сдвига каталога (#798)
Issue: #798
User-Visible: no
2026-10-05 15:15:20 +03:00
Matysh 55ac10be3c chore(golden): accept provider-route hint and publish self-review (#798)
Reviewed the complete attested WSL capture on published 22828495b.
Only general-color-popover-desktop-en changes: the Zigbee hint describes
provider-confirmed routes rather than observed neighbors. Preserve the other
199 PNGs; 107 byte-identical witnesses, floor 10. No threshold/matrix changes.
Author self-review and dev merge are explicitly authorized by the owner.

Issue: #798
User-Visible: no
Release: v1.79.0
Baseline-Reviewed-Local: sha256:c0d5ed83d3bf2591a21fff465403ce4ab71c10df781a78736cf690a578a5df52
2026-10-05 15:15:19 +03:00
Matysh 22828495bb chore(zigbee): account for measured provider-route editor graph growth
Keep the 500-byte noise guard and 2000-byte band; measured 242382 B gzip
leaves 1018 B under the revised lazy-editor ceiling plus band.
Absolute initial View budget stays unchanged.

Issue: #798
User-Visible: no
2026-10-05 15:04:19 +03:00
Matysh 370c082f31 feat(zigbee): show provider-confirmed parents and coordinator routes
Replace inferred BFS topology with confirmed end-device parents and active
router routes from ZHA and Zigbee2MQTT. Fail closed on ambiguous evidence,
preserve stale snapshots, and request routing tables without hover traffic.
Use solid LQI-coloured arrows, plan-scaled unknown-LQI outlines, and named
unplaced targets. Add provider/runtime/visual regression coverage.

Owner authorized author self-review and merge to dev while the review model
is unavailable; no release or issue closure is included.

Issue: #798
User-Visible: yes
2026-10-05 14:57:23 +03:00
Matysh 0d64547643 docs(reviews): publish independent reviews for #794 and #795
Record the owner's manual-integration exception and regenerate the review
index for the combined dev candidate. Neither report claims a pipeline verdict.

Issue: #794
Issue: #795
User-Visible: no
2026-10-05 11:30:17 +03:00
Matysh 2ac221d28b Merge reviewed infra fixes #794 and #795 for owner-authorized integration
Preserve both independently reviewed code commits without rewriting history.
The owner explicitly approved manual integration after the review model failed.
The combined candidate must pass exact-SHA Validate before the dev push.

Issue: #794
Issue: #795
User-Visible: no
2026-10-05 11:28:49 +03:00
Matysh d19bac3966 test: prove static LED disconnect owns disposal
Issue: #794
User-Visible: no
2026-10-05 11:09:15 +03:00
Matysh aef02229c6 fix(ci): give nightly mutation shards headroom and report cancellation accurately
Split the full registry into ten shards without increasing the one-hour
limit. Preserve summary and step outcome separately so cancellation after
194/194 remains red without claiming the summary was missing.

Issue: #795
User-Visible: no
2026-10-05 11:04:29 +03:00
Matysh 865312e0b0 docs(reviews): record owner-authorized self-review for #793
Issue: #793
User-Visible: no
2026-10-05 09:39:44 +03:00
266 changed files with 15172 additions and 4588 deletions
+17 -18
View File
@@ -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 }} \
+7
View File
@@ -49,6 +49,10 @@ async def _async_flush_runtime(
writers are independently guarded so one failed store cannot prevent the
other from reaching disk during shutdown.
"""
zigbee = getattr(entry.runtime_data, "zigbee_coordinator", None)
if zigbee is not None:
await zigbee.async_close()
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
if recorder is not None:
try:
@@ -111,6 +115,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
_LOGGER.exception("House Plan: virtual-light storage is not readable; using default on")
entry.runtime_data = data
from .zigbee_topology import ZigbeeScanCoordinator
data.zigbee_coordinator = ZigbeeScanCoordinator(hass, entry)
from .radar import RadarCoordinator
data.radar_coordinator = RadarCoordinator(hass, data)
await data.radar_coordinator.async_setup()
+1 -1
View File
@@ -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"
VERSION = "1.80.0-beta.5"
DECOR_ASSETS_API_VERSION = 1
SUMMARY_PANEL_API_VERSION = 1
@@ -1,136 +1,141 @@
{
"schema": 1,
"fingerprint": "ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172",
"fingerprint": "32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 300133,
"initialViewGzipBytes": 302533,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301657,
"initialPanelGzipBytes": 304058,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2328,
"initialPanelOnlyGzipBytes": 2330,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/fr-BJJefRIG.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"houseplan-assets/iso-scene-render-CR7lxY-w.js",
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"houseplan-assets/live-interaction-runtime-C1983oH8.js",
"houseplan-assets/moon-runtime-Ca1mxhJl.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js",
"houseplan-assets/space-editor-EDOQT48H.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/de-Bks1kqD-.js",
"houseplan-assets/editor-C9cO7LMo.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/fr-Cyx-vulq.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"houseplan-assets/hp-zigbee-topology-overlay-GWA8--2F.js",
"houseplan-assets/iso-scene-render-B7_Yb29a.js",
"houseplan-assets/led-strip-editor-cVPwZHfh.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/led-strip-runtime-XyrltA_5.js",
"houseplan-assets/live-interaction-runtime-BPpAmdEl.js",
"houseplan-assets/live-tip-placement-C_m0HUe7.js",
"houseplan-assets/moon-runtime-3jMWVufG.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/pdf-export-k7dc6e6q.js",
"houseplan-assets/space-editor-D57grVhE.js",
"houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js",
"houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js"
],
"lazyGzipBytes": 478817,
"lazyGzipBytes": 486591,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/editor-C9cO7LMo.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js"
],
"lazyEditorGzipBytes": 240843,
"lazyEditorGzipBytes": 244539,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"lazyOnboardingGzipBytes": 28402,
"lazyOnboardingGzipBytes": 28721,
"lazyLocaleFiles": [
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/fr-BJJefRIG.js"
"houseplan-assets/de-Bks1kqD-.js",
"houseplan-assets/fr-Cyx-vulq.js"
],
"lazyLocaleGzipBytes": 53876,
"lazyLocaleGzipBytes": 53875,
"lazyIsometricFiles": [
"houseplan-assets/iso-scene-render-CR7lxY-w.js"
"houseplan-assets/iso-scene-render-B7_Yb29a.js"
],
"lazyIsometricGzipBytes": 12348,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js"
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js"
],
"lazyFurnitureArtGzipBytes": 16936,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js"
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/pdf-export-k7dc6e6q.js"
],
"lazyPdfGzipBytes": 126934,
"lazyMoonFiles": [
"houseplan-assets/moon-runtime-Ca1mxhJl.js"
"houseplan-assets/moon-runtime-3jMWVufG.js"
],
"lazyMoonGzipBytes": 11713,
"lazyMoonGzipBytes": 11714,
"lazyLedFiles": [
"houseplan-assets/led-strip-field-ObbckCkp.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js"
"houseplan-assets/led-strip-field-CIA1-5AA.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/led-strip-runtime-XyrltA_5.js"
],
"lazyLedGzipBytes": 10613,
"lazyLedGzipBytes": 10685,
"lazyLedEditorFiles": [
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/led-strip-editor-cVPwZHfh.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"lazyLedEditorGzipBytes": 10707,
"lazyLedEditorGzipBytes": 10712,
"lazyNamespaceLocaleFiles": [
"houseplan-assets/led-de-yp1GefDC.js",
"houseplan-assets/led-fr-CHODsRJm.js",
"houseplan-assets/led-ru-D2-9Yy6l.js",
"houseplan-assets/settings-de-ZP6F6dIF.js",
"houseplan-assets/settings-fr-doqd_TsB.js",
"houseplan-assets/settings-ru-CQ1W2gik.js",
"houseplan-assets/support-de-haZQ7q8i.js",
"houseplan-assets/support-fr-DnRyZ_Ga.js",
"houseplan-assets/support-ru-B7zzXsuL.js",
"houseplan-assets/tools-de-CYXPPfUH.js",
"houseplan-assets/tools-fr-DvygRB10.js",
"houseplan-assets/tools-ru-2xmu2w_8.js",
"houseplan-assets/topology-de-COda8n9n.js",
"houseplan-assets/topology-fr-BT9yhzay.js",
"houseplan-assets/topology-ru-C-d06yvD.js"
"houseplan-assets/led-de-p2Lf-Tk6.js",
"houseplan-assets/led-fr-CYUJP7Ri.js",
"houseplan-assets/led-ru-CACJZ6TJ.js",
"houseplan-assets/settings-de-p06UIc1i.js",
"houseplan-assets/settings-fr-CcDGizn4.js",
"houseplan-assets/settings-ru-DLUtY0xm.js",
"houseplan-assets/support-de-B2N_YYL8.js",
"houseplan-assets/support-fr-D6zFUxMl.js",
"houseplan-assets/support-ru-D6-IM08K.js",
"houseplan-assets/tools-de-l34ZmKd7.js",
"houseplan-assets/tools-fr-DfUkWkg4.js",
"houseplan-assets/tools-ru-C-24FEHa.js",
"houseplan-assets/topology-de-DsW3wYBw.js",
"houseplan-assets/topology-fr-pMOG69_N.js",
"houseplan-assets/topology-ru-5D8r7xvs.js"
],
"lazyNamespaceLocaleGzipBytes": 30082,
"lazyNamespaceLocaleGzipBytes": 31951,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DHF8oaNH.js",
"sha256": "7262c67e3f88f44ee6c6a0973073d1f7c3c9f177ff8254758a21c4e3867c5b06",
"rawBytes": 55329,
"gzipBytes": 18027,
"path": "houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"sha256": "31a9fe5cf5d17e26359207c3585deeefdc0ec01aa9bdb70679f070093d0c9082",
"rawBytes": 54564,
"gzipBytes": 17740,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": [
"houseplan-assets/settings-de-ZP6F6dIF.js",
"houseplan-assets/settings-fr-doqd_TsB.js",
"houseplan-assets/settings-ru-CQ1W2gik.js"
"houseplan-assets/settings-de-p06UIc1i.js",
"houseplan-assets/settings-fr-CcDGizn4.js",
"houseplan-assets/settings-ru-DLUtY0xm.js"
]
},
{
"path": "houseplan-assets/de-C4xCvmYr.js",
"sha256": "e3778d3139c05e075281f96460e15a6aaafe8dd9200306b0a3799a1bc5710828",
"path": "houseplan-assets/de-Bks1kqD-.js",
"sha256": "63bbdd6bd7bb7d4c2fd8576aae125dac5e4f9e0339decca8e5854475c1ef29cd",
"rawBytes": 89873,
"gzipBytes": 27169,
"isEntry": false,
@@ -138,19 +143,28 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CFo6BxSe.js",
"sha256": "81d90441a06989a01a09c19e8c73567b80cd4d6ba52e4ea4796e096e1e5b7aa4",
"path": "houseplan-assets/editor-C9cO7LMo.js",
"sha256": "57bd14e0dc4dd07911e802edee30dcf6ccd92f390888db10a11031b6566d20dd",
"rawBytes": 3833,
"gzipBytes": 1584,
"gzipBytes": 1586,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-B6HsmMPA.js",
"sha256": "d18d3eb8bf1dd9dda6d6fdeed84edc16d3945e8a7e23b1d88e5f2edcef0a0af6",
"path": "houseplan-assets/floating-surface-Cze1Izpe.js",
"sha256": "43281844d891390fa37657261a593dae0eebc03e486718401c4c1c4b3bd71546",
"rawBytes": 1099,
"gzipBytes": 579,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-CNHrqe_D.js",
"sha256": "dcaacdb8cdd47c74ed028b3566a4f1c8eb87e4593671881aa6fc64093941a292",
"rawBytes": 25605,
"gzipBytes": 5059,
"isEntry": false,
@@ -158,17 +172,17 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BJJefRIG.js",
"sha256": "4932ff13f6fdc339e9d67123105f9bd97f051395a3c3310240fc07b119d87024",
"path": "houseplan-assets/fr-Cyx-vulq.js",
"sha256": "1f091fc1d4144f348569d20cbe2467798399ff9d594fd0d9d96ef5a0cac3d4b5",
"rawBytes": 92266,
"gzipBytes": 26707,
"gzipBytes": 26706,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"sha256": "10a0b09e4b1513bd02ffba047d6931add1d1106c453af03f6b3c778b5bb55e15",
"path": "houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"sha256": "08c128df8bfd3faefe807e044343d63403eed6b494766a8c651102d85343b13d",
"rawBytes": 52035,
"gzipBytes": 16936,
"isEntry": false,
@@ -176,297 +190,311 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BOdiRDFR.js",
"sha256": "37c784960d53789a66988a4e4ab8a8afe88aeddb80c5f244f3d453ef86c3ba8c",
"rawBytes": 1056405,
"gzipBytes": 299329,
"path": "houseplan-assets/houseplan-card-DTtVq_VD.js",
"sha256": "95aa41b2fe6704f6f92864bbfcac0058a90cc16276dd626f46c592e2125f0749",
"rawBytes": 1063661,
"gzipBytes": 301728,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/fr-BJJefRIG.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"houseplan-assets/iso-scene-render-CR7lxY-w.js",
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"houseplan-assets/live-interaction-runtime-C1983oH8.js",
"houseplan-assets/moon-runtime-Ca1mxhJl.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js",
"houseplan-assets/space-editor-EDOQT48H.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js"
"houseplan-assets/de-Bks1kqD-.js",
"houseplan-assets/editor-C9cO7LMo.js",
"houseplan-assets/fr-Cyx-vulq.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"houseplan-assets/hp-zigbee-topology-overlay-GWA8--2F.js",
"houseplan-assets/iso-scene-render-B7_Yb29a.js",
"houseplan-assets/led-strip-editor-cVPwZHfh.js",
"houseplan-assets/led-strip-runtime-XyrltA_5.js",
"houseplan-assets/live-interaction-runtime-BPpAmdEl.js",
"houseplan-assets/moon-runtime-3jMWVufG.js",
"houseplan-assets/pdf-export-k7dc6e6q.js",
"houseplan-assets/space-editor-D57grVhE.js",
"houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"sha256": "772b538d5b87265a0e88117f01034a3ccadd5f4e928ced77cb99254ab74e299a",
"rawBytes": 695353,
"gzipBytes": 194530,
"path": "houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"sha256": "3924aa8128a3d2c43094d78b20ae93bf989267228d7c9b69574049facd3cce65",
"rawBytes": 700823,
"gzipBytes": 196271,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js"
],
"dynamicImports": [
"houseplan-assets/support-de-haZQ7q8i.js",
"houseplan-assets/support-fr-DnRyZ_Ga.js",
"houseplan-assets/support-ru-B7zzXsuL.js",
"houseplan-assets/tools-de-CYXPPfUH.js",
"houseplan-assets/tools-fr-DvygRB10.js",
"houseplan-assets/tools-ru-2xmu2w_8.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
"houseplan-assets/support-de-B2N_YYL8.js",
"houseplan-assets/support-fr-D6zFUxMl.js",
"houseplan-assets/support-ru-D6-IM08K.js",
"houseplan-assets/tools-de-l34ZmKd7.js",
"houseplan-assets/tools-fr-DfUkWkg4.js",
"houseplan-assets/tools-ru-C-24FEHa.js",
"houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"sha256": "242ae570916ec2a000a8a0079a948826faeb45c0968cc1006e9670f9d3c4088b",
"rawBytes": 15886,
"gzipBytes": 4907,
"path": "houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"sha256": "95fe9b90f66e33e5d76b34baa5a305a0ea858f9b65c3a9f1854ab1c6781c173e",
"rawBytes": 15930,
"gzipBytes": 4930,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"sha256": "8cb7c3f010cf7ef792a676bfd994c4498f31222dd12ded79e3e2f37dfae4f52e",
"rawBytes": 10958,
"gzipBytes": 3768,
"path": "houseplan-assets/hp-zigbee-topology-overlay-GWA8--2F.js",
"sha256": "8dddc2789d4d3c64feecc67c6057df047ba3ef7e7b9b29cc027e07cd4878f5f7",
"rawBytes": 16748,
"gzipBytes": 5535,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/live-tip-placement-C_m0HUe7.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js",
"houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-CR7lxY-w.js",
"sha256": "1986dd43c8b76580f893c1629d494f7747f02aa8d544baff20d56fe10b470bb5",
"rawBytes": 36700,
"path": "houseplan-assets/iso-scene-render-B7_Yb29a.js",
"sha256": "25781538401f3c5a869bf1209fbecb055753d16212d15b6c840f75b54a8a2523",
"rawBytes": 36701,
"gzipBytes": 12348,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-de-yp1GefDC.js",
"sha256": "eb9ce8bb536ac20e11cf4fe925d68f28b1ba8a5ea02c61abf9c3bb939de0a4c4",
"path": "houseplan-assets/led-de-p2Lf-Tk6.js",
"sha256": "dee1c52b983cdfb1b3abcbb0c5afdf37b76c71196a8beeb5f64b58fa12663a76",
"rawBytes": 1907,
"gzipBytes": 921,
"gzipBytes": 922,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-fr-CHODsRJm.js",
"sha256": "adb428bd63b6157ef0a5d749badf94e95204c9c3b83e5f3022accf65d304435e",
"path": "houseplan-assets/led-fr-CYUJP7Ri.js",
"sha256": "1d6b597d0a3870f0fd39edd5ab97ffbe061c00a788fb2ffd16b0369022a47dd4",
"rawBytes": 1886,
"gzipBytes": 906,
"gzipBytes": 907,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-ru-D2-9Yy6l.js",
"sha256": "bc5575139b30d6e93e07673f47865c332e71011796929a52043645a904d6df31",
"path": "houseplan-assets/led-ru-CACJZ6TJ.js",
"sha256": "949021fdbc9f4beb50386d6dc73a3fd9b815e027709b109a37b10fd1ca33b22f",
"rawBytes": 2525,
"gzipBytes": 1106,
"gzipBytes": 1107,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-editor-kcNyoue_.js",
"sha256": "dac9fdcaae6444caa1773a07384155cc92b916d2ebf3498e1dfc1b3525a84753",
"path": "houseplan-assets/led-strip-editor-cVPwZHfh.js",
"sha256": "f3fbd37e65394b6848c5313e60806af58890da2e44d184bd980ccc24c2730a55",
"rawBytes": 21704,
"gzipBytes": 7381,
"gzipBytes": 7383,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": [
"houseplan-assets/led-de-yp1GefDC.js",
"houseplan-assets/led-fr-CHODsRJm.js",
"houseplan-assets/led-ru-D2-9Yy6l.js"
"houseplan-assets/led-de-p2Lf-Tk6.js",
"houseplan-assets/led-fr-CYUJP7Ri.js",
"houseplan-assets/led-ru-CACJZ6TJ.js"
]
},
{
"path": "houseplan-assets/led-strip-field-ObbckCkp.js",
"sha256": "b85d8de01906a7e70f28baff534cf1a211216c99b72005b6d77a0d19cbb5ce8f",
"rawBytes": 7920,
"gzipBytes": 3321,
"path": "houseplan-assets/led-strip-field-CIA1-5AA.js",
"sha256": "32f9670f0c5ced199d42c4945b72d341d916fefb9a4567bb501c69b3a020d8e7",
"rawBytes": 8066,
"gzipBytes": 3381,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-geometry-DaLifxqY.js",
"sha256": "2dfbe1509da809008c9a525a7ed02226c026aaa5c0dafcb9f3dc1c44151ed63e",
"path": "houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"sha256": "668708a49b6c22a165aa02aed5b37d0ec26e0bd800b912c5c4a1b9bdc257bf58",
"rawBytes": 7055,
"gzipBytes": 2917,
"gzipBytes": 2916,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"sha256": "6fd2d3578e550dfbe8c38308923e21f9fddeb469f64e8fcf295dc292949ddf32",
"rawBytes": 11179,
"gzipBytes": 4375,
"path": "houseplan-assets/led-strip-runtime-XyrltA_5.js",
"sha256": "da89974ff9af4ecbd58ab09d01e739a2791034d0193a1a94cc5b4e27fad7cc5d",
"rawBytes": 11248,
"gzipBytes": 4388,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js"
],
"dynamicImports": [
"houseplan-assets/led-strip-field-ObbckCkp.js"
"houseplan-assets/led-strip-field-CIA1-5AA.js"
]
},
{
"path": "houseplan-assets/live-interaction-runtime-C1983oH8.js",
"sha256": "04be552ac07f99c03713f162ce0b6129c8491dfd89da9cd8e07cd71866fe309c",
"rawBytes": 8370,
"gzipBytes": 3281,
"path": "houseplan-assets/live-interaction-runtime-BPpAmdEl.js",
"sha256": "c3b063a58be71532e681d1a74b4db516679ccce8dd14cb2e2d3763c3a3f4df35",
"rawBytes": 9694,
"gzipBytes": 3782,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/live-tip-placement-C_m0HUe7.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-Ca1mxhJl.js",
"sha256": "97b9f55cb8e2915acb5eaa260f21e2103bc08f0e654c1c0b03c3000604319730",
"path": "houseplan-assets/live-tip-placement-C_m0HUe7.js",
"sha256": "ec891435f961d5d2b5aa3a7780bf29d7dd6eaa37e0781c3157bc6324b672d7e9",
"rawBytes": 1191,
"gzipBytes": 684,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-3jMWVufG.js",
"sha256": "88d30102ad1ba68b433e45586cb43ff7bfe27bcb2fd46039e6ef663617c47a4d",
"rawBytes": 30292,
"gzipBytes": 11713,
"gzipBytes": 11714,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/namespace-language-D86tJUeD.js",
"sha256": "012aa30cbf8bbbb2eff6b7ef05970ff90fb2b4b576910578e3527b75e68ffda6",
"path": "houseplan-assets/namespace-language-CPu79Qai.js",
"sha256": "c7b1e603fb5ad465a2fc022d9b6b764b447752816c79d03e58a3578433f458c8",
"rawBytes": 675,
"gzipBytes": 409,
"gzipBytes": 413,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-C-Y4BSVu.js",
"sha256": "fba700bc14c66c695f1b3de3e82abdf5fb6b14b70773c3d0f0e185dcf2989491",
"path": "houseplan-assets/pdf-export-k7dc6e6q.js",
"sha256": "6d0bcf018933f1ee2c119436c9c9383dc729e2652503663673b8cd430c1e5d71",
"rawBytes": 236714,
"gzipBytes": 109998,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-de-ZP6F6dIF.js",
"sha256": "12dd3028d2de2e4fc3bfcf2299e45e253075ca23b95235436ef1cf9156738a3e",
"rawBytes": 8886,
"gzipBytes": 3411,
"path": "houseplan-assets/settings-de-p06UIc1i.js",
"sha256": "3d1613488d11ed598043ac7ec6d6099d408f5b17a6c0a9d2c9ac5fbebc3396ee",
"rawBytes": 9039,
"gzipBytes": 3463,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-fr-doqd_TsB.js",
"sha256": "0c1f01b69c2b1704ddd29e99dd7609ad679655b5241b3e2b8921057198005ff3",
"rawBytes": 9297,
"gzipBytes": 3385,
"path": "houseplan-assets/settings-fr-CcDGizn4.js",
"sha256": "2ff305cd2808bcc9e9426fd2ca6969c59b247257ea542b53b47dd2e5cbf097a1",
"rawBytes": 9462,
"gzipBytes": 3439,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-ru-CQ1W2gik.js",
"sha256": "bad5c925aac84429d0831a1036ea3162ead8d4431db35ea577b0e806c8fbdbf1",
"rawBytes": 12284,
"gzipBytes": 3900,
"path": "houseplan-assets/settings-ru-DLUtY0xm.js",
"sha256": "1925b094a7e6af7667fb5388ed3a9afec65e65398f3d118f04a07a6b7d7937dc",
"rawBytes": 12482,
"gzipBytes": 3962,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-editor-EDOQT48H.js",
"sha256": "47339ab7329ffd1a0cbd21980c1cf2a2041d7a514ed5992e73a07abdce6fb764",
"path": "houseplan-assets/space-editor-D57grVhE.js",
"sha256": "fb5e2d934ff64b56a639ad52c16d2358715be36c560ef37d8ac4f43475e95834",
"rawBytes": 2619,
"gzipBytes": 1130,
"gzipBytes": 1131,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-BZnGwuVh.js",
"sha256": "516e3462d3896a9ca782ee0f2f3266a4da6e6b6664e92a619a23b8991e600795",
"rawBytes": 14862,
"gzipBytes": 3427,
"path": "houseplan-assets/summary-panel-editor-Cc7hoZrc.js",
"sha256": "c0c13b1be403b029f2442d5cb9e3db8fad77a05c6a93b96948aa59ba0aad2f38",
"rawBytes": 14867,
"gzipBytes": 3430,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js"
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-D_ZXPJxz.js",
"sha256": "3be26833b041416cef359e4c7086a0b0e6788680dda8f1583ae76d9256f944fa",
"path": "houseplan-assets/summary-panel-metrics-lOZWVec6.js",
"sha256": "37e5de39c2464734bd4a440d80311262ffb79b5733c14a07f784840b6f45d4a5",
"rawBytes": 2701,
"gzipBytes": 1348,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js",
"sha256": "7db94205b54996355041487465d6bade0530e5ac0d13a0eb9b7645086171ce58",
"path": "houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js",
"sha256": "1cb8d8fa330fe9987749fe16f265c98726b4bfd16771c0c7fc4c8ec484822aa8",
"rawBytes": 73476,
"gzipBytes": 20491,
"gzipBytes": 20488,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-BZnGwuVh.js",
"houseplan-assets/summary-panel-metrics-D_ZXPJxz.js"
"houseplan-assets/summary-panel-editor-Cc7hoZrc.js",
"houseplan-assets/summary-panel-metrics-lOZWVec6.js"
]
},
{
"path": "houseplan-assets/support-de-haZQ7q8i.js",
"sha256": "f377e9d552978c850f069eeb05b20c7572f81c7a8c7b2d60838a7907f1c2b3ef",
"path": "houseplan-assets/support-de-B2N_YYL8.js",
"sha256": "3f8c9e159b0d73b169ea547c9c57b9254fb9118d232f6d5e0ea7ad828e8115e3",
"rawBytes": 6167,
"gzipBytes": 2400,
"isEntry": false,
@@ -474,8 +502,8 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/support-fr-DnRyZ_Ga.js",
"sha256": "bb66f839f3b63a74212dc83d273db535dd4597e5f43c5c1b35f0e1fac401e424",
"path": "houseplan-assets/support-fr-D6zFUxMl.js",
"sha256": "76ec9da724ebb6bd8ae006d5169356d57f758a316941e3ba530ce988582e80b1",
"rawBytes": 6333,
"gzipBytes": 2433,
"isEntry": false,
@@ -483,8 +511,8 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/support-ru-B7zzXsuL.js",
"sha256": "63ec59059f3a10c994e4292c4c2b125bb7b2916326dbae27f9975c14be008822",
"path": "houseplan-assets/support-ru-D6-IM08K.js",
"sha256": "b6a2ffdecec86a2df3539d5e3b2ab4699d195a7bb9a717c310dd5d483f99fd0e",
"rawBytes": 8376,
"gzipBytes": 2878,
"isEntry": false,
@@ -492,26 +520,26 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-de-CYXPPfUH.js",
"sha256": "411c98cf876e42d7255882d0faa486602453f871d443b0c2b59c22e3b2552efb",
"path": "houseplan-assets/tools-de-l34ZmKd7.js",
"sha256": "236791eab4ea72a33d832e10c91c232f175f3c41aa894808c64ec9eb09359009",
"rawBytes": 4130,
"gzipBytes": 1378,
"gzipBytes": 1379,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-fr-DvygRB10.js",
"sha256": "f7d35402d5f3504baa8c00dc8bd5170487535c1b87f9a8367a28e332cda92bf2",
"path": "houseplan-assets/tools-fr-DfUkWkg4.js",
"sha256": "ebf7d82ffa772d33c569ddcb5bd16fa7f2899ffae465beeb0ed3a5bd63601a27",
"rawBytes": 4136,
"gzipBytes": 1360,
"gzipBytes": 1361,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-ru-2xmu2w_8.js",
"sha256": "14e6c4c22d615293903fc4f25d831f0af042b4e48234d4f2e114ef7d8817d57a",
"path": "houseplan-assets/tools-ru-C-24FEHa.js",
"sha256": "65959c38f358fbf048c68f033bbcf272dc546c717dcb3c94260299a6ac3d3399",
"rawBytes": 5633,
"gzipBytes": 1697,
"isEntry": false,
@@ -519,80 +547,80 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-de-COda8n9n.js",
"sha256": "88ff4a8cccc94e3eba040af628d9a85db664cbf20586f95704e0823a6215c1f9",
"rawBytes": 2698,
"gzipBytes": 1336,
"path": "houseplan-assets/topology-de-DsW3wYBw.js",
"sha256": "a9119255ef3894816f06e00403cc88b71cd67519f9a7f36b227fefc1e8428df9",
"rawBytes": 4237,
"gzipBytes": 1891,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-fr-BT9yhzay.js",
"sha256": "60cb68c5c9086ec5f4901627a6baa3e3c8cdfda430b0977622b260984122fcfa",
"rawBytes": 2718,
"gzipBytes": 1356,
"path": "houseplan-assets/topology-fr-pMOG69_N.js",
"sha256": "b5b8eb3ba26aa1e32888fd0f798523a8c912480df6ba77ff5980263fbad53804",
"rawBytes": 4321,
"gzipBytes": 1873,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-ru-C-d06yvD.js",
"sha256": "ca9a68f252b8275c954ae7ff7ea56d07a8ea5cf14f18eaac285fd0c63a7bad8f",
"rawBytes": 3669,
"gzipBytes": 1615,
"path": "houseplan-assets/topology-ru-5D8r7xvs.js",
"sha256": "d9a196027c5afd27c4986d18afd2e35853e013e4bf098c52176dbebb16ac9ae9",
"rawBytes": 5880,
"gzipBytes": 2239,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-BG_a6cIb.js",
"sha256": "98d8e54ec59e2d1810b124a0a0c6c2f528c112ab17d12bc3c0a129e13c456c72",
"rawBytes": 10958,
"gzipBytes": 4298,
"path": "houseplan-assets/zigbee-topology-FHYQyNDc.js",
"sha256": "411c7a13eb929ec2a613ef75c6561984e32067c295e1f80e5a2622a13c39ac46",
"rawBytes": 15905,
"gzipBytes": 5955,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": [
"houseplan-assets/topology-de-COda8n9n.js",
"houseplan-assets/topology-fr-BT9yhzay.js",
"houseplan-assets/topology-ru-C-d06yvD.js"
"houseplan-assets/topology-de-DsW3wYBw.js",
"houseplan-assets/topology-fr-pMOG69_N.js",
"houseplan-assets/topology-ru-5D8r7xvs.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js",
"sha256": "966ef9fa87888f0ed561d1061b36f0192c27700f040305ae038fe9509c7a7e33",
"rawBytes": 3983,
"gzipBytes": 1789,
"path": "houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js",
"sha256": "0e6ac1b8d5676ef64b062dfee29feb3010a83c89c410f4b4d707d8a9f6d0f33a",
"rawBytes": 7883,
"gzipBytes": 2880,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "80338f4a64b84431f95438482aeabe1cc6b0f399ccb0c600b318c0f6e6cccc38",
"sha256": "979e98a40327df3a4043bb501c8829a25d66e0c5ca98358e0ee63c75b39ef8b0",
"rawBytes": 1205,
"gzipBytes": 804,
"gzipBytes": 805,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "ce8c313af85073d1c54ccc74e2111c1f14c5d8e3e8539bc59ec436d6d3e0d574",
"sha256": "b3797a5767db43a78bf91ecba3e5369fb257691624731f8ea310239ec7aad6f2",
"rawBytes": 6165,
"gzipBytes": 2328,
"gzipBytes": 2330,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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-BOdiRDFR.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")},_=this._schema,f=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),u=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";import{aI as e,l as o,aL as t,eI as s,A as a,eJ as i,eK as l,eL as n,E as r,c}from"./houseplan-card-DTtVq_VD.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
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${u(_.slice(0,3))}
${f?c`<div class="default-floor-error" role="alert"
${d(f.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:f})}
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${u(_.slice(3))}
${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);
@@ -0,0 +1 @@
function t(t){const h=t.visualViewport;return h&&Number.isFinite(h.width)&&Number.isFinite(h.height)?{left:Number(h.offsetLeft)||0,top:Number(h.offsetTop)||0,width:Math.max(0,Number(h.width)||0),height:Math.max(0,Number(h.height)||0)}:{left:0,top:0,width:t.innerWidth,height:t.innerHeight}}function h(t,h,a,e=7,i=8){const o=a.left+i,n=a.top+i,m=Math.max(o,a.left+a.width-i),r=Math.max(n,a.top+a.height-i),M=Math.max(0,m-o),f=Math.max(0,r-n),u=Math.min(Math.max(0,h.width),M),d=Math.min(Math.max(0,h.height),f);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 c="bottom",b=x;return x+d>r&&s>=n?(c="top",b=Math.min(Math.max(n,s),Math.max(n,r-d))):b=Math.min(Math.max(n,x),Math.max(n,r-d)),{left:Math.round(p),top:Math.round(b),side:c,maxWidth:Math.round(M),maxHeight:Math.round(f)}}function a(t){const h=t.HTMLElement?.prototype;return"function"==typeof h?.showPopover&&"function"==typeof h?.hidePopover}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{a,t as f,h as p};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,n as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";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="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{e as dictionary,n as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,a as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";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="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{e as dictionary,a as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const l="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,l as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";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="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{e as dictionary,d as fingerprint};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{eu as e,eC as t,c6 as o,eJ as s,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-BOdiRDFR.js";import{L as g,v as w,s as $,a as _,b as y,p as k,c as b,d as C,e as P}from"./led-strip-geometry-DaLifxqY.js";const M="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-ObbckCkp.js"):import(new URL(`./led-strip-field-ObbckCkp.js?${Date.now()}`,import.meta.url).href)).then(e=>{e.LED_FIELD_FINGERPRINT===s?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(s,i){if(!s)return null;const n=Array.isArray(s.masonryGeometry)?s.masonryGeometry:[],a=[...e(s.masonryGeometry),...s.opaqueBodies],r=[];for(const e of a)for(let t=0;t<e.length;t++){const o=e[t],s=e[(t+1)%e.length];o&&s&&r.push({a:[o[0],o[1]],b:[s[0],s[1]]})}const l=25,d=new Map;r.forEach((e,t)=>{const o=Math.floor(Math.min(e.a[0],e.b[0])/l),s=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=o;e<=s;e++)for(let o=i;o<=n;o++){const s=`${e},${o}`,i=d.get(s);i?i.push(t):d.set(s,[t])}});const c=e=>{let t=1/0,o=1/0,s=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),o=Math.min(o,n[1]),s=Math.max(s,n[0]),i=Math.max(i,n[1]);return[t,o,s,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=s.opaqueBodies.map(e=>({box:c(e),body:e}));return{faces:r,inside:e=>u.some(({box:o,poly:s})=>p(e,o)&&t([e[0],e[1]],[s]))||h.some(({box:t,body:s})=>p(e,t)&&o([e[0],e[1]],s)),epsilon:i,near:e=>{const t=new Set;for(let o=Math.floor(e[0]/l);o<=Math.floor(e[2]/l);o++)for(let s=Math.floor(e[1]/l);s<=Math.floor(e[3]/l);s++)for(const e of d.get(`${o},${s}`)||[])t.add(e);return[...t].map(e=>r[e])}}}function I(e){const t=e.candidate?.sourceEid||e.device.primary||"",o=t?e.hass?.states?.[t]?.state:void 0,s="unavailable"===o||"unknown"===o||!!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=s?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:s?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const T=new WeakMap;function j(e,t,o){const s=P*o;const i=T.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===s&&i.points.length===e.points.length&&i.points.every((t,o)=>t[0]===e.points[o][0]&&t[1]===e.points[o][1]))return i.value;const n=R(e.points),a=y(n,t,s/2),r={t:s,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,o=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),s=e.iso;return d`<g class="led-strips" data-hp-led-strips="${o.length}"
data-hp-iso=${s?"raised":m}>
${s?d`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
x="-100000" y="-100000" width="200000" height="200000">
<feGaussianBlur stdDeviation="${s.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(o,e=>e.strip.id,o=>{const{t:i,path:n,d:a}=j(o.strip,e.faces,e.d),r="unavailable"===o.state,l=r?"#9e9e9e":"on"===o.state&&!o.glow&&o.appearance?o.appearance.c:"#FFFFFF",c=2*b(i*e.perUnit)/(e.perUnit||1),p=o.device,u=t=>function(e,t,o){const s=e.currentTarget,i=s?.getScreenCTM?.();if(!i||o.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=o.views.map(e=>{const{t:t,path:s}=j(e.strip,o.faces,o.d);return{id:e.strip.id,points:s.points.map(e=>[e[0]*o.perUnit,e[1]*o.perUnit]),closed:s.closed,thicknessPx:t*o.perUnit}}),l=C([a[0]*o.perUnit,a[1]*o.perUnit],r);return o.views.find(e=>e.strip.id===l)||t}(t,o,e).device,h=s?`translate(0 ${-s.lift})`:m;return d`<g class="led-strip state-${o.state}" data-led-strip="${o.strip.id}"
data-marker="${p.id}" data-state="${o.state}" data-closed="${n.closed?"true":"false"}">
${s?d`<path class="led-iso-shadow" d="${a}" fill="none" stroke="black"
stroke-opacity="${s.shadow.opacity}" stroke-width="${i}" stroke-linecap="round"
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
transform="translate(${s.shadow.dx} ${s.shadow.dy-s.lift})"></path>
${[1,.5].map(e=>d`<path class="led-iso-edge" d="${a}" fill="none" stroke="${s.edge}"
stroke-width="${i}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
transform="translate(0 ${-s.lift+s.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,o,s){return(t?e.find(e=>e.id===t):void 0)??(o?e.find(e=>s(o,e)):void 0)}function N(e){const t=new Map(e.devices.map(e=>[e.id,e])),o=g/e.cellCm*e.gridPitch,s=new Map(i({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:o,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),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:s.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!d&&e.glowFor(d),defaultRadius:o,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const l=_/e.cellCm*e.gridPitch;return{views:r,faces:A(e.scene,l),d:e.iconPct/100*a(e.space),scene:e.scene,polygons:e.polygons}}const q=new WeakMap;function B(e){for(const t of q.get(e)?.frame.views??[])T.delete(t.strip);q.delete(e),L?.releaseLedField(e)}function S(e){return{shapes:q.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,visibilityPaths:0,pathChars:0,recomputes:0}}}function O(e,t,o){const s=t.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),i=s.length?e._lightBarriers(t,s.map(({room:e,poly:t})=>({r:e,poly:t}))):null,n=[t,e._renderDevices,e._renderPlanHass,o,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:s,glowFor:e=>l(o,e),inRoom:(t,o)=>e._pointInRoom(t,o),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&q.set(e,{key:n,frame:c}),c}function W(e,t,o,s){const i=O(e,t,o),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&&s.w?n/s.w:1,handlers:"view"===e._mode?{click:(t,o)=>e._clickDevice(t,o),keydown:(t,o)=>e._keyDevice(t,o),contextmenu:(t,o)=>e._ctxDevice(t,o),pointerdown:(t,o)=>e._pointerDown(t,o),pointermove:(t,o)=>{const s=e._pointerMove(t,o);s&&e._showDeviceTip(t,s)},pointerup:(t,o)=>e._pointerUp(t,o),pointercancel:(t,o)=>e._pointerCancel(t,o),pointerover:(t,o)=>e._showDeviceTip(t,o),pointerleave:()=>e._clearPointerHover(),focus:(t,o)=>e._showDeviceFocusTip(t,o),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,o){if(!1===e.isConnected)return d``;const s=O(e,t,o),i=s.scene&&(D(s.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return i?i.renderLedField({views:s.views,scene:s.scene,polygons:s.polygons,faces:s.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}]:[]}),o=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}),s=e.live?o.views:o.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),i=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},_/e.cellCm*e.gridPitch),n=e.scene&&e.live&&(D(s)||L?.hasLedField(e.owner))?x(e.space.id,e.ready):null;return d`${n?n.renderLedField({views:s,scene:e.scene,polygons:t,faces:i,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:()=>!0}):m}${G({views:s,d:o.d,faces:i,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,N as ledFrame,O as ledFrameFor,S as ledStats,I as ledStripView,j as ledStripePath,B as releaseLed,z as renderLedFieldFor,W as renderLedLayerFor,G as renderLedStripes,Y as renderStaticLed,H as stripRoom};
@@ -0,0 +1,47 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";import{ey as e,eG as t,cb as s,eN as o,eO as i,af as n,ek as a,Q as r,eP as l,aB as d,el as c,aP as p,eQ as u,eR as h,eS as f,A as m,eT as v}from"./houseplan-card-DTtVq_VD.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-C0tm4EW4.js";const F="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";let M,L=null,U=null;function x(e,t){return L||U||M===e||""===M||(U=(void 0===M?import("./led-strip-field-CIA1-5AA.js"):import(new URL(`./led-strip-field-CIA1-5AA.js?${Date.now()}`,import.meta.url).href)).then(e=>{e.LED_FIELD_FINGERPRINT===o?L=e:M=""},()=>{M=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 T(e){const t=e.candidate?.sourceEid||e.device.primary||"",s=t?e.hass?.states?.[t]?.state:void 0,o="unavailable"===s||"unknown"===s||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=o?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:o?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const I=new WeakMap;function j(e,t,s){const o=P*s;const i=I.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===o&&i.points.length===e.points.length&&i.points.every((t,s)=>t[0]===e.points[s][0]&&t[1]===e.points[s][1]))return i.value;const n=R(e.points),a=_(n,t,o/2),r={t:o,path:a,d:k(a)};return I.set(e,{faces:t,epsilon:t?.epsilon,points:n,value:r}),r}function 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 B(e,t,s,o){return(t?e.find(e=>e.id===t):void 0)??(s?e.find(e=>o(s,e)):void 0)}function H(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=B(e.space.rooms,l.marker?.room_id,$(R(a.points)),e.inRoom);r.push(T({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 N=new WeakMap;function S(e){for(const t of N.get(e)?.frame.views??[])I.delete(t.strip);N.delete(e),L?.releaseLedField(e)}function q(e){return{shapes:N.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=N.get(e);if(a&&a.key.every((e,t)=>e===n[t]))return a.frame;for(const e of a?.frame.views??[])I.delete(e.strip);const d=e._config?.icon_size??2.5,c=H({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&&N.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 Y(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 z(e){const t=e.space.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),s=H({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{F as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,H as ledFrame,O as ledFrameFor,q as ledStats,T as ledStripView,j as ledStripePath,S as releaseLed,Y as renderLedFieldFor,W as renderLedLayerFor,G as renderLedStripes,z as renderStaticLed,B as stripRoom};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";const t=t=>[t.left,t.top,t.right,t.bottom,t.width,t.height].every(Number.isFinite)&&t.right>=t.left&&t.bottom>=t.top&&t.width>=0&&t.height>=0;function o(o){const{preferred:e,size:i,bounds:h,blockers:r,gap:n=8,edge:f=8}=o;if(![e.x,e.y,i.width,i.height,n,f].every(Number.isFinite)||i.width<=0||i.height<=0||n<0||f<0||!t(h)||r.some(o=>!t(o)))return null;const s=h.left+f,d=h.right-f-i.width,l=h.top+f,b=h.bottom-f-i.height;if(![s,d,l,b].every(Number.isFinite)||d<s||b<l)return null;const c=r.filter(t=>t.right>t.left&&t.bottom>t.top).map(t=>({left:t.left-n-i.width,right:t.right+n,top:t.top-n-i.height,bottom:t.bottom+n})),g=(t,o,e)=>Math.max(o,Math.min(e,t)),m=new Set([s,d,g(e.x,s,d)]),a=new Set([l,b,g(e.y,l,b)]);for(const t of c)m.add(g(t.left,s,d)),m.add(g(t.right,s,d)),a.add(g(t.top,l,b)),a.add(g(t.bottom,l,b));const p=[...m].sort((t,o)=>t-o),u=[...a].sort((t,o)=>t-o);let w=null,y=1/0;for(const t of u)for(const o of p){if(c.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};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";import{eV as e,eW as n,eK as a}from"./houseplan-card-DTtVq_VD.js";function c(a,c){return new e([{code:"en",dictionary:a},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750",console.warn,n)}function o(e){return function(e,n){return{state:a=>{const c=e.state(a);return"pending"!==c&&n.some(e=>"pending"===e.state(a))?"pending":c},dictionary:n=>e.dictionary(n),ensure:a=>Promise.all([e,...n].map(e=>e.ensure(a))).then(()=>{})}}(a,e)}export{c as n,o as s};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{eR as e,eS as a,eG as n}from"./houseplan-card-BOdiRDFR.js";function o(n,o){return new e([{code:"en",dictionary:n},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172",console.warn,a)}function r(e){return function(e,a){return{state:n=>{const o=e.state(n);return"pending"!==o&&a.some(e=>"pending"===e.state(n))?"pending":o},dictionary:a=>e.dictionary(a),ensure:n=>Promise.all([e,...a].map(e=>e.ensure(n))).then(()=>{})}}(n,e)}export{o as n,r as s};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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-BOdiRDFR.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
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";import{aI as e,l as t,aL as s,A as a,eJ as o,eK as i,eL as n,E as l,eU as c,c as r}from"./houseplan-card-DTtVq_VD.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return l;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
.hass=${this.hass}
.data=${{...this._config,fit:c(this._config.fit)}}
.schema=${this._schema}
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{c as a,A as e}from"./houseplan-card-BOdiRDFR.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-CcLP5B5v.js";import"./form-kit.styles-B6HsmMPA.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__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";import{c as a,A as s}from"./houseplan-card-DTtVq_VD.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-CsQP3jRJ.js";import"./form-kit.styles-CNHrqe_D.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__="ab07da35681c15ab6136c323500b69766580
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__="ab07da35681c15ab6136c323500b69766580
<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__="ab07da35681c15ab6136c323500b69766580
?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__="ab07da35681c15ab6136c323500b69766580
@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__="ab07da35681c15ab6136c323500b69766580
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__="ab07da35681c15ab6136c323500b69766580
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__="ab07da35681c15ab6136c323500b69766580
<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__="ab07da35681c15ab6136c323500b69766580
${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__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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 u,b6 as d,f1 as f,f2 as m,ch as g,f3 as v}from"./houseplan-card-BOdiRDFR.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=d(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,u="device"===r?a?.area_id:l?.area_id||c&&e.registry.devices?.[c]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;c&&e.registry.devices?.[c]&&e.spaceIds.has(f)&&(!n.devices.has(c)||"entity"===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:u(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(c,u,d=h){try{let f=0;for(const m of u){const u=c.spaces.find(e=>String(e?.id)===m.id);if(!u)continue;const g=e(u,m),v=d(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};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";import{f3 as e,Q as t,ba as n,O as i,ah as o,af as r,ck as s,f4 as a,cj as c,b9 as l,b8 as d,bb as u,f5 as f,f6 as m,cm as g,f7 as v}from"./houseplan-card-DTtVq_VD.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,r as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";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="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{e as dictionary,r as fingerprint};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,r as fingerprint};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";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="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{e as dictionary,r as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,n as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";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="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{e as dictionary,n as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,s as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";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="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{e as dictionary,n as fingerprint};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";export{e as dictionary,a as fingerprint};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";import{n as t,c as n}from"./zigbee-topology-BG_a6cIb.js";import{aP as e}from"./houseplan-card-BOdiRDFR.js";import"./namespace-language-D86tJUeD.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};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";try{await import("./houseplan-assets/houseplan-card-BOdiRDFR.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__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";try{await import("./houseplan-assets/houseplan-card-DTtVq_VD.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -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"
"version": "1.80.0-beta.5"
}
+2
View File
@@ -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]
@@ -156,6 +156,9 @@ def _global_settings(value: object) -> dict[str, Any]:
show_room_tooltip = value.get("show_room_tooltip")
if isinstance(show_room_tooltip, bool):
out["show_room_tooltip"] = show_room_tooltip
show_device_battery = value.get("show_device_battery")
if isinstance(show_device_battery, bool):
out["show_device_battery"] = show_device_battery
volumetric_view = value.get("volumetric_view")
if isinstance(volumetric_view, bool):
out["volumetric_view"] = volumetric_view
@@ -2321,6 +2321,7 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("sun_rays"): bool,
vol.Optional("sun_ray_origin"): _SUN_RAY_ORIGIN,
vol.Optional("show_room_tooltip"): bool,
vol.Optional("show_device_battery"): bool,
# #649: installation-wide 2.5D View; absent/false keeps Flat.
vol.Optional("volumetric_view"): bool,
# #661: the moon on the "Follow the Sun" background; absent/false = off.
@@ -258,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)
@@ -288,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:
@@ -1481,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 {}),
},
)
@@ -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)
+10 -3
View File
@@ -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)) {
+17 -13
View File
@@ -1,8 +1,8 @@
{
"schema": 3,
"matrixVersion": 71,
"acceptedAt": "2026-10-03T21:10:10.375Z",
"sourceFingerprint": "660dd7fd37337d645e3b1d038e725b1f2cae1c2ac628726925ff15d1744aa88f",
"matrixVersion": 73,
"acceptedAt": "2026-10-06T13:09:50.436Z",
"sourceFingerprint": "681414fd342c2e3b2578853adf756d20555d81b168370ad98afe9699a216b3f6",
"chromium": "151.0.7922.34",
"capturedOn": "linux",
"acceptedOn": "linux",
@@ -10,20 +10,20 @@
"platform": "linux",
"arch": "x64",
"chromium": "151.0.7922.34",
"buildFingerprint": "660dd7fd37337d645e3b1d038e725b1f2cae1c2ac628726925ff15d1744aa88f",
"buildFingerprint": "681414fd342c2e3b2578853adf756d20555d81b168370ad98afe9699a216b3f6",
"ci": null
},
"localAttestation": {
"schema": "houseplan-golden-wsl/v1",
"sha256": "aaf9ebcb3613071dc8048506f68a889dab6bdea8e62ba5ce677b12d611e9eae7",
"artifactSha256": "6f22f397e53e147b8c7cf41ec1b0063eb966fbb57ef3d0a10289728cb7af1f9f",
"createdAt": "2026-10-03T21:03:28.942Z",
"sha256": "4918901746d50a9b8876c6683f9d4e71ce6782c2c02c680c836d0cd1af1b0765",
"artifactSha256": "1525ac9e3608a57025210e028be6c66fa47abea89b0861ddce30b22bdb378f57",
"createdAt": "2026-10-06T13:09:28.786Z",
"source": {
"repository": "Matysh/houseplan-card",
"branch": "issue/790-led-core-source-color",
"commit": "181b61ae8e6adc967dde8c2a87799152ec777956",
"tree": "ec94fddbfd9acbda68ceef4c158b283f7506062a",
"remoteSha": "181b61ae8e6adc967dde8c2a87799152ec777956",
"branch": "issue/792-device-battery-indicator",
"commit": "88bdd513ec53f1abc82f1f3f63765ba4207373ae",
"tree": "a0814d37f559ea6dff3227ebe55f37266d40360d",
"remoteSha": "88bdd513ec53f1abc82f1f3f63765ba4207373ae",
"clean": true,
"status": ""
},
@@ -44,7 +44,7 @@
},
"foreignCapture": null,
"witnesses": {
"count": 101,
"count": 108,
"floor": 10
},
"scenarios": {
@@ -163,6 +163,10 @@
"openings-hidden-view-dark": "209a6a52993a6840d85126f59f408fac68dcb9b7117e489161de96d63c834fae",
"lighting-glow-sun-dark": "9021fa10f6606d2307f75f8024473be3e2d6e94d1c48ab149551c2bbdf129578",
"device-value-badge-positions-dark": "f4ae3548d65e8fd0377b7c2b446065d922473c40f85537a8ce99d3219978223c",
"device-battery-board-light": "6400764fe7531ddc1f47aa37bd7ce63163e17a0a61c6ac0fe2caaad369239ab5",
"device-battery-board-dark": "b9d3f258eb1adfa5c91ca68de55c6b0905b0fd08aef74190a65e283d2c424fba",
"device-battery-mobile-dark": "84d039a26cc90a9d2c5ce654a4b0900903ff69a657d3b70535e475956e9e57a8",
"device-battery-zigbee-overlap-dark": "72c11da5add5e5d378c3e29257c88a306644367be4172844d565f99578cea6ac",
"device-value-face-cover-source-dark": "18aa90881fe7794c98c52142b73fb1db33bf57d390c24b6286b7b7db6312bcfa",
"device-icon-state-table-light": "0e426d536bd89354d51bd90ef62c08bdf8d36f27132d5f1f22b64c79850266df",
"device-icon-state-table-dark": "c993ee850b38af1325f1290d45e516a19c4bc6c3e4db864cdc1cc61276ce6d18",
@@ -220,7 +224,7 @@
"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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

+390
View File
@@ -0,0 +1,390 @@
/** #792: synthetic, data-driven reference boards; no renderer/CSS substitution. */
const STATES = ['normal', 'warning', 'low', 'unknown'];
const VALUES = { normal: '80', warning: '40', low: '10', unknown: 'unavailable' };
const COLORS = { normal: [29, 194, 29], warning: [240, 160, 12], low: [240, 65, 12], unknown: [112, 119, 129] };
const SPACE = 'golden-battery';
// Independent visual oracle for the owner's four-state MDI choice.
export const BATTERY_BOARD_ICONS = Object.freeze({
normal: 'mdi:battery', warning: 'mdi:battery-30',
low: 'mdi:battery-outline', unknown: 'mdi:battery-unknown',
});
export function batteryBoardSamples(kind) {
if (!['desktop', 'mobile'].includes(kind)) throw new Error(`unknown battery board: ${kind}`);
if (kind === 'mobile') return [
['normal', 'none'], ['warning', 'right'], ['low', 'left'], ['unknown', 'top'],
['normal', 'bottom'], ['warning', 'text'], ['low', 'binary'], ['unknown', 'binary'],
].map(([state, arrangement], index) => ({
id: `battery-mobile-${index}`, state, arrangement, diameter: index < 6 ? 32 : 56,
x: index % 2 ? 0.66 : 0.22, y: 0.16 + Math.floor(index / 2) * 0.22,
}));
return [
...[32, 56, 96].flatMap((diameter, row) => STATES.map((state, column) => ({
id: `battery-${diameter}-${state}`, state, diameter, arrangement: 'none',
x: 0.14 + column * 0.23, y: 0.12 + row * 0.19,
}))),
...['none', 'right', 'left', 'top', 'bottom'].map((arrangement, index) => ({
id: `battery-layout-${arrangement}`, state: STATES[index % 4], diameter: 56, arrangement,
// Opposite horizontal capsules extend toward each other. Leave room for
// both complete shells plus the first capsule's independent battery.
x: [0.08, 0.22, 0.56, 0.73, 0.91][index], y: 0.70,
})),
...['text', 'legacy', 'binary', 'binary'].map((arrangement, index) => ({
id: `battery-extra-${index}`, state: STATES[index], diameter: 56, arrangement,
x: 0.13 + index * 0.23, y: 0.88,
})),
];
}
export function makeBatteryBoardFixture(kind) {
const fixture = {
config: {
model_version: 7,
spaces: [{ id: SPACE, title: 'Battery reference', plan_url: null, view_box: [0, 0, 1, 1],
cell_cm: 5, rooms: [{ id: 'battery-room', name: '', area: 'battery_area',
poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }],
walls: [], wall_segments: [], partitions: [], openings: [], wall_columns: [], decor: [],
settings: { fill_mode: 'none', show_names: false, show_borders: false, glow_enabled: false },
}],
markers: [], settings: { bg_mode: 'static', sun_rays: false, marker_area_snapshot: {} },
},
devices: {}, entities: {}, states: {}, layout: {},
areas: { battery_area: { area_id: 'battery_area', name: 'Battery reference' } },
};
const addEntity = (id, entityId, state, attributes, diagnostic = false) => {
fixture.entities[entityId] = { entity_id: entityId, device_id: id, platform: 'houseplan_golden',
config_entry_id: 'golden_entry', disabled_by: null,
original_device_class: attributes.device_class,
...(diagnostic ? { entity_category: 'diagnostic' } : {}),
};
fixture.states[entityId] = { entity_id: entityId, state, attributes };
};
for (const sample of batteryBoardSamples(kind)) {
const { id, diameter, arrangement, state } = sample;
const stem = id.replaceAll('-', '_');
const valueId = `sensor.${stem}_measurement`;
const batteryId = `${arrangement === 'binary' ? 'binary_sensor' : 'sensor'}.${stem}_battery`;
fixture.devices[id] = { id, name: id, model: 'GOLDEN-BATTERY', area_id: 'battery_area',
identifiers: [['houseplan_golden', id]], config_entries: ['golden_entry'], disabled_by: null,
};
addEntity(id, valueId, arrangement === 'text' ? '498' : '21.5', {
device_class: arrangement === 'legacy' ? 'humidity' : 'temperature',
unit_of_measurement: arrangement === 'text' ? 'ppm' : arrangement === 'legacy' ? '%' : '°C',
});
addEntity(id, batteryId, arrangement === 'binary'
? state === 'low' ? 'on' : state === 'normal' ? 'off' : 'unavailable'
: VALUES[state], { device_class: 'battery', ...(arrangement === 'binary' ? {} : { unit_of_measurement: '%' }) }, true);
const marker = { id, binding: `device:${id}`, space: SPACE, size: diameter / 16,
icon: 'mdi:thermometer', value_badge: { enabled: false },
};
if (['right', 'left', 'top', 'bottom'].includes(arrangement)) marker.value_badge = {
enabled: true, position: arrangement, source: { kind: 'entity_state', entity_id: valueId },
};
if (arrangement === 'text') {
marker.display = 'value';
marker.value_source = { kind: 'entity_state', entity_id: valueId };
}
if (arrangement === 'legacy') {
// A humidity-led legacy marker with a diagnostic climate temperature:
// the preserved second metric widens the same shell as the primary badge.
marker.icon = 'mdi:water-percent';
marker.use_climate_temp = true;
delete marker.value_badge;
addEntity(id, `climate.${stem}_diagnostic`, 'idle', { current_temperature: 22.4 }, true);
}
fixture.config.markers.push(marker);
fixture.config.settings.marker_area_snapshot[id] = { binding: marker.binding, area: 'battery_area' };
fixture.layout[id] = { s: SPACE, x: sample.x, y: sample.y };
}
return fixture;
}
/** Calibrate the public icon-size option once; never force a face's CSS size. */
export async function prepareBatteryBoard(page, scenario) {
return page.evaluate(async ({ samples, scenario, colors, icons }) => {
const card = window.__goldenCard;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const marker = (id) => card.renderRoot.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`);
const reference = samples[0];
const initial = marker(reference.id)?.querySelector('.device-shell-frame')?.getBoundingClientRect().width;
if (!(initial > 0)) throw new Error('battery golden calibration has no round shell');
const iconSize = (scenario.iconSize || 3.4) * reference.diameter / initial;
if (!(iconSize > 0 && iconSize <= 8)) throw new Error(`battery golden invalid icon size ${iconSize}`);
card.setConfig({ type: 'custom:houseplan-card', title: `Golden ${scenario.id}`,
icon_size: iconSize, language: scenario.language || 'en', show_temperature: true, show_signal: false,
});
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage').getBoundingClientRect();
const near = (actual, expected, detail) => {
if (!Number.isFinite(actual) || Math.abs(actual - expected) > 0.5)
throw new Error(`battery golden ${detail}: ${actual} != ${expected}`);
};
const loadedIcons = new Set();
const measured = [];
for (const sample of samples) {
const device = marker(sample.id);
const shell = device?.querySelector('.device-shell-frame');
const battery = device?.querySelector('.device-battery');
if (!shell || !battery) throw new Error(`battery golden missing face: ${sample.id}`);
const iconNodes = battery.querySelectorAll('ha-icon.device-battery-icon');
if (iconNodes.length !== 1) throw new Error(`battery golden icon count: ${sample.id}`);
const icon = iconNodes[0];
await icon.updateComplete;
const expectedIcon = icons[sample.state];
if (icon.icon !== expectedIcon) throw new Error(`battery golden wrong MDI: ${sample.id}`);
const artwork = icon.shadowRoot?.querySelector('svg');
const paths = artwork?.querySelectorAll('path');
const expectedPath = window.__ICONS?.[expectedIcon];
if (!artwork || artwork.getAttribute('viewBox') !== '0 0 24 24'
|| paths.length !== 1 || typeof expectedPath !== 'string' || !expectedPath
|| paths[0].getAttribute('d') !== expectedPath)
throw new Error(`battery golden missing MDI path: ${sample.id}`);
const inkBounds = paths[0].getBBox();
if (!(inkBounds.width > 0 && inkBounds.height > 0))
throw new Error(`battery golden empty MDI geometry: ${sample.id}`);
loadedIcons.add(expectedIcon);
const expectedFrame = { 32: 19, 56: 33, 96: 56 }[sample.diameter];
const expectedGap = { 32: 2, 56: 4, 96: 7 }[sample.diameter];
const rect = battery.getBoundingClientRect();
const iconRect = icon.getBoundingClientRect();
const artworkRect = artwork.getBoundingClientRect();
const whole = shell.getBoundingClientRect();
const core = device.querySelector('.device-core').getBoundingClientRect();
near(core.height * 101.5 / 80, sample.diameter, `${sample.id} base diameter`);
near(rect.width, expectedFrame, `${sample.id} frame width`);
near(rect.height, expectedFrame, `${sample.id} frame height`);
near(iconRect.width, expectedFrame, `${sample.id} MDI host width`);
near(iconRect.height, expectedFrame, `${sample.id} MDI host height`);
near(artworkRect.width, expectedFrame, `${sample.id} painted SVG width`);
near(artworkRect.height, expectedFrame, `${sample.id} painted SVG height`);
near(rect.left - whole.right, expectedGap, `${sample.id} whole-shell gap`);
near(rect.top + rect.height / 2, whole.top + whole.height / 2, `${sample.id} whole-shell center`);
if (battery.dataset.state !== sample.state) throw new Error(`battery golden wrong state: ${sample.id}`);
if (rect.left < stage.left || rect.right > stage.right || rect.top < stage.top || rect.bottom > stage.bottom)
throw new Error(`battery golden clipped battery: ${sample.id}`);
if (sample.arrangement === 'legacy' && !device.querySelector('.legacy-secondary'))
throw new Error('battery golden legacy second metric is absent');
if (sample.arrangement === 'text' && !(whole.width > sample.diameter))
throw new Error('battery golden Text shell is not expanded');
measured.push({ id: sample.id, state: sample.state, color: colors[sample.state],
x: rect.left, y: rect.top, width: rect.width, height: rect.height });
}
await frame();
return { iconSize, loadedIcons: loadedIcons.size, samples: measured };
}, { samples: batteryBoardSamples(scenario.batteryBoard), scenario, colors: COLORS, icons: BATTERY_BOARD_ICONS });
}
/** Real screenshot ink, not DOM presence: empty/broken/invisible SVG fails. */
export async function inspectBatteryBoardPixels(page, png, clip, board) {
const samples = await page.evaluate(async ({ png64, clip, samples }) => {
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, image.width, image.height).data;
image.close();
return samples.map((sample) => {
const left = Math.floor(sample.x - (clip?.x || 0));
const top = Math.floor(sample.y - (clip?.y || 0));
const right = Math.ceil(left + sample.width);
const bottom = Math.ceil(top + sample.height);
let matching = 0;
for (let y = Math.max(0, top); y < Math.min(canvas.height, bottom); y++) {
for (let x = Math.max(0, left); x < Math.min(canvas.width, right); x++) {
const offset = (y * canvas.width + x) * 4;
if (sample.color.every((channel, index) => Math.abs(pixels[offset + index] - channel) <= 12)) matching++;
}
}
return { id: sample.id, matching, minimum: Math.max(8, Math.floor(sample.width * sample.height * 0.035)) };
});
}, { png64: png.toString('base64'), clip, samples: board.samples });
const failures = samples.filter((sample) => sample.matching < sample.minimum);
if (failures.length) throw new Error(`semantic battery golden ink missing: ${JSON.stringify(failures)}`);
return samples;
}
export const BATTERY_ZIGBEE_MARKER = 'battery-56-normal';
/** Same natural unplaced-parent arrangement as smoke_device_battery_zigbee. */
export function makeBatteryZigbeeFixture() {
const fixture = makeBatteryBoardFixture('desktop');
fixture.config.markers = fixture.config.markers.filter((marker) => marker.id === BATTERY_ZIGBEE_MARKER);
fixture.config.markers[0].size = 1.4;
fixture.config.settings = { ...fixture.config.settings, volumetric_view: false,
show_device_battery: true, moon_enabled: false, zigbee_topology: { enabled: true },
marker_area_snapshot: { [BATTERY_ZIGBEE_MARKER]: fixture.config.settings.marker_area_snapshot[BATTERY_ZIGBEE_MARKER] },
};
fixture.devices = { [BATTERY_ZIGBEE_MARKER]: fixture.devices[BATTERY_ZIGBEE_MARKER] };
fixture.entities = Object.fromEntries(Object.entries(fixture.entities)
.filter(([, entity]) => entity.device_id === BATTERY_ZIGBEE_MARKER));
fixture.states = Object.fromEntries(Object.entries(fixture.states).filter(([id]) => fixture.entities[id]));
fixture.layout = { [BATTERY_ZIGBEE_MARKER]: { s: SPACE, x: 0.34, y: 0.34 } };
fixture.zhaDevices = [
{ ieee: '00124b0000000001', nwk: 1, device_reg_id: BATTERY_ZIGBEE_MARKER, device_type: 'EndDevice',
neighbors: [{ ieee: '00124b0000000002', relationship: 'Parent', lqi: 50 }] },
{ ieee: '00124b0000000002', nwk: 2, device_reg_id: 'not_on_plan', device_type: 'Router',
name: 'Upstairs parent relay',
neighbors: [{ ieee: '00124b0000000001', relationship: 'Child', lqi: 50 }] },
];
return fixture;
}
export async function batteryZigbeeProbe(page) {
return page.evaluate((id) => {
const root = window.__goldenCard.renderRoot;
const marker = root.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`);
const overlay = root.querySelector('hp-zigbee-topology-overlay')?.shadowRoot;
const rect = (node) => {
if (!node) throw new Error('battery Zigbee golden missing rendered geometry');
const r = node.getBoundingClientRect();
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height };
};
const battery = rect(marker?.querySelector('.device-battery'));
const caption = rect(overlay?.querySelector('[data-hp="zigbee-topology-parent-bubble"]'));
const core = rect(marker?.querySelector('.device-core'));
return { battery, caption, core, overlap: {
x: Math.max(battery.x, caption.x), y: Math.max(battery.y, caption.y),
right: Math.min(battery.right, caption.right), bottom: Math.min(battery.bottom, caption.bottom),
} };
}, BATTERY_ZIGBEE_MARKER);
}
/** Fixed, core-anchored focus: tiny layering changes cannot hide in a full page. */
export function batteryZigbeeClip(probe, viewport) {
const clip = { x: Math.floor(probe.core.x - 16),
y: Math.floor(probe.core.y + probe.core.height / 2 - 70), width: 420, height: 140 };
if (clip.x < 0 || clip.y < 0 || clip.x + clip.width > viewport.width
|| clip.y + clip.height > viewport.height || [probe.core, probe.battery, probe.caption].some((rect) =>
rect.x < clip.x || rect.y < clip.y || rect.right > clip.x + clip.width || rect.bottom > clip.y + clip.height))
throw new Error(`battery Zigbee golden focused clip truncates the witness: ${JSON.stringify({ clip, probe, viewport })}`);
return clip;
}
/** Executable pixel oracle shared with unit negative cases, not a z-index check. */
export function inspectBatteryZigbeePixels(images, probe, clip) {
if (probe.overlap.right - probe.overlap.x < 4 || probe.overlap.bottom - probe.overlap.y < 6)
throw new Error('battery Zigbee golden caption does not overlap battery');
const at = (image, x, y) => {
const ix = Math.floor(x - clip.x), iy = Math.floor(y - clip.y);
if (ix < 0 || iy < 0 || ix >= image.width || iy >= image.height)
throw new Error('battery Zigbee golden pixel outside capture');
const index = (iy * image.width + ix) * 4;
return image.data.slice(index, index + 3);
};
const changed = (a, b) => a.some((value, index) => Math.abs(value - b[index]) > 12);
const green = (pixel) => COLORS.normal.every((value, index) => Math.abs(value - pixel[index]) < 8);
let ink = 0, covered = 0, coreChanged = 0, exposedRoute = 0;
const centreY = probe.caption.y + probe.caption.height / 2;
for (let y = Math.ceil(Math.max(probe.overlap.y + 2, centreY - 5));
y < Math.min(probe.overlap.bottom - 2, centreY + 5); y++) {
for (let x = Math.ceil(probe.overlap.x + 2); x < probe.overlap.right - 2; x++) {
const baseline = at(images.captionHidden, x, y), painted = at(images.active, x, y);
if (!green(baseline)) continue;
ink++;
if (changed(baseline, painted) && painted[1] - Math.max(painted[0], painted[2]) < 50) covered++;
}
}
const cx = probe.core.x + probe.core.width / 2, cy = probe.core.y + probe.core.height / 2;
for (let x = Math.ceil(cx); x < probe.caption.x - 3; x++) {
for (let dy = -2; dy <= 2; dy++) {
if (!changed(at(images.active, x, cy + dy), at(images.routesHidden, x, cy + dy))) continue;
if (x < probe.core.right - 5) coreChanged++;
else if (x > probe.core.right + 3) exposedRoute++;
}
}
const evidence = { ink, covered, coreChanged, exposedRoute };
// Keep the smoke's differential thresholds; the focused zero-diff golden
// additionally catches a one-pixel edge leak that a ratio alone could miss.
if (ink < 3 || covered / ink < 0.9)
throw new Error(`battery Zigbee golden caption does not cover real battery ink: ${JSON.stringify(evidence)}`);
if (coreChanged > 2 || exposedRoute < 3)
throw new Error(`battery Zigbee golden route/core paint order is missing: ${JSON.stringify(evidence)}`);
return evidence;
}
export async function prepareBatteryZigbeeOverlap(page) {
// Open/refresh/close the real General Settings UI; __hpTest still points at
// the old demo card after golden remounts and must not seed this scene.
await page.evaluate(() => window.__goldenCard.renderRoot.querySelector('[data-hp="settings"]').click());
await page.waitForFunction(() => [...(window.__goldenCard.renderRoot
.querySelector('hp-zigbee-topology-settings')?.shadowRoot?.querySelectorAll('button') || [])]
.some((button) => button.querySelector('ha-icon[icon="mdi:access-point-network"]') && !button.disabled));
await page.evaluate(() => [...window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-settings')
.shadowRoot.querySelectorAll('button')]
.find((button) => button.querySelector('ha-icon[icon="mdi:access-point-network"]')).click());
await page.waitForFunction(() => /Received/i.test(window.__goldenCard.renderRoot
.querySelector('hp-zigbee-topology-settings')?.shadowRoot?.textContent || ''));
await page.evaluate(() => window.__goldenCard.renderRoot.querySelector('hp-dialog [data-hp="dialog-cancel"]').click());
await page.waitForFunction(() => !window.__goldenCard.renderRoot.querySelector('hp-dialog'));
const face = await page.evaluate(async (id) => {
const marker = window.__goldenCard.renderRoot.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`);
const battery = marker?.querySelector('.device-battery');
const icon = battery?.querySelector('ha-icon.device-battery-icon');
await icon?.updateComplete;
const svg = icon?.shadowRoot?.querySelector('svg'), path = svg?.querySelector('path');
const rect = battery?.getBoundingClientRect(), core = marker?.querySelector('.device-core')?.getBoundingClientRect();
if (battery?.dataset.state !== 'normal' || icon?.icon !== 'mdi:battery'
|| !path || path.getAttribute('d') !== window.__ICONS?.['mdi:battery']
|| !(path.getBBox().width > 0) || !(path.getBBox().height > 0)
|| svg.getAttribute('viewBox') !== '0 0 24 24'
|| !(rect.width > 0) || Math.abs(svg.getBoundingClientRect().width - rect.width) > 0.5
|| Math.abs(svg.getBoundingClientRect().height - rect.height) > 0.5)
throw new Error('battery Zigbee golden lacks a full-size official MDI battery');
return { x: core.x + core.width / 2, y: core.y + core.height / 2,
battery: { id, state: 'normal', color: [29, 194, 29], x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
}, BATTERY_ZIGBEE_MARKER);
// Before hover the very same MDI must paint. Empty glyphs cannot make the
// subsequent caption control vacuously green.
await inspectBatteryBoardPixels(page, await page.screenshot({ scale: 'css', animations: 'disabled' }),
null, { samples: [face.battery] });
await page.mouse.move(face.x, face.y);
await page.waitForFunction(() => window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-overlay')
?.shadowRoot?.querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.getBoundingClientRect().width > 0);
await page.evaluate(() => {
const caption = window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-overlay').shadowRoot
.querySelector('[data-hp="zigbee-topology-parent-bubble"]');
if (!caption.textContent.includes('Upstairs parent relay'))
throw new Error('battery Zigbee golden expected real parent caption text');
});
await page.evaluate(async () => {
await window.__goldenCard.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
});
const probe = await batteryZigbeeProbe(page), clip = batteryZigbeeClip(probe, page.viewportSize());
const screenshot = () => page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' });
const controls = async (hidden) => page.evaluate((hidden) => {
const overlay = window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-overlay').shadowRoot;
for (const [name, selector] of [['caption', 'zigbee-topology-parent-bubble'], ['routes', 'zigbee-topology-lines']]) {
const node = overlay.querySelector(`[data-hp="${selector}"]`);
if (!node) throw new Error(`battery Zigbee golden missing ${name}`);
if (hidden === name) node.style.visibility = 'hidden';
else node.style.removeProperty('visibility');
}
}, hidden);
let active, captionHidden, routesHidden;
try {
active = await screenshot();
await controls('caption');
captionHidden = await screenshot();
await controls('routes');
routesHidden = await screenshot();
} finally {
await controls(null);
}
const images = await page.evaluate(async (encoded) => Object.fromEntries(await Promise.all(
Object.entries(encoded).map(async ([name, base64]) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height), context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const image = context.getImageData(0, 0, bitmap.width, bitmap.height);
bitmap.close();
return [name, { width: image.width, height: image.height, data: Array.from(image.data) }];
}))), { active: active.toString('base64'), captionHidden: captionHidden.toString('base64'),
routesHidden: routesHidden.toString('base64') });
return { ...inspectBatteryZigbeePixels(images, probe, clip), clip, overlap: probe.overlap };
}
+9 -1
View File
@@ -1,6 +1,8 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { readFileSync } from 'node:fs';
import { makeBatteryBoardFixture, prepareBatteryBoard, makeBatteryZigbeeFixture,
prepareBatteryZigbeeOverlap, batteryZigbeeProbe, batteryZigbeeClip } from './device-battery.mjs';
const junctionArtifactsFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
@@ -41,7 +43,9 @@ const wallUnionIsolationFixture = JSON.parse(readFileSync(
const cardVersion = '0.0.0-golden';
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
const fixtureFor = (scenario) => scenario.fixture === 'large'
const fixtureFor = (scenario) => scenario.batteryBoard ? makeBatteryBoardFixture(scenario.batteryBoard)
: scenario.batteryZigbeeOverlap ? makeBatteryZigbeeFixture()
: scenario.fixture === 'large'
? makeLargeHouseFixture({ includeStairs: false })
: makeVisualMatrixFixture({
applianceLifecycle: !!scenario.applianceLifecycle, ledStrips: !!scenario.ledStrips,
@@ -1011,6 +1015,7 @@ export async function prepareGoldenScenario(page, scenario) {
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
if (message.type === 'zha/devices' && fixture.zhaDevices) return structuredClone(fixture.zhaDevices);
if (message.type === 'houseplan/config/get') return {
config: structuredClone(fixture.config), rev: 1,
can_write: scenario.canWrite !== false,
@@ -2491,11 +2496,14 @@ export async function prepareGoldenScenario(page, scenario) {
if (!point) throw new Error(`golden hover stair missing: ${scenario.hoverStair}`);
await page.mouse.move(point.x, point.y);
}
if (scenario.batteryBoard) result.batteryBoard = await prepareBatteryBoard(page, scenario);
if (scenario.batteryZigbeeOverlap) result.batteryZigbeeOverlap = await prepareBatteryZigbeeOverlap(page);
return result;
}
export async function goldenClip(page, capture) {
if (capture === 'page') return null;
if (capture === 'battery-zigbee-overlap') return batteryZigbeeClip(await batteryZigbeeProbe(page), page.viewportSize());
return page.evaluate((captureKind) => {
const card = window.__goldenCard;
const target = card?.renderRoot?.querySelector('.stage');
+15 -1
View File
@@ -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 = 71;
export const GOLDEN_MATRIX_VERSION = 73;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -888,6 +888,20 @@ export const GOLDEN_SCENARIOS = Object.freeze([
'golden-climate': { s: 'golden-lighting', x: 0.80, y: 0.28 },
},
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
// #792: six exact designer variants (Light/Dark × D32/56/96), all four
// states, every badge side, expanded Text and legacy multi-metric shells.
// Each rendered battery must render its MDI path and paint matching ink.
...['light', 'dark'].map((theme) => ({
id: `device-battery-board-${theme}`, fixture: 'visual', space: 'golden-battery', mode: 'view',
batteryBoard: 'desktop', theme, viewport: { width: 1120, height: 1120 }, ...stage,
})),
{ id: 'device-battery-mobile-dark', fixture: 'visual', space: 'golden-battery', mode: 'view',
batteryBoard: 'mobile', theme: 'dark', viewport: { width: 390, height: 844 }, ...stage },
// Real ZHA refresh + hover; differential raster controls prove natural
// caption/battery overlap. A focused zero-diff frame protects 1px edges.
{ id: 'device-battery-zigbee-overlap-dark', fixture: 'visual', space: 'golden-battery', mode: 'view',
batteryZigbeeOverlap: true, iconSize: 5, theme: 'dark', viewport: { width: 1200, height: 900 },
capture: 'battery-zigbee-overlap', threshold: { maxChannelDelta: 10, maxDiffRatio: 0 } },
{ id: 'device-value-face-cover-source-dark', fixture: 'visual',
space: 'golden-lighting', mode: 'view', glowEnabled: false, sunRays: false,
showNames: false,
+4
View File
@@ -7,6 +7,7 @@ import { launch } from '../serve.mjs';
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
import { inspectBatteryBoardPixels } from './device-battery.mjs';
import { CAPTURE_PROVENANCE_SCHEMA, captureProvenance } from '../../scripts/capture-environment.mjs';
import {
assertGoldenInvocation,
@@ -1020,6 +1021,9 @@ try {
writeFileSync(actualPath, actual);
result.actualSha256 = sha256(actual);
result.actual = actualPath;
if (scenario.batteryBoard) {
result.batteryPixels = await inspectBatteryBoardPixels(page, actual, clip, result.runtime.batteryBoard);
}
if (scenario.sunRayPixels) {
const control = await captureWithoutSunRays(page, screenshotOptions);
const sample = await countChangedPixels(page, actual, control.png, scenario.sunRayPixels);
+18
View File
@@ -17,6 +17,24 @@
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
и служит guard'ом мутантов в `scripts/mutation-gate.mjs`.
## Граница вердикта (#776)
`finish()` сначала делает round-trip по открытым страницам, затем дожидается
`browser.close()` и только после этого считает доставленные `pageerror`.
Round-trip не опустошает все очереди браузера: хвостовое отклонение промиса
может прийти во время закрытия. Снимок счётчика до `close()` давал `EXC`, затем
ложный `OK` и exit 0. Теперь любое событие, доставленное до итогового вердикта,
входит в единую диагностику и exit 1; произвольные будущие таймеры не ожидаются.
`reportPageErrors()` сохраняет отдельный контракт: round-trip без закрытия
браузера, итог по событиям, доставленным к возврату этого вызова.
`node --test test/smoke-harness-lifecycle.test.mjs` детерминированно исполняет
публичные `watchPage`/`finish`/`reportPageErrors` с управляемой доставкой событий,
в том числе внутри асинхронного закрытия. Он не требует Chromium и проверяет
реальный код выхода дочернего процесса и отсутствие ложного `OK`. Исходные
браузерные пробы остаются без задержек и подавления ошибок: они проверяют
настоящий канал Playwright, а не подменяют его Node-свидетелем.
Одна проба живёт не здесь: `--guard-probe` у `demo/benchmark_backdrop_decode.mjs`
(#430). Benchmark нельзя переселить в этот каталог — его запускают руками при
рекалибровке порогов, — поэтому `verify-guard.mjs` умеет запускать файл выше
+133
View File
@@ -0,0 +1,133 @@
/** #792: production-shaped registry/config inputs, no private card writes. */
export async function installBatteryFixture(page) {
await page.evaluate(async () => {
const card = window.__card;
const sources = [
['sensor.aaa_hp_battery', 'd_temp', '80'],
['sensor.zzz_hp_battery', 'd_temp', '5'],
['sensor.hp_light_battery', 'd_light1', '80'],
];
const states = { ...card.hass.states };
for (const [id, device, state] of sources) {
window.__addRegistryEntity(id, null, state);
Object.assign(card.hass.entities[id], { device_id: device, device_class: 'battery' });
states[id] = { entity_id: id, state, attributes: { device_class: 'battery', unit_of_measurement: '%' } };
window.__setRegistryArea('entity', id, null);
}
card.hass = { ...card.hass, states };
card.setConfig({ type: 'custom:houseplan-card', title: 'Battery witness', icon_size: 5, language: 'en' });
await window.__hpTest.setMode('view');
await window.__hpTest.setServerConfig(cfg => ({ ...cfg,
settings: { ...cfg.settings, volumetric_view: false, show_device_battery: true, moon: false },
markers: [
...Object.keys(card.hass.devices).filter(id => !['d_temp', 'd_light1', 'd_leak'].includes(id))
.map(id => ({ id, binding: `device:${id}`, hidden: true })),
{ id: 'd_temp', binding: 'device:d_temp', space: 'f1', display: 'badge',
value_badge: { enabled: false }, size: 1.4, tap_action: 'info' },
{ id: 'd_light1', binding: 'device:d_light1', space: 'f1', display: 'badge',
value_badge: { enabled: false }, tap_action: 'info' },
{ id: 'd_leak', binding: 'device:d_leak', space: 'f1', display: 'badge', value_badge: { enabled: false } },
],
}));
await window.__hpTest.setLayout(layout => ({ ...layout,
d_temp: { s: 'f1', x: 0.34, y: 0.34 },
d_light1: { s: 'f1', x: 0.7, y: 0.65 },
d_leak: { s: 'f1', x: 0.78, y: 0.3 },
}));
await window.__hpTest.settled();
});
await page.waitForFunction(() => window.__card.shadowRoot
.querySelector('[data-hp="device"][data-id="d_temp"] .device-battery[data-state="normal"]'));
}
export async function setBatteryState(page, state, source = 'sensor.aaa_hp_battery') {
await page.evaluate(async ({ state, source }) => {
const card = window.__card;
card.hass = { ...card.hass, states: { ...card.hass.states,
[source]: { ...card.hass.states[source], state },
} };
for (const secondary of document.querySelectorAll('houseplan-space-card')) secondary.hass = card.hass;
await window.__hpTest.settled();
}, { state, source });
}
export async function patchBatteryMarker(page, patch) {
await page.evaluate(patch => window.__hpTest.setServerConfig(cfg => ({ ...cfg,
markers: cfg.markers.map(marker => marker.id === 'd_temp' ? { ...marker, ...patch } : marker),
})), patch);
}
export async function batteryGeometry(page, id = 'd_temp') {
return page.evaluate(id => {
const node = window.__card.shadowRoot.querySelector(`[data-hp="device"][data-id="${id}"]`);
const rect = selector => {
const r = node?.querySelector(selector)?.getBoundingClientRect();
return r ? { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom } : null;
};
return { shell: rect('.device-shell-frame'), core: rect('.device-core'), badge: rect('.value-badge'),
lqi: rect('.lqi'), battery: rect('.device-battery'),
state: node?.querySelector('.device-battery')?.dataset.state || null,
};
}, id);
}
/** Owner-approved MDI mapping, independently pinned from the production renderer. */
const BATTERY_MDI_ICONS = {
normal: 'mdi:battery', warning: 'mdi:battery-30',
low: 'mdi:battery-outline', unknown: 'mdi:battery-unknown',
};
export async function batteryMdiIcon(page, state, id = 'd_temp') {
return page.evaluate(({ state, id, expected }) => {
const battery = window.__card.shadowRoot
.querySelector(`[data-hp="device"][data-id="${id}"] .device-battery`);
const icon = battery?.querySelector('ha-icon.device-battery-icon');
const path = icon?.shadowRoot?.querySelector('svg path');
const box = path?.getBBox();
return battery?.dataset.state === state && icon?.getAttribute('icon') === expected
&& !!window.__ICONS?.[expected] && path?.getAttribute('d') === window.__ICONS[expected]
&& box?.width > 0 && box?.height > 0;
}, { state, id, expected: BATTERY_MDI_ICONS[state] });
}
/** Count painted colour, not just the ha-icon attribute or a nonempty path. */
export async function batteryColorPixels(page, rectangle, rgb) {
await page.waitForFunction(() => {
const batteries = [...window.__card.shadowRoot.querySelectorAll('.device-battery')];
return batteries.length > 0 && batteries.every(battery => {
const icon = battery.querySelector('ha-icon.device-battery-icon');
const path = icon?.shadowRoot?.querySelector('svg path');
const box = path?.getBBox();
return !!path?.getAttribute('d') && box.width > 0 && box.height > 0;
});
});
await page.evaluate(() => window.__hpTest.settled());
const png = await page.screenshot({ animations: 'disabled', clip: {
x: Math.floor(rectangle.x), y: Math.floor(rectangle.y),
width: Math.ceil(rectangle.width) + 1, height: Math.ceil(rectangle.height) + 1,
} });
const count = await page.evaluate(async ({ data, rgb }) => {
const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${data}`)).blob());
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const pixels = ctx.getImageData(0, 0, bitmap.width, bitmap.height).data;
let count = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (rgb.every((channel, index) => Math.abs(channel - pixels[i + index]) <= 8)) count++;
}
return count;
}, { data: png.toString('base64'), rgb });
if (!count) console.log('Battery missing raster ink:', await page.evaluate(() => {
const node = window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery');
const icon = node?.querySelector('ha-icon.device-battery-icon');
return { html: node?.outerHTML, icon: icon?.getAttribute('icon'),
path: icon?.shadowRoot?.querySelector('path')?.getAttribute('d'),
assets: [icon, icon?.shadowRoot?.querySelector('svg')].filter(Boolean).map(asset => {
const css = getComputedStyle(asset), box = asset.getBoundingClientRect();
return { tag: asset.tagName, display: css.display, visibility: css.visibility,
opacity: css.opacity, width: box.width, height: box.height };
}) };
}));
return count;
}
+9 -10
View File
@@ -37,13 +37,12 @@ const _livePages = new Set();
* уносит недоставленное событие. В логе это видно дословно: `EXC` печатается
* ПОСЛЕ результата и ДО `OK`.
*
* Круговой запрос к странице вытесняет ранее поставленные макрозадачи, поэтому
* всё, что страница успела произвести до этого момента, к нам уже дошло.
*
* Честная граница: исключение, возникшее ПОСЛЕ этого round-trip'а — например, в
* обработчике `beforeunload` при закрытии браузера, — не учитывается. Ловить его
* значит ждать неизвестно чего неизвестно сколько; контракт формулируется как
* «всё, что произошло до вызова вердикта».
* Круговой запрос даёт странице возможность доставить накопленные события,
* но не является барьером для всех browser task queues: например, хвостовое
* отклонение промиса может прийти уже во время browser.close() (#776).
* finish() поэтому читает счётчик только после завершения закрытия. Граница —
* все pageerror, доставленные до вердикта; произвольных будущих таймеров не ждём.
* reportPageErrors() браузер не закрывает и судит события после своего запроса.
*/
async function roundTripLivePages() {
for (const page of _livePages) {
@@ -111,11 +110,11 @@ export async function reportPageErrors() {
/** Print the result, report failures, close the browser, set the exit code. */
export async function finish(browser, out) {
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
// Порядок обязателен: сначала дать странице доставить события, потом читать
// счётчик (#404). Обратный порядок и был дефектом.
// #404/#776: дать доставить события и дождаться закрытия, затем снять итог.
// Между round-trip и close тоже приходят pageerror: ранний снимок давал EXC + OK.
await roundTripLivePages();
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
await browser?.close?.();
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
// #629: доказательство «перевод без потери утверждений» — отсортированный
// список имён проверок; без переменной вывод прежний.
if (process.env.HP_SMOKE_CHECKS === '1') {
+199
View File
@@ -0,0 +1,199 @@
/** #792 AC4–10: live dependencies, static exceptions, geometry and passive input.
* D32/56/96 × light/dark and the approved MDI art are also pinned by battery golden boards;
* route layering has its own raster witness in smoke_device_battery_zigbee.
*/
import { launch, check, checkAll, finish } from './serve.mjs';
import { batteryColorPixels, batteryGeometry, batteryMdiIcon, installBatteryFixture, patchBatteryMarker, setBatteryState } from './helpers/device-battery-fixture.mjs';
const { page, browser } = await launch({ width: 1200, height: 900 }, 1, [], { hasTouch: true });
const out = {};
try {
await installBatteryFixture(page);
out.nonBatteryGetsNoQuestionMark = await page.evaluate(() => {
const node = window.__card.shadowRoot.querySelector('[data-id="d_leak"]');
return !!node && node.getBoundingClientRect().width > 0 && !node.querySelector('.device-battery');
});
for (const [value, state, rgb] of [
['80', 'normal', [29, 194, 29]], ['40', 'warning', [240, 160, 12]],
['5', 'low', [240, 65, 12]], ['unavailable', 'unknown', [112, 119, 129]],
]) {
await setBatteryState(page, value);
await page.waitForFunction(state => window.__card.shadowRoot
.querySelector('[data-id="d_temp"] .device-battery')?.dataset.state === state, state);
const geometry = await batteryGeometry(page);
const pixels = await batteryColorPixels(page, geometry.battery, rgb);
out[`batteryUsesOfficialMdi_${state}`] = await batteryMdiIcon(page, state);
out[`batteryOnlyTickPaints_${state}`] = pixels >= 8;
await page.evaluate(() => window.__hpTest.setServerConfig(cfg => ({ ...cfg,
settings: { ...cfg.settings, show_device_battery: false },
})));
out[`globalOffHides_${state}`] = (await batteryGeometry(page)).battery === null;
await page.evaluate(() => window.__hpTest.setServerConfig(cfg => ({ ...cfg,
settings: { ...cfg.settings, show_device_battery: true },
})));
}
// The selected unavailable first source must not borrow the healthy second.
await setBatteryState(page, '100', 'sensor.zzz_hp_battery');
out.unavailableFirstRemainsUnknown = (await batteryGeometry(page)).state === 'unknown';
await setBatteryState(page, '80');
await page.evaluate(() => window.__setRegistryDisabled('entity', 'sensor.aaa_hp_battery', 'user'));
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery')?.dataset.state === 'unknown');
out.disabledFirstSourceCannotReadStaleLiveValue = true;
await page.evaluate(() => window.__setRegistryDisabled('entity', 'sensor.aaa_hp_battery', null));
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery')?.dataset.state === 'normal');
await page.evaluate(() => window.__setRegistryDisabled('device', 'd_temp', 'user'));
await page.waitForFunction(() => !window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery'));
out.disabledMarkerGetsNoLiveBattery = true;
await page.evaluate(() => window.__setRegistryDisabled('device', 'd_temp', null));
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_temp"] .device-battery')?.dataset.state === 'normal');
for (const position of ['none', 'right', 'left', 'top', 'bottom']) {
await patchBatteryMarker(page, { value_badge: position === 'none' ? { enabled: false } : {
enabled: true, source: { kind: 'entity_state', entity_id: 'sensor.living_temp' }, position,
} });
const before = await batteryGeometry(page);
out[`rightOfWholeBlock_${position}`] = !!before.battery && !!before.shell
&& before.battery.x >= before.shell.right + 1
&& Math.abs(before.battery.y + before.battery.height / 2 - before.shell.y - before.shell.height / 2) <= 0.8;
await page.evaluate(() => window.__hpTest.setServerConfig(cfg => ({ ...cfg,
settings: { ...cfg.settings, show_device_battery: false },
})));
const hidden = await batteryGeometry(page);
out[`hideKeepsCoreAndBadge_${position}`] = hidden.battery === null
&& ['shell', 'core', 'badge', 'lqi'].every(key => JSON.stringify(before[key]) === JSON.stringify(hidden[key]));
await page.evaluate(() => window.__hpTest.setServerConfig(cfg => ({ ...cfg,
settings: { ...cfg.settings, show_device_battery: true },
})));
}
const iconGeometry = await batteryGeometry(page);
await patchBatteryMarker(page, { binding: 'entity:sensor.living_temp', display: 'value',
value_source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
value_badge: { enabled: false },
});
await setBatteryState(page, '123456789012345', 'sensor.living_temp');
const text = await batteryGeometry(page);
out.longTextUsesWholeShell = text.core.width > iconGeometry.core.width * 1.5
&& text.battery.x >= text.shell.right + 1;
await setBatteryState(page, '5');
out.entityMarkerInheritsOwnSiblingTick = (await batteryGeometry(page)).state === 'low';
for (const display of ['static_icon', 'value_static_icon']) {
await patchBatteryMarker(page, { display });
await setBatteryState(page, '40');
const warning = await batteryGeometry(page);
await setBatteryState(page, '80');
const normal = await batteryGeometry(page);
out[`staticModeLiveBattery_${display}`] = warning.state === 'warning' && normal.state === 'normal';
out[`staticModeKeepsOtherDiagnosticsSuppressed_${display}`] = await page.evaluate(() => {
const node = window.__card.shadowRoot.querySelector('[data-id="d_temp"]');
return !node.querySelector('.lqi,.value-badge,.device-pulse,.activity-dot');
});
}
await patchBatteryMarker(page, { display: 'badge', binding: 'device:d_temp', value_badge: { enabled: false } });
await setBatteryState(page, '22.4', 'sensor.living_temp');
// Public marker editor entry gives the exact live preview and draft projection.
await page.evaluate(async () => { await window.__hpTest.setMode('devices'); await window.__hpTest.openMarkerDialog('d_temp'); });
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot
?.querySelector('.device-battery'));
await setBatteryState(page, '40');
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('hp-device-preview')?.shadowRoot
?.querySelector('.device-battery')?.dataset.state === 'warning');
out.previewLiveTickAndNoClipping = await page.evaluate(() => {
const preview = window.__card.shadowRoot.querySelector('hp-device-preview').shadowRoot;
const battery = preview.querySelector('.device-battery');
const r = battery?.getBoundingClientRect();
const stage = preview.querySelector('.previewstage')?.getBoundingClientRect();
return battery?.dataset.state === 'warning' && !!r && !!stage
&& r.left >= stage.left && r.top >= stage.top && r.right <= stage.right && r.bottom <= stage.bottom;
});
await page.evaluate(async () => { await window.__hpTest.close(undefined, { via: 'cancel' }); await window.__hpTest.setMode('view'); });
await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const secondary = document.createElement('houseplan-space-card');
secondary.setConfig({ type: 'custom:houseplan-space-card', space: 'f1', live_states: false, show_button: false });
secondary.hass = window.__card.hass;
document.body.appendChild(secondary);
});
await page.waitForFunction(() => document.querySelector('houseplan-space-card')?.shadowRoot
?.querySelector('[data-id="d_temp"] .device-battery'));
await setBatteryState(page, '5');
await page.waitForFunction(() => document.querySelector('houseplan-space-card')?.shadowRoot
?.querySelector('[data-id="d_temp"] .device-battery')?.dataset.state === 'low');
out.staticCardLiveStatesFalseUpdatesBattery = (await batteryGeometry(page)).state === 'low';
out.staticCardHasNoInteractiveBattery = await page.evaluate(() => {
const node = document.querySelector('houseplan-space-card').shadowRoot.querySelector('[data-id="d_temp"]');
return getComputedStyle(node.querySelector('.device-battery')).pointerEvents === 'none'
&& !node.querySelector('.device-battery [tabindex],.device-battery [title],.device-pulse,.activity-dot');
});
await page.evaluate(() => document.querySelector('houseplan-space-card').remove());
// LED owns the physical representation; hide its geometry to restore the normal icon.
await page.evaluate(() => window.__hpTest.setServerConfig(cfg => ({ ...cfg,
spaces: cfg.spaces.map(space => space.id === 'f1' ? { ...space,
led_strips: [{ id: 'battery-led', marker: 'd_light1', active: true, points: [[0.61, 0.7], [0.82, 0.7]] }],
} : space),
})));
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('.led-strip[data-led-strip="battery-led"]'));
out.ledHasNoBattery = await page.evaluate(() => !window.__card.shadowRoot
.querySelector('[data-id="d_light1"] .device-battery'));
await page.evaluate(() => window.__hpTest.setServerConfig(cfg => ({ ...cfg,
spaces: cfg.spaces.map(space => space.id === 'f1' ? { ...space,
led_strips: space.led_strips.map(strip => ({ ...strip, active: false })),
} : space),
})));
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('[data-id="d_light1"] .device-battery'));
out.hiddenLedRestoresBattery = true;
// A point outside the old shell is visually occupied, but is not a new click target.
const inert = await batteryGeometry(page);
const point = { x: inert.battery.x + inert.battery.width / 2, y: inert.battery.y + inert.battery.height / 2 };
out.batteryDoesNotExpandHitCapsule = await page.evaluate(point => {
const root = window.__card.shadowRoot;
const target = root.elementFromPoint(point.x, point.y);
return !!target && !target.closest('[data-hp="device"][data-id="d_temp"]')
&& !root.querySelector('[data-id="d_temp"] .device-battery [tabindex]')
&& !root.querySelector('[data-id="d_temp"] .device-battery [title]');
}, point);
await page.mouse.click(point.x, point.y);
out.batteryClickDoesNotOpenCard = await page.evaluate(() => !window.__card.shadowRoot
.querySelector('hp-dialog[data-kind="info"],hp-dialog[data-kind="marker"]'));
const flat = await batteryGeometry(page);
await page.evaluate(async () => { await window.__hpTest.setVolumetricView(true); });
const iso = await batteryGeometry(page);
out.isoKeepsScreenFacingBattery = iso.battery.x > iso.shell.right
&& Math.abs(iso.battery.width - iso.battery.height) <= 0.8
&& Math.abs(iso.battery.y + iso.battery.height / 2 - iso.shell.y - iso.shell.height / 2) <= 0.8;
await page.evaluate(() => window.__hpTest.setVolumetricView(false));
const flatAgain = await batteryGeometry(page);
out.viewSwitchDoesNotDriftBattery = Math.abs(flat.battery.x - flatAgain.battery.x) <= 1
&& Math.abs(flat.battery.y - flatAgain.battery.y) <= 1;
// Trusted touch begins on painted battery ink. The plan must still pan.
await page.setViewportSize({ width: 430, height: 820 });
await page.evaluate(() => window.__hpTest.settled());
const mobile = await batteryGeometry(page);
const touchPoint = { x: mobile.battery.x + mobile.battery.width / 2,
y: mobile.battery.y + mobile.battery.height / 2 };
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ ...touchPoint, id: 1 }] });
for (let step = 1; step <= 5; step++) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [
{ x: touchPoint.x + step * 9, y: touchPoint.y + step * 3, id: 1 },
] });
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.evaluate(() => window.__hpTest.settled());
const panned = await batteryGeometry(page);
out.touchPanStartsThroughBattery = Math.hypot(panned.core.x - mobile.core.x, panned.core.y - mobile.core.y) > 15;
out.touchPanDoesNotOpenInfo = await page.evaluate(() => !window.__card.shadowRoot.querySelector('hp-dialog[data-kind="info"]'));
checkAll(out);
} catch (error) {
check('batterySmokeCompleted', false);
out.error = String(error?.stack || error);
}
await finish(browser, out);
+140
View File
@@ -0,0 +1,140 @@
/** #792 AC7: real caption pixels cover battery ink; route pixels stay below core.
* Controls hide only the rendered test layer for a differential raster probe.
* Registry, settings, provider fetch and hover enter through public surfaces.
*/
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { launch, check, checkAll, finish } from './serve.mjs';
import { batteryColorPixels, batteryGeometry, batteryMdiIcon, installBatteryFixture } from './helpers/device-battery-fixture.mjs';
const { page, browser } = await launch({ width: 1200, height: 900 }, 1);
const artifacts = new URL('../artifacts/device-battery-zigbee/', import.meta.url);
mkdirSync(artifacts, { recursive: true });
const out = {};
try {
await installBatteryFixture(page);
await page.evaluate(async () => {
const card = window.__card;
const originalCallWS = card.hass.callWS;
card.hass = { ...card.hass, callWS: async message => {
if (message.type === 'zha/devices') return [
{ ieee: '00124b0000000001', nwk: 1, device_reg_id: 'd_temp', device_type: 'EndDevice',
neighbors: [{ ieee: '00124b0000000002', relationship: 'Parent', lqi: 50 }] },
{ ieee: '00124b0000000002', nwk: 2, device_reg_id: 'not_on_plan', device_type: 'Router',
name: 'Upstairs parent relay', neighbors: [
{ ieee: '00124b0000000001', relationship: 'Child', lqi: 50 },
] },
];
return originalCallWS(message);
} };
await window.__hpTest.setServerConfig(cfg => ({ ...cfg,
settings: { ...cfg.settings, zigbee_topology: { enabled: true } },
}));
card.shadowRoot.querySelector('[data-hp="settings"]').click();
await window.__hpTest.settled();
});
await page.waitForFunction(() => {
const root = window.__card.shadowRoot.querySelector('hp-zigbee-topology-settings')?.shadowRoot;
return [...(root?.querySelectorAll('button') || [])]
.some(button => button.querySelector('ha-icon[icon="mdi:access-point-network"]') && !button.disabled);
});
await page.evaluate(() => {
const root = window.__card.shadowRoot.querySelector('hp-zigbee-topology-settings').shadowRoot;
[...root.querySelectorAll('button')].find(button => button.querySelector('ha-icon[icon="mdi:access-point-network"]')).click();
});
await page.waitForFunction(() => /Received/i.test(window.__card.shadowRoot
.querySelector('hp-zigbee-topology-settings')?.shadowRoot?.textContent || ''));
await page.evaluate(() => window.__hpTest.close(undefined, { via: 'cancel' }));
for (const iso of [false, true]) {
const mode = iso ? 'iso' : 'flat';
await page.evaluate(iso => window.__hpTest.setVolumetricView(iso), iso);
const geometry = await batteryGeometry(page);
out[`${mode}_batteryHasRealGreenInk`] = await batteryColorPixels(page, geometry.battery, [29, 194, 29]) >= 8;
out[`${mode}_batteryUsesOfficialMdi`] = await batteryMdiIcon(page, 'normal');
await page.mouse.move(geometry.core.x + geometry.core.width / 2, geometry.core.y + geometry.core.height / 2);
await page.waitForFunction(() => window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay')?.shadowRoot
?.querySelector('[data-hp="zigbee-topology-parent-bubble"]'));
await page.evaluate(() => window.__hpTest.settled());
const probe = await page.evaluate(() => {
const root = window.__card.shadowRoot;
const overlay = root.querySelector('hp-zigbee-topology-overlay').shadowRoot;
const marker = root.querySelector('[data-id="d_temp"]');
const rect = node => { const r = node.getBoundingClientRect();
return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; };
const battery = rect(marker.querySelector('.device-battery'));
const caption = rect(overlay.querySelector('[data-hp="zigbee-topology-parent-bubble"]'));
const core = rect(marker.querySelector('.device-core'));
return { battery, caption, core, dpr: devicePixelRatio,
overlap: { x: Math.max(battery.x, caption.x), y: Math.max(battery.y, caption.y),
right: Math.min(battery.right, caption.right), bottom: Math.min(battery.bottom, caption.bottom) } };
});
out[`${mode}_captionActuallyOverlapsBattery`] = probe.overlap.right - probe.overlap.x >= 4
&& probe.overlap.bottom - probe.overlap.y >= 6;
const screenshot = name => page.screenshot({ animations: 'disabled',
path: fileURLToPath(new URL(`${mode}-${name}.png`, artifacts)) });
const active = await screenshot('active');
await page.evaluate(() => {
const overlay = window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay').shadowRoot;
overlay.querySelector('[data-hp="zigbee-topology-parent-bubble"]').style.visibility = 'hidden';
});
const captionHidden = await screenshot('caption-hidden-control');
await page.evaluate(() => {
const overlay = window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay').shadowRoot;
overlay.querySelector('[data-hp="zigbee-topology-parent-bubble"]').style.removeProperty('visibility');
overlay.querySelector('[data-hp="zigbee-topology-lines"]').style.visibility = 'hidden';
});
const routesHidden = await screenshot('routes-hidden-control');
await page.evaluate(() => window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay').shadowRoot
.querySelector('[data-hp="zigbee-topology-lines"]').style.removeProperty('visibility'));
const evidence = await page.evaluate(async ({ active, captionHidden, routesHidden, probe }) => {
const decode = async data => {
const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${data}`)).blob());
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height), ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
};
const [a, hidden, noRoutes] = await Promise.all([decode(active), decode(captionHidden), decode(routesHidden)]);
const at = (image, x, y) => {
const index = (Math.floor(y * probe.dpr) * image.width + Math.floor(x * probe.dpr)) * 4;
return [image.data[index], image.data[index + 1], image.data[index + 2]];
};
const changed = (first, second) => first.some((value, index) => Math.abs(value - second[index]) > 12);
let ink = 0, covered = 0, coreChanged = 0, exposedRoute = 0;
// A rounded caption does not paint its bounding-box corners. Sample its
// central band, where the actual dark fill/border/text must cover ink.
const centreY = probe.caption.y + probe.caption.height / 2;
for (let y = Math.ceil(Math.max(probe.overlap.y + 2, centreY - 5));
y < Math.min(probe.overlap.bottom - 2, centreY + 5); y++) {
for (let x = Math.ceil(probe.overlap.x + 2); x < probe.overlap.right - 2; x++) {
const baseline = at(hidden, x, y), painted = at(a, x, y);
if (Math.abs(baseline[0] - 29) < 8 && Math.abs(baseline[1] - 194) < 8 && Math.abs(baseline[2] - 29) < 8) {
ink++;
if (changed(baseline, painted) && painted[1] - Math.max(painted[0], painted[2]) < 50) covered++;
}
}
}
const cx = probe.core.x + probe.core.width / 2, cy = probe.core.y + probe.core.height / 2;
for (let x = Math.ceil(cx); x < probe.caption.x - 3; x++) {
for (let dy = -2; dy <= 2; dy++) {
if (!changed(at(a, x, cy + dy), at(noRoutes, x, cy + dy))) continue;
if (x < probe.core.right - 5) coreChanged++;
else if (x > probe.core.right + 3) exposedRoute++;
}
}
return { ink, covered, coreChanged, exposedRoute,
captionCoversInk: ink >= 3 && covered / ink >= 0.9,
coreCoversRoute: coreChanged <= 2 && exposedRoute >= 3 };
}, { active: active.toString('base64'), captionHidden: captionHidden.toString('base64'),
routesHidden: routesHidden.toString('base64'), probe });
out[`${mode}_captionPaintsOverBattery`] = evidence.captionCoversInk;
out[`${mode}_coreStillPaintsOverRoutes`] = evidence.coreCoversRoute;
console.log(`Battery Zigbee ${mode} pixel evidence:`, evidence, probe);
await page.mouse.move(10, 10);
}
checkAll(out);
} catch (error) {
check('batteryZigbeeSmokeCompleted', false);
out.error = String(error?.stack || error);
}
await finish(browser, out);
+57
View File
@@ -21,6 +21,11 @@ const out = await page.evaluate(async () => {
c._openSettingsDialog(); await upd();
const d0 = JSON.parse(JSON.stringify(c._settingsDialog));
const batteryRow = q('#gs-device-battery')?.closest('.hpf-toggle');
o.batteryDefaultOnInDisplay = d0.showDeviceBattery === true
&& q('#gs-device-battery')?.checked === true
&& batteryRow?.closest('.hpf-card')?.dataset.card === 'display'
&& batteryRow?.querySelector('.hpf-toggle-title')?.textContent.trim() === 'Show device battery status';
// --- AC1: семь карточек по §5.1, старой разметки нет ----------------------
o.sevenCardsInOrder = JSON.stringify(qa('.hpf-card').map((card) => card.dataset.card))
@@ -112,6 +117,58 @@ const out = await page.evaluate(async () => {
o.resetMakesDirtyWhenDefaultsDiffer = JSON.stringify(c._settingsDialog.colors) !== JSON.stringify(d0.colors)
? saveBtn().disabled === false : true;
c._settingsDialog = null; await upd();
// #792 AC9: the battery opt-out uses this form's draft, cancel and shared save.
const beforeBattery = JSON.stringify(c._serverCfg);
const openBatterySettings = async () => {
sr().querySelector('[data-hp="settings"]').click(); await upd();
};
const saveBatterySettings = async () => {
saveBtn().click();
for (let attempt = 0; attempt < 100; attempt++) {
await upd();
if (!c._settingsDialog?.busy) return;
}
throw new Error('battery settings save did not finish');
};
await openBatterySettings();
q('#gs-device-battery').click(); await upd();
o.batteryDraftDoesNotMutateSaved = c._settingsDialog.showDeviceBattery === false
&& saveBtn().disabled === false && JSON.stringify(c._serverCfg) === beforeBattery;
q('[data-hp="dialog-cancel"]').click(); await upd();
o.batteryCancelAsks = !!confirm() && !!dlg();
confirm()?.querySelector('[data-hp="dialog-confirm"]')?.click(); await upd();
o.batteryCancelKeepsSaved = !dlg() && JSON.stringify(c._serverCfg) === beforeBattery;
await openBatterySettings();
o.batteryReopenAfterCancelIsOn = q('#gs-device-battery')?.checked === true;
q('#gs-device-battery').click(); await upd();
const originalCallWS = c.hass.callWS;
let written;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') written = structuredClone(message);
return originalCallWS(message);
} };
await saveBatterySettings();
o.batteryFalseSavedWithRevision = !dlg() && c._serverCfg.settings.show_device_battery === false
&& written?.config.settings.show_device_battery === false && Number.isInteger(written?.expected_rev);
await openBatterySettings();
o.batteryReopensOffAndClean = q('#gs-device-battery')?.checked === false && saveBtn().disabled === true;
const disabledConfig = structuredClone(c._serverCfg);
q('#gs-device-battery').click(); await upd();
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') throw new Error('battery-save-offline');
return originalCallWS(message);
} };
await saveBatterySettings();
o.batteryFailedSaveRollsBackAndKeepsDraft = JSON.stringify(c._serverCfg) === JSON.stringify(disabledConfig)
&& c._settingsDialog?.showDeviceBattery === true && c._settingsDialog?.busy === false;
c.hass = { ...c.hass, callWS: originalCallWS };
await saveBatterySettings();
o.batteryEnabledRemovesKey = !dlg() && !Object.hasOwn(c._serverCfg.settings, 'show_device_battery');
const expectedSettings = { ...disabledConfig.settings };
delete expectedSettings.show_device_battery;
o.batterySavePreservesSiblingSettings = JSON.stringify(c._serverCfg.settings) === JSON.stringify(expectedSettings);
return o;
});
+4 -1
View File
@@ -543,7 +543,10 @@ try {
await page.waitForTimeout(750);
const reconnected = await fadeSnapshot();
checkFadeEvents('reconnected', reconnected);
check('#789: reconnect starts clean lifecycles and returns all four fields', reconnected.nodes.every(nodes =>
// #794: visible fade is a browser oracle, but events after reconnect can also
// finish an old entry. space-card-led-disconnect.test.mjs proves synchronous
// owner disposal independently, before a detached render can clean up.
check('#789: reconnect visibly fades and returns all four fields', reconnected.nodes.every(nodes =>
nodes.every(node => node?.opacity === 1 && !node.leaving)), true);
for (const exit of [
{ label: 'disabled pools', config: { space: 'f1', light_pools: false } },
+3 -1
View File
@@ -129,7 +129,9 @@ const result = await page.evaluate(async () => {
root().querySelector('.dev').dispatchEvent(mouse('pointermove', 200, 200));
await card.updateComplete;
out.deviceTipSurvives = card._tip?.room === false && !!root().querySelector('.tip');
const deviceTip = root().querySelector('.tip');
out.deviceTipSurvives = card._tip?.room === false && !!deviceTip && !deviceTip.hidden
&& deviceTip.textContent.includes(card._tip.title);
card._openSettingsDialog();
out.reopenShowsOff = card._settingsDialog?.showRoomTooltip === false;
+5 -1
View File
@@ -37,7 +37,11 @@ const res = await page.evaluate(async () => {
out.dialogOpens = await openSettings();
const display = q('.hpf-card[data-card="display"]');
const ids = [...display.querySelectorAll('.hpf-toggle > input[type="checkbox"]')].map((i) => i.id);
out.thirdInDisplay = JSON.stringify(ids) === JSON.stringify(['gs-room-tooltip', 'gs-radar-live', 'gs-volumetric-view']);
out.thirdInDisplay = JSON.stringify(ids.slice(0, 3)) === JSON.stringify(['gs-room-tooltip', 'gs-radar-live', 'gs-volumetric-view']);
// #792 appends its control without moving the established Display settings.
out.batteryFollowsExisting = JSON.stringify(ids) === JSON.stringify([
'gs-room-tooltip', 'gs-radar-live', 'gs-volumetric-view', 'gs-device-battery',
]);
const row = q('#gs-volumetric-view').closest('.hpf-toggle')?.parentElement ?? null;
out.rowTitleAndIcon = !!row && row.textContent.includes(c._t('gs.volumetric_view'))
&& !!row.querySelector('ha-icon[icon="mdi:cube-outline"]');
+341
View File
@@ -0,0 +1,341 @@
/** #804: partial partition promotion through the real room dialog and config/set. */
import { launch, check, finish } from './serve.mjs';
import { isDeepStrictEqual } from 'node:util';
const point = (x, y) => [x / 240, y / 240];
const partition = (id, x1, y1, x2, y2, cm) => ({
id, a: point(x1, y1), b: point(x2, y2), cm,
});
const room = (id, x1, y1, x2, y2) => ({
id, name: id, area: null,
poly: [point(x1, y1), point(x2, y1), point(x2, y2), point(x1, y2)],
});
// Only the reported lattice geometry survives here; no private export or HA data.
// In particular there is NO short overlay carrier on x=74, y=112..133.
function garderoba({ opening = null, duplicate = false } = {}) {
const rooms = [room('neighbour', 102, 112, 130, 133)];
rooms[0].wall_ids = ['neighbour-top', 'neighbour-right', 'neighbour-bottom', 'neighbour-left'];
const wallSegments = [
partition('neighbour-left', 102, 112, 102, 133, 15),
partition('neighbour-top', 102, 112, 130, 112, 15),
partition('neighbour-bottom', 102, 133, 130, 133, 15),
partition('neighbour-right', 130, 112, 130, 133, 15),
];
// Explicit current-model seed: standalone browser/mutation runs do not need
// an unrelated test-build compilation just to construct this rectangle.
const keys = ['0.425000,0.512500@1.5706', '0.483333,0.466667@0.0000',
'0.483333,0.554167@0.0000', '0.541667,0.512500@1.5706'];
const config = {
model_version: 10, markers: [], settings: {},
spaces: [{
id: 'lineage', title: 'Lineage', cell_cm: 8, view_box: [0.24, 0.34, 0.36, 0.52],
rooms, wall_segments: wallSegments,
walls: wallSegments.map(({ a, b, cm }, index) => ({ key: keys[index], cm, a, b })),
openings: [],
partitions: [
partition('long-source', 74, 90, 74, 200, 25),
partition('upper-source', 74, 112, 102, 112, 15),
partition('lower-source', 102, 133, 74, 133, 15),
],
}, {
id: 'other-floor', title: 'Other floor', cell_cm: 8,
view_box: [0, 0, 1, 1], rooms: [], wall_segments: [],
partitions: [partition('untouched-wall', 10, 10, 30, 10, 15)],
}],
};
const space = config.spaces[0];
if (opening) {
const y = opening === 'same-residual' ? 101 : opening === 'absorbed' ? 122 : 160;
space.openings = [{
id: 'retained-opening', type: 'window', x: 74 / 240, y: y / 240,
angle: -90, length: 6 / 240, cm: 48,
host: { kind: 'partition', id: 'long-source', t: (y - 90) / 110 },
contact: 'binary_sensor.synthetic_window', future_field: { keep: true },
}];
}
if (duplicate) space.wall_segments.push(structuredClone(space.wall_segments[0]));
return config;
}
function batchConfig() {
return {
model_version: 10, markers: [], settings: {},
spaces: [{
id: 'lineage', title: 'Lineage', cell_cm: 8, view_box: [0.24, 0.34, 0.30, 0.36],
rooms: [], wall_segments: [], openings: [],
partitions: [
partition('batch-source', 74, 90, 74, 160, 25),
partition('batch-top', 74, 112, 102, 112, 15),
partition('batch-divider', 74, 126, 102, 126, 15),
partition('batch-bottom', 74, 140, 102, 140, 15),
],
}],
};
}
const results = {};
let lastBrowser;
async function scenario(name, config, body) {
if (process.env.HP_LINEAGE_SCENARIO && !name.includes(process.env.HP_LINEAGE_SCENARIO)) return;
console.log(`SCENARIO ${name}`);
const { page, browser } = await launch({ width: 1280, height: 960 });
page.setDefaultTimeout(10000);
lastBrowser = browser;
try {
await page.evaluate(async (config) => {
await window.__hpTest.setServerConfig(config);
await window.__hpTest.setLayout({});
await window.__hpTest.switchSpace('lineage');
await window.__hpTest.setMode('plan');
await window.__hpTest.setTool('draw');
// Mode transition owns the stage briefly after the toolbar settles.
await new Promise((resolve) => setTimeout(resolve, 500));
await window.__hpTest.settled();
const card = window.__card;
const fallback = card.hass.callWS.bind(card.hass);
const state = {
saved: structuredClone(card._serverCfg), revision: card._cfgRev,
attempts: [], accepted: [], rejected: [], reads: 0,
};
// A public HA boundary, not a private editor override. The mock actually
// persists accepted writes and enforces the existing host-kind/id rule.
card.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') {
state.reads++;
return { config: structuredClone(state.saved), rev: state.revision, can_write: true };
}
if (message.type !== 'houseplan/config/set') return fallback(message);
const candidate = structuredClone(message.config);
state.attempts.push(candidate);
for (const oldSpace of state.saved.spaces || []) {
const nextSpace = candidate.spaces.find((item) => item.id === oldSpace.id);
for (const oldOpening of oldSpace.openings || []) {
if (oldOpening.host?.kind !== 'partition') continue;
const next = nextSpace?.openings?.find((item) => item.id === oldOpening.id);
if (next && (next.host?.kind !== 'partition' || next.host.id !== oldOpening.host.id)) {
state.rejected.push({ opening: oldOpening.id, code: 'invalid_format' });
throw Object.assign(new Error('surviving partition opening cannot change its host'), {
code: 'invalid_format',
});
}
}
}
state.saved = candidate;
state.accepted.push(candidate);
return { ok: true, rev: ++state.revision };
} };
window.__lineageSmoke = state;
}, config);
const output = await body(page);
results[name] = output;
for (const [key, value] of Object.entries(output)) check(`${name}: ${key}`, value);
} catch (error) {
console.error(`${name}: ${error.stack || error.message}`);
console.error('UI state', await page.evaluate(() => ({
mode: window.__card._mode, tool: window.__card._tool, space: window.__card._space,
path: window.__card._path, toast: window.__card._toast,
roomDialog: window.__card._roomDialog, view: window.__card._view,
dialogs: [...window.__card.renderRoot.querySelectorAll('[data-hp="dialog"]')].map((item) => item.outerHTML.slice(0, 400)),
})));
results[name] = { exception: error.message };
check(`${name}: scenario completed`, false);
} finally {
await browser.close();
}
}
async function clickPoint(page, x, y) {
const screen = await page.evaluate(([x, y]) => {
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
const view = window.__card._view;
return {
x: rect.left + (x / 240 * 1000 - view.x) / view.w * rect.width,
y: rect.top + (y / 240 * 1000 - view.y) / view.h * rect.height,
};
}, [x, y]);
await page.mouse.click(screen.x, screen.y);
await page.evaluate(() => window.__hpTest.settled());
}
async function decide(page, name, keep = false) {
const dialog = page.locator('[data-hp="dialog"][data-kind="room"]');
await dialog.waitFor({ state: 'visible' });
if (keep) {
await dialog.locator('[data-hp="dialog-confirm"]').filter({ hasText: 'Keep as walls' }).click();
} else {
await dialog.locator('#room-name').fill(name);
await dialog.locator('.dialog-action-commit [data-hp="dialog-confirm"]').click();
}
await page.evaluate(() => window.__hpTest.settled());
}
async function settleWrites(page) {
// The public save path is debounced. Waiting also proves a rejected gesture
// did not merely postpone a write until after an immediate assertion.
await page.waitForTimeout(800);
await page.waitForFunction(() => window.__card._writesPending === 0);
await page.evaluate(() => window.__hpTest.settled());
}
async function snapshot(page) {
return page.evaluate(() => ({
config: structuredClone(window.__card._serverCfg),
history: window.__card._geometryHistory.size,
toast: window.__card._toast,
server: structuredClone(window.__lineageSmoke),
pendingRooms: window.__card._wallFaceBatch?.candidates.length || 0,
}));
}
const equal = isDeepStrictEqual;
const geometry = (config) => config.spaces.map((space) => ({
id: space.id, rooms: space.rooms || [], wall_segments: space.wall_segments || [],
walls: space.walls || [], partitions: space.partitions || [], openings: space.openings || [],
}));
const uniqueIds = (space) => {
const segments = [...space.wall_segments || [], ...space.partitions || []];
const ids = segments.map((item) => item.id);
const catalog = new Set((space.wall_segments || []).map((item) => item.id));
return new Set(ids).size === ids.length
&& (space.rooms || []).every((item) => item.wall_ids?.length === item.poly.length
&& item.wall_ids.every((id) => catalog.has(id)));
};
await scenario('Garderoba without workaround', garderoba(), async (page) => {
const before = await snapshot(page);
await clickPoint(page, 88, 122);
await decide(page, 'Garderoba');
await settleWrites(page);
const after = await snapshot(page);
const space = after.config.spaces[0];
const created = space.rooms.find((item) => item.name === 'Garderoba');
const residuals = (space.partitions || []).filter((item) => (
Math.abs(item.a[0] * 240 - 74) < 1e-6 && Math.abs(item.b[0] * 240 - 74) < 1e-6
));
const out = {
roomCreated: !!created && space.rooms.length === before.config.spaces[0].rooms.length + 1,
actualConfigSaved: after.server.accepted.length === before.server.accepted.length + 1
&& !!created && equal(geometry(after.server.saved), geometry(after.config)),
twoOriginalContinuations: residuals.length === 2
&& residuals.every((item) => item.cm === 25)
&& equal(residuals.map((item) => [item.a[1] * 240, item.b[1] * 240].map(Math.round)).sort((a, b) => a[0] - b[0]), [[90, 112], [133, 200]]),
roomThicknessPreserved: !!created && equal(created.wall_ids.map((id) => space.wall_segments.find((item) => item.id === id).cm).sort((a, b) => a - b), [15, 15, 15, 25]),
neighboursAndOtherFloorUnchanged: equal(space.rooms.find((item) => item.id === 'neighbour'), before.config.spaces[0].rooms[0])
&& equal(after.config.spaces[1], before.config.spaces[1]),
identitiesValid: uniqueIds(space),
oneHistoryCommand: after.history === before.history + 1,
noMigrationError: !String(after.toast).includes('wall identifiers'),
};
if (!created) return out;
const historyButton = (icon) => page.locator('[data-hp="toolbar"][data-kind="plan"] button')
.filter({ has: page.locator(`ha-icon[icon="mdi:${icon}-variant"]`) });
await historyButton('undo').click();
await settleWrites(page);
const undone = await snapshot(page);
out.undoRestoresSource = equal(geometry(undone.config), geometry(before.config));
await historyButton('redo').click();
await settleWrites(page);
const redone = await snapshot(page);
out.redoRestoresExactIds = equal(geometry(redone.config), geometry(after.config));
// A distinct card reads the mock server via the real config/get lifecycle.
await page.evaluate(async () => {
const old = window.__card;
const next = document.createElement('houseplan-card');
next.setConfig({ type: 'custom:houseplan-card' });
next.hass = old.hass;
old.replaceWith(next);
window.__card = next;
});
await page.waitForFunction(() => window.__card._serverCfg?.spaces?.[0]?.rooms?.length === 2);
const reloaded = await snapshot(page);
out.reloadReadsDurableIdentity = reloaded.server.reads > 0
&& equal(geometry(reloaded.config), geometry(after.config));
return out;
});
for (const ending of ['save', 'keep', 'cancel', 'escape']) {
await scenario(`batch ${ending}`, batchConfig(), async (page) => {
await clickPoint(page, 102, 112);
await clickPoint(page, 102, 140);
await settleWrites(page);
const before = await snapshot(page);
await decide(page, 'First');
const pending = await snapshot(page);
const out = {
twoFacesOffered: before.pendingRooms === 2,
firstDecisionDoesNotWritePartialRoom: pending.config.spaces[0].rooms.length === 0
&& pending.server.accepted.length === before.server.accepted.length,
};
if (ending === 'save') await decide(page, 'Second');
else if (ending === 'keep') await decide(page, '', true);
else {
await page.evaluate(async (via) => {
const dialog = window.__card.renderRoot.querySelector('[data-hp="dialog"][data-kind="room"]');
const outcome = await window.__hpTest.close(dialog, { via });
if (outcome.confirm) outcome.confirm.querySelector('[data-hp="dialog-confirm"]').click();
}, ending === 'escape' ? 'escape' : 'cancel');
}
await settleWrites(page);
const after = await snapshot(page);
const space = after.config.spaces[0];
if (ending === 'save' || ending === 'keep') {
const expected = ending === 'save' ? 2 : 1;
out.onlyAcceptedRoomsCreated = space.rooms.length === expected;
out.oneAtomicHistoryAndSave = after.history === before.history + 1
&& after.server.accepted.length === before.server.accepted.length + 1;
out.identitiesValid = uniqueIds(space);
if (ending === 'save' && space.rooms.length === 2) {
const shared = space.rooms[0].wall_ids.filter((id) => space.rooms[1].wall_ids.includes(id));
out.oneSharedPhysicalAtom = shared.length === 1
&& space.wall_segments.filter((item) => item.id === shared[0]).length === 1;
}
} else {
out.cancelDiscardsEarlierDecision = equal(geometry(after.config), geometry(before.config))
&& after.history === before.history
&& after.server.accepted.length === before.server.accepted.length;
}
return out;
});
}
await scenario('true duplicate rollback', garderoba({ duplicate: true }), async (page) => {
const before = await snapshot(page);
await clickPoint(page, 88, 122);
await decide(page, 'Must not exist');
await settleWrites(page);
const after = await snapshot(page);
return {
duplicateStillRejected: String(after.toast).includes('wall identifiers'),
noPartialMutation: equal(geometry(after.config), geometry(before.config)),
noHistoryOrServerWrite: after.history === before.history && after.server.attempts.length === 0,
};
});
for (const opening of ['same-residual', 'absorbed', 'new-residual']) {
await scenario(`opening ${opening}`, garderoba({ opening }), async (page) => {
const before = await snapshot(page);
await clickPoint(page, 88, 122);
await decide(page, 'Opening room');
await settleWrites(page);
const after = await snapshot(page);
if (opening !== 'same-residual') return {
actualBackendRejection: after.server.attempts.length === 1 && after.server.rejected.length === 1,
serverRetainsOriginal: after.server.accepted.length === 0 && equal(after.server.saved, before.server.saved),
optimisticGeometryRolledBack: equal(geometry(after.config), geometry(before.config)),
noRejectedHistory: after.history === before.history,
};
const oldOpening = before.config.spaces[0].openings[0];
const newOpening = after.config.spaces[0].openings[0];
const { host: _oldHost, ...oldFields } = oldOpening;
const { host: _newHost, ...newFields } = newOpening;
return {
sameResidualAccepted: after.server.accepted.length === 1 && after.server.rejected.length === 0,
openingGeometryAndMetadataPreserved: equal(oldFields, newFields),
originalPartitionHostRetained: newOpening.host.kind === 'partition'
&& newOpening.host.id === oldOpening.host.id && newOpening.host.t !== oldOpening.host.t,
};
});
}
await finish(lastBrowser, results);
+471
View File
@@ -0,0 +1,471 @@
// #802: real pointer + public HA/config inputs. No private card writes: the
// fixture delivers registry events and the integration's normal topology feed.
import { mkdirSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
const artifacts = new URL('../artifacts/zigbee-topology-802/', import.meta.url);
mkdirSync(artifacts, { recursive: true });
const out = {};
const evidence = [];
const ieee = (n) => `00124b000000000${n}`;
const remoteName = 'Landing <router> & "night light"';
const nodes = [
['d_light1', 1, 1, 'Router'], ['d_lamp', 2, 0, 'Coordinator'],
['d_mower', 3, 3, 'EndDevice'], ['d_temp', 4, 4, 'EndDevice'],
['not_on_plan', 6, 6, 'Router'], ['d_kettle', 7, 7, 'EndDevice'],
];
const z2m = {
nodes: nodes.map(([id, n, nwk, type]) => ({ ieeeAddr: ieee(n), networkAddress: nwk, type,
friendlyName: id === 'not_on_plan' ? 'Unplaced <router> & target' : 'Provider target' })),
links: [
{ source: ieee(2), target: ieee(1), linkquality: 130, routes: [
{ destinationAddress: 0, nextHopAddress: 0, status: 'ACTIVE' },
] },
{ source: ieee(1), target: ieee(3), relationship: 'Parent', linkquality: 140 },
{ source: ieee(6), target: ieee(4), relationship: 'Parent', linkquality: 90 },
{ source: ieee(2), target: ieee(6), linkquality: 110, routes: [
{ destinationAddress: 0, nextHopAddress: 0, status: 'ACTIVE' },
] },
],
};
const zha = nodes.map(([id, n, nwk, type]) => ({ ieee: ieee(n), nwk, device_reg_id: id,
device_type: type, name: id === 'not_on_plan' ? 'Unplaced <router> & target' : 'Provider target',
neighbors: n === 3 ? [{ ieee: ieee(1), relationship: 'Parent', lqi: 140 }]
: n === 4 ? [{ ieee: ieee(6), relationship: 'Parent', lqi: 90 }] : [],
...(n === 1 || n === 6 ? { routes: [{ dest_nwk: 0, next_hop: 0, route_status: 'Active' }] } : {}),
}));
for (const provider of ['z2m', 'zha']) {
const { page, browser } = await launch({ width: 1100, height: 900 });
const root = page.locator('#host > houseplan-card');
const marker = (id) => root.locator(`[data-hp="device"][data-id="${id}"]`);
const bubble = root.locator('[data-hp="zigbee-topology-parent-bubble"]');
const settle = () => page.evaluate(() => window.__hpTest.settled());
// Like smoke_preloader, keep the RAF-backed facade operation rooted in the
// page and wait from Playwright: CDP can otherwise collect the returned
// pending evaluate promise. Start once; never retry a config mutation.
const setVolumetricView = async (on) => {
await page.evaluate((on) => {
const probe = { done: false, error: null, promise: null };
window.__zigbeeProjectionSwitch = probe;
probe.promise = window.__hpTest.setVolumetricView(on);
void probe.promise.then(() => { probe.done = true; }, (error) => {
probe.error = error instanceof Error ? error.stack || error.message : String(error);
probe.done = true;
});
}, on);
try {
await page.waitForFunction(() => window.__zigbeeProjectionSwitch?.done,
null, { polling: 25, timeout: 15000 });
const error = await page.evaluate(() => window.__zigbeeProjectionSwitch.error);
if (error) throw new Error(`projection fixture failed: ${error}`);
} finally {
await page.evaluate(() => { delete window.__zigbeeProjectionSwitch; });
}
};
let pointer;
const hover = async (id, vertical = 0.5) => {
await page.mouse.move(2, 2);
const box = await marker(id).boundingBox();
if (!box) throw new Error(`missing marker ${id}`);
pointer = { x: box.x + box.width / 2, y: box.y + box.height * vertical };
await page.mouse.move(pointer.x, pointer.y);
await settle();
};
const sample = async (id) => page.evaluate((id) => {
const card = window.__card;
const root = card.shadowRoot;
const topology = root.querySelector('hp-zigbee-topology-overlay');
const tip = root.querySelector('[data-hp-live-tip]');
const rect = (element) => {
const r = element.getBoundingClientRect();
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height };
};
const visible = (element) => !!element && !element.hidden && element.getBoundingClientRect().width > 0
&& getComputedStyle(element).display !== 'none' && getComputedStyle(element).visibility !== 'hidden';
const stage = rect(root.querySelector('.stage'));
const bounds = { left: Math.max(stage.left, 0), top: Math.max(stage.top, 0),
right: Math.min(stage.right, innerWidth), bottom: Math.min(stage.bottom, innerHeight) };
const source = rect(root.querySelector(`[data-hp="device"][data-id="${id}"]`));
const badges = [...(topology?.shadowRoot?.querySelectorAll('.parent-bubble,.remote,.route-status') || [])]
.filter(visible).map((element) => ({ rect: rect(element), text: element.textContent.trim(),
kind: element.className, pointerEvents: getComputedStyle(element).pointerEvents }));
const tipRect = tip ? rect(tip) : null;
const overlap = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left))
* Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
const tipVisible = visible(tip);
const badgesDisjoint = badges.every((badge, i) => badges.slice(i + 1)
.every((other) => overlap(badge.rect, other.rect) < 0.5));
const inside = (r) => r.left >= bounds.left - 1 && r.top >= bounds.top - 1
&& r.right <= bounds.right + 1 && r.bottom <= bounds.bottom + 1;
const sourceElement = root.querySelector(`[data-hp="device"][data-id="${id}"]`);
const floor = sourceElement.getAttribute('data-hp-iso-floor')?.split(',').map(Number);
const visual = sourceElement.getAttribute('data-hp-iso-visual')?.split(',').map(Number);
return { source, bounds, badges, tip: tipRect, tipVisible,
projectionIso: root.querySelector('.stage').classList.contains('projection-iso'),
isoWalls: !!root.querySelector('[data-hp="iso-walls"]'),
sourceRaised: floor?.length === 2 && visual?.length === 2
&& Math.hypot(floor[0] - visual[0], floor[1] - visual[1]) > 0.1,
tipText: tip?.textContent || '', badgesDisjoint,
clean: tipVisible && badges.length > 0 && badges.every((badge) => overlap(tipRect, badge.rect) < 0.5)
&& overlap(tipRect, source) < 0.5 && inside(tipRect),
badgeInside: badges.every((badge) => inside(badge.rect)),
pointerTransparent: !!topology && getComputedStyle(topology).pointerEvents === 'none'
&& badges.every((badge) => badge.pointerEvents === 'none'),
safeText: !topology?.shadowRoot?.querySelector('router,img,script'),
endpoint: root.querySelector(`[data-hp="device"][data-id="${id}"]`)?.hasAttribute('data-hp-zigbee-topology-endpoint'),
lines: [...(topology?.shadowRoot?.querySelectorAll('line,polygon') || [])].map((element) =>
['x1', 'x2', 'y1', 'y2', 'points'].map((name) => element.getAttribute(name)).join(',')),
calls: { ...window.__tooltipFixture.calls },
};
}, id);
const witness = async (name, id, { hidden = false } = {}) => {
const value = await sample(id);
evidence.push({ provider, name, ...value });
out[`${provider}_${name}`] = value.badgesDisjoint
&& (hidden ? !value.tipVisible && value.badges.length > 0 : value.clean);
return value;
};
const deliver = async (options = {}) => page.evaluate((options) => {
const fixture = window.__tooltipFixture;
if (options.name !== undefined) {
fixture.devices.d_light1 = { ...fixture.devices.d_light1,
name_by_user: options.name, name: options.registryName ?? '' };
window.__card.hass = { ...window.__card.hass, devices: { ...fixture.devices } };
for (const callback of fixture.registryCallbacks) callback({ action: 'update', device_id: 'd_light1' });
}
if (options.clear) fixture.result = null;
else if (options.restore) fixture.result = fixture.originalResult;
if (options.providerName !== undefined && fixture.result) {
fixture.result = structuredClone(fixture.result);
fixture.result.nodes[0].friendlyName = options.providerName;
}
fixture.phase = options.phase || 'ready';
fixture.stale = options.stale === true;
for (const callback of fixture.callbacks) callback(fixture.event());
}, options);
try {
// A delayed overlay chunk makes the first hover occur before upgrade. The
// source pointer stays still while the public backend reset supplies data.
let releaseOverlay;
let overlayWasDelayed = false;
const overlayReady = new Promise((resolve) => { releaseOverlay = resolve; });
if (provider === 'z2m') await page.route('**/*hp-zigbee-topology-overlay*.js', async (route) => {
overlayWasDelayed = true;
await overlayReady; await route.fallback();
});
await page.evaluate(async ({ provider, nodes, z2m, zha, remoteName }) => {
const card = window.__card;
const original = card.hass;
const fixture = { devices: structuredClone(original.devices), callbacks: new Set(), registryCallbacks: new Set(),
calls: { zha: 0, start: 0, subscriptions: 0 }, revision: 0, phase: 'ready', stale: false,
result: provider === 'z2m' ? z2m : null, originalResult: z2m, zha };
for (const [id, n] of nodes) if (fixture.devices[id]) fixture.devices[id].identifiers = [['zha', `00124b000000000${n}`]];
fixture.devices.d_light1 = { ...fixture.devices.d_light1, name_by_user: remoteName };
fixture.event = () => fixture.result ? {
kind: 'state', session_id: 'tooltip-802', revision: ++fixture.revision,
provider: { topic: 'zigbee2mqtt', job_id: 'fixture', phase: fixture.phase, elapsed_ms: 1200,
obtained_at: Date.now() - (fixture.stale ? 360000 : 0), stale: fixture.stale,
...(fixture.phase === 'error' ? { error: 'provider' } : {}), result: fixture.result },
} : { kind: 'removed', session_id: 'tooltip-802', revision: ++fixture.revision, topic: 'zigbee2mqtt' };
const connection = { ...original.connection,
subscribeEvents: async (callback, type) => {
if (type === 'device_registry_updated') fixture.registryCallbacks.add(callback);
const release = await original.connection.subscribeEvents(callback, type);
return () => { fixture.registryCallbacks.delete(callback); release(); };
},
subscribeMessage: async (callback, message) => {
if (message.type !== 'houseplan/zigbee/subscribe') return original.connection.subscribeMessage(callback, message);
fixture.calls.subscriptions++;
fixture.callbacks.add(callback);
callback({ kind: 'reset', session_id: 'tooltip-802', revision: fixture.revision,
topics: fixture.result ? ['zigbee2mqtt'] : [] });
if (fixture.result) callback(fixture.event());
return () => fixture.callbacks.delete(callback);
},
};
card.hass = { ...original, devices: fixture.devices, connection,
callWS: async (message) => {
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'zha/devices') { fixture.calls.zha++; return fixture.zha; }
if (message.type === 'houseplan/zigbee/start') { fixture.calls.start++; return fixture.event(); }
const response = await original.callWS(message);
return message.type === 'houseplan/config/get' ? { ...response, zigbee_scan_api: 1 } : response;
},
};
window.__tooltipFixture = fixture;
document.getElementById('host').style.width = '900px';
await window.__hpTest.setServerConfig((cfg) => {
cfg.settings = { ...cfg.settings, zigbee_topology: { enabled: true, z2m_base_topics: ['zigbee2mqtt'] } };
cfg.spaces = cfg.spaces.map((space) => ({ ...space,
settings: { ...space.settings, show_borders: true, show_names: true },
}));
return cfg;
});
await window.__hpTest.setLayout((layout) => ({ ...layout,
d_kettle: { s: 'f1', x: 0.50, y: 0.40 }, d_temp: { s: 'f1', x: 0.22, y: 0.62 },
d_light1: { s: 'f1', x: 0.25, y: 0.27 }, d_mower: { s: 'garden', x: 0.50, y: 0.48 },
}));
}, { provider, nodes, z2m, zha, remoteName });
if (provider === 'zha') {
await page.evaluate(() => window.__hpTest.setMode('plan'));
await root.locator('[data-hp="settings"]').click();
await root.locator('hp-zigbee-topology-settings').getByRole('button', { name: /Read ZHA/ }).click();
await page.waitForFunction(() => window.__tooltipFixture.calls.zha === 1);
await page.evaluate(() => window.__hpTest.close());
await page.evaluate(() => window.__hpTest.setMode('view'));
}
await page.evaluate(() => window.__hpTest.switchSpace('garden'));
await hover('d_mower');
if (provider === 'z2m') out.mouseReallyPrecedesLazyUpgrade = overlayWasDelayed && await page.evaluate(() =>
!customElements.get('hp-zigbee-topology-overlay')
&& !window.__card.shadowRoot.querySelector('[data-hp-live-tip]').hidden);
releaseOverlay();
await bubble.waitFor({ state: 'visible' });
await settle();
out[`${provider}_remoteCaption`] = (await bubble.innerText()).trim() === `Ground floor (${remoteName})`;
const remote = await witness(provider === 'z2m' ? 'firstHoverAfterLazyUpgrade' : 'firstMouseHover', 'd_mower');
out[`${provider}_safeTextAndPointerTransparent`] = remote.safeText && remote.pointerTransparent;
await page.screenshot({ path: fileURLToPath(new URL(`${provider}-remote-caption.png`, artifacts)) });
if (provider === 'z2m') {
for (const [name, registryName, providerName, expected] of [
[' ', 'Registry target', 'Provider target', 'Ground floor (Registry target)'],
[' ', ' ', 'Provider target', 'Ground floor (Provider target)'],
[' ', ' ', ' ', 'Ground floor'],
]) {
await deliver({ name, registryName, providerName });
await page.waitForTimeout(300);
await hover('d_mower');
await page.waitForFunction((expected) => window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay')
?.shadowRoot?.querySelector('.parent-bubble')?.textContent.trim() === expected, expected);
out[`${provider}_nameFallback_${expected}`] = (await bubble.innerText()).trim() === expected;
}
await deliver({ name: remoteName });
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'f1').title = ''; return cfg;
}));
await hover('d_mower');
out.z2m_missingSpaceTitle = (await bubble.innerText()).trim() === `another space (${remoteName})`;
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => {
cfg.spaces.find((space) => space.id === 'f1').title = 'Ground floor'; return cfg;
}));
for (const iso of [false, true]) for (const theme of ['light', 'dark']) {
await setVolumetricView(iso);
await page.evaluate(({ theme }) => {
const card = window.__card;
card.hass = { ...card.hass, themes: { ...card.hass.themes, darkMode: theme === 'dark' } };
for (const [key, value] of Object.entries(theme === 'light' ? {
'--card-background-color': '#ffffff', '--ha-card-background': '#ffffff',
'--primary-text-color': '#202020', '--secondary-text-color': '#606060',
} : { '--card-background-color': '#1c2530', '--ha-card-background': '#1c2530',
'--primary-text-color': '#e1e1e1', '--secondary-text-color': '#9aa4ad' })) card.style.setProperty(key, value);
}, { theme });
await page.evaluate(() => window.__hpTest.switchSpace('f1'));
for (const [id, name] of [['d_temp', 'unplaced'], ['d_light1', 'remote-count'], ['d_kettle', 'status-only']]) {
await hover(id, 0.25);
await root.locator('.route-status').waitFor({ state: 'visible' });
const value = await witness(`${iso ? 'iso' : 'flat'}_${theme}_${name}`, id);
out[`actualProjection_${iso}_${theme}_${name}`] = value.projectionIso === iso
&& (!iso || (value.isoWalls && value.sourceRaised));
if (name === 'remote-count') out[`remoteCount_${iso}_${theme}`] = value.badges.some((badge) => /1/.test(badge.text) && badge.kind === 'remote');
if (name === 'status-only') {
out[`statusOnlyHasNoEndpoint_${iso}_${theme}`] = !value.endpoint && value.lines.length === 0
&& value.badges.some((badge) => /No route data/.test(badge.text));
await page.screenshot({ path: fileURLToPath(new URL(`status-only-${iso ? 'iso' : 'flat'}-${theme}.png`, artifacts)) });
if (!iso && theme === 'light') {
// Recreate only the old display position, not product state. The
// same intersection oracle must reject the original defect.
const negative = await page.evaluate(({ x, y }) => {
const tip = window.__card.shadowRoot.querySelector('[data-hp-live-tip]');
const before = tip.getAttribute('style');
tip.style.left = `${Math.min(innerWidth - tip.offsetWidth - 8, x + 12)}px`;
tip.style.top = `${Math.min(innerHeight - tip.offsetHeight - 8, y + 12)}px`;
return before;
}, pointer);
const oldPlacement = await sample(id);
evidence.push({ provider, name: 'negative-control-cursor-plus-12', ...oldPlacement });
out.oldPlacementNegativeControlFails = !oldPlacement.clean && oldPlacement.tipVisible
&& oldPlacement.badges.some(({ rect }) => Math.max(0, Math.min(rect.right, oldPlacement.tip.right)
- Math.max(rect.left, oldPlacement.tip.left)) * Math.max(0, Math.min(rect.bottom, oldPlacement.tip.bottom)
- Math.max(rect.top, oldPlacement.tip.top)) > 10);
await page.screenshot({ path: fileURLToPath(new URL('negative-control-old-placement.png', artifacts)) });
await root.locator('[data-hp-live-tip]').evaluate((tip, style) => tip.setAttribute('style', style), negative);
}
}
}
}
// Live provider status arrives while the mouse remains stationary.
await deliver({ phase: 'error', stale: true });
await page.waitForFunction(() => /Stale data/i.test(window.__card.shadowRoot.querySelector('hp-zigbee-topology-overlay')
?.shadowRoot?.querySelector('.route-status')?.textContent || ''));
await settle();
const changed = await witness('runtimeErrorStaleNoPointerMove', 'd_kettle');
out.runtimeIncludesPartialStaleError = changed.badges.some((badge) => /Incomplete data/.test(badge.text)
&& /Stale data/i.test(badge.text) && /could not be loaded/i.test(badge.text));
await deliver();
await setVolumetricView(false);
await page.evaluate(() => window.__hpTest.switchSpace('garden'));
const longName = ('Long landing router <safe> & ' + 'north corridor diagnostic lighting '.repeat(5)).trim();
await deliver({ name: longName });
await page.waitForTimeout(300);
await hover('d_mower');
const long = await witness('longName', 'd_mower');
out.longNameWrapsInsideStage = long.badgeInside && long.safeText
&& long.badges.some((badge) => badge.text.includes(longName) && badge.rect.height > 25);
await page.screenshot({ path: fileURLToPath(new URL('long-remote-caption.png', artifacts)) });
// Freeze the fixture header/stage height so horizontal container resize
// cannot move the marker away and create an unrelated native pointerout.
await deliver({ name: remoteName });
await page.evaluate(() => {
const style = document.createElement('style');
style.textContent = '.head{height:100px!important;min-height:100px!important;max-height:100px!important;overflow:hidden}.stage{height:400px!important;min-height:400px!important;max-height:400px!important}';
window.__card.shadowRoot.append(style);
});
await page.waitForTimeout(150);
await hover('d_mower');
const beforeResize = await sample('d_mower');
await page.evaluate(() => { document.getElementById('host').style.width = '95px'; });
await page.waitForTimeout(150);
await witness('narrowHidesOnlyOrdinaryTip', 'd_mower', { hidden: true });
await page.screenshot({ path: fileURLToPath(new URL('narrow-priority.png', artifacts)) });
await deliver({ clear: true }); await settle();
const narrowWithoutBadges = await sample('d_mower');
out.removingBadgesRestoresHiddenTipWithoutNewHover = narrowWithoutBadges.tipVisible
&& narrowWithoutBadges.badges.length === 0;
await deliver({ restore: true }); await settle();
await witness('returningBadgesReappliesNarrowFallback', 'd_mower', { hidden: true });
await page.evaluate(() => { document.getElementById('host').style.width = '900px'; });
await page.waitForTimeout(150);
const restored = await witness('resizeRestoresWithoutNewHover', 'd_mower');
out.resizePreservesSourceAndRoute = JSON.stringify(restored.source) === JSON.stringify(beforeResize.source)
&& JSON.stringify(restored.lines) === JSON.stringify(beforeResize.lines);
await deliver({ clear: true });
await settle();
const removed = await sample('d_mower');
out.removingDiagnosticsRestoresOrdinaryTip = removed.tipVisible && removed.badges.length === 0;
await deliver({ restore: true, name: remoteName });
await hover('d_mower');
const networkBefore = JSON.stringify((await sample('d_mower')).calls);
for (let i = 0; i < 12; i++) await page.mouse.move(pointer.x + i % 3, pointer.y + i % 2);
out.pointerMotionNeverFetches = JSON.stringify((await sample('d_mower')).calls) === networkBefore;
await page.mouse.move(2, 2); await settle();
const left = await sample('d_mower');
out.pointerLeaveClearsBoth = !left.tipVisible && left.badges.length === 0;
await page.evaluate(() => window.__hpTest.switchSpace('f1'));
await hover('d_tv');
const ordinary = await sample('d_tv');
out.nonZigbeeRetainsOrdinaryTooltip = ordinary.tipVisible && ordinary.badges.length === 0;
await page.evaluate(() => window.__hpTest.switchSpace('garden'));
await page.evaluate(() => window.__hpTest.setLayout((layout) => ({ ...layout,
d_mower: { s: 'garden', x: 0.94, y: 0.12 },
})));
await hover('d_mower');
const edge = await witness('edgeCaption', 'd_mower');
out.edgeCaptionInsideWorkingArea = edge.badgeInside;
await page.mouse.wheel(0, -180);
await page.waitForTimeout(450);
const zoomed = await witness('cameraZoomWithoutNewHover', 'd_mower');
out.cameraActuallyChangesGeometry = Math.abs(edge.source.width - zoomed.source.width) > 0.1
|| Math.abs(edge.source.left - zoomed.source.left) > 0.1;
await page.screenshot({ path: fileURLToPath(new URL('edge-zoom.png', artifacts)) });
// A second production card shares the HA connection/runtime but owns its
// own pointer/focus tip and layout observers.
await page.evaluate(() => {
const second = document.createElement('houseplan-card');
second.id = 'second-tooltip-card';
second.setConfig({ type: 'custom:houseplan-card', icon_size: 3.4 });
second.hass = window.__card.hass;
second.style.cssText = 'position:fixed;left:5px;top:5px;width:350px;z-index:100';
document.body.append(second);
});
const second = page.locator('#second-tooltip-card');
await second.locator('[data-hp="device"]').first().waitFor({ state: 'visible' });
await second.locator('[data-hp="device"]').first().focus();
await page.keyboard.press('Tab');
await settle();
out.secondCardKeyboardTipDoesNotActivateTopology = await second.evaluate((card) => {
const root = card.shadowRoot;
return root.activeElement?.matches('[data-hp="device"]:focus-visible')
&& !root.querySelector('[data-hp-live-tip]').hidden
&& !root.querySelector('hp-zigbee-topology-overlay')?.shadowRoot?.querySelector('.parent-bubble,.remote,.route-status');
});
await hover('d_mower');
const firstWithSecond = await sample('d_mower');
await second.evaluate((card) => card.remove());
await settle();
const afterSecondRemoved = await sample('d_mower');
out.secondCardUnmountDoesNotClearFirstHover = firstWithSecond.clean && afterSecondRemoved.clean
&& JSON.stringify(firstWithSecond.badges) === JSON.stringify(afterSecondRemoved.badges);
for (const pointerType of ['touch', 'pen']) {
await marker('d_mower').dispatchEvent('pointerdown', { pointerType, pointerId: 82,
bubbles: true, composed: true, clientX: pointer.x, clientY: pointer.y });
await settle();
const cleared = await sample('d_mower');
out[`${pointerType}ClearsTooltipAndTopology`] = !cleared.tipVisible && cleared.badges.length === 0;
await hover('d_mower');
await witness(`mouseRestoresAfter_${pointerType}`, 'd_mower');
}
await page.mouse.move(2, 2);
await page.keyboard.press('Tab');
await marker('d_mower').focus();
await settle();
const keyboardOnly = await sample('d_mower');
out.focusAloneShowsOrdinaryTipWithoutTopology = keyboardOnly.tipVisible && keyboardOnly.badges.length === 0
&& await marker('d_mower').evaluate((element) => element.matches(':focus-visible'));
await hover('d_mower');
await witness('focusedDeviceMouseDiagnostics', 'd_mower');
await page.mouse.move(2, 2); await settle();
const focusRestored = await sample('d_mower');
out.pointerLeaveRestoresKeyboardTipAfterDiagnostics = focusRestored.tipVisible
&& focusRestored.tipText === keyboardOnly.tipText && focusRestored.badges.length === 0;
await marker('d_mower').evaluate((element) => element.blur());
// A transformed HA/dashboard ancestor establishes the containing block
// for a position:fixed tooltip. Measure the actual screen result, not
// the CSS left/top values, before and after document scrolling.
await hover('d_mower');
const beforeAncestorTransform = await sample('d_mower');
await page.evaluate(() => {
document.body.style.minHeight = '1800px';
const host = document.getElementById('host');
host.style.transformOrigin = '0 0';
host.style.transform = 'translate(35px, 125px) scale(0.82)';
});
await hover('d_mower');
const transformed = await witness('transformedContainingAncestor', 'd_mower');
out.ancestorScaleReallyAppliesToFixedTooltip = Math.abs(
transformed.source.width / beforeAncestorTransform.source.width - 0.82,
) < 0.02 && await root.locator('[data-hp-live-tip]').evaluate((tip) =>
getComputedStyle(tip).position === 'fixed');
out.transformedBadgesInsideVisibleStage = transformed.badgeInside;
await page.screenshot({ path: fileURLToPath(new URL('ancestor-transform.png', artifacts)) });
// Six CSS pixels keep the pointer inside the marker, so this check also
// witnesses the scroll observer without a new pointer event/hover.
await page.evaluate(() => window.scrollTo(0, 6));
await settle();
const smallScroll = await witness('ancestorScrollWithoutNewHover', 'd_mower');
out.scrollActuallyMovesScreenGeometry = Math.abs(transformed.source.top - smallScroll.source.top - 6) < 1;
// A larger document scroll moves the source away from the old pointer;
// re-enter the still-visible marker with a real mouse.
await page.evaluate(() => window.scrollTo(0, 180));
await hover('d_mower');
const scrolled = await witness('transformedAncestorAfterPageScroll', 'd_mower');
out.largePageScrollKeepsBadgesInsideVisibleStage = scrolled.badgeInside
&& Math.abs(transformed.source.top - scrolled.source.top - 180) < 1
&& await page.evaluate(() => window.scrollY === 180);
await page.screenshot({ path: fileURLToPath(new URL('ancestor-transform-scrolled.png', artifacts)) });
}
} catch (error) {
out[`${provider}_completed`] = false;
console.error(error);
await page.screenshot({ path: fileURLToPath(new URL(`${provider}-failure.png`, artifacts)) });
} finally {
// Keep the shared smoke page-error guard authoritative for both browsers.
await finish(browser);
}
}
writeFileSync(new URL('layout-evidence.json', artifacts), JSON.stringify({ out, evidence }, null, 2));
checkAll(out);
await finish(null, out);
+444 -43
View File
@@ -1,5 +1,9 @@
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { launch, checkAll, finish } from './serve.mjs';
const visualArtifacts = new URL('../artifacts/zigbee-topology-798/', import.meta.url);
mkdirSync(visualArtifacts, { recursive: true });
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
@@ -24,33 +28,47 @@ const result = await page.evaluate(async () => {
.some((entry) => /zigbee-topology-runtime|hp-zigbee-topology-overlay/.test(entry.name));
let zhaCalls = 0;
let pendingConfigReads = 0;
window.__zigbeeSmokeFetchCount = () => zhaCalls;
const originalHass = card.hass;
card.hass = { ...originalHass, callWS: async (message) => {
if (message.type !== 'zha/devices') return originalHass.callWS(message);
if (message.type !== 'zha/devices') {
const configRead = message.type === 'houseplan/config/get';
if (configRead) pendingConfigReads++;
try { return await originalHass.callWS(message); }
finally { if (configRead) pendingConfigReads--; }
}
zhaCalls++;
return [
{ ieee: '00124b0000000001', device_reg_id: 'd_light1', device_type: 'Router', neighbors: [
{ ieee: '00124b0000000001', nwk: 1, device_reg_id: 'd_light1', device_type: 'Router',
routes: [{ dest_nwk: 0, next_hop: 0, route_status: 'Active' }], neighbors: [
{ ieee: '00124b0000000002', relationship: 'Parent' },
{ ieee: '00124b0000000003', lqi: 80, relationship: 'Child' },
{ ieee: '00124b0000000005', lqi: 125, relationship: 'Sibling' },
{ ieee: '00124b0000000005', lqi: 128, relationship: 'Child' },
{ ieee: '00124b0000000007', lqi: 255, relationship: 'Sibling' },
] },
{ ieee: '00124b0000000002', device_reg_id: 'd_lamp', device_type: 'Coordinator', neighbors: [
{ ieee: '00124b0000000001', lqi: 170, relationship: 'Child' },
{ ieee: '00124b0000000002', nwk: 0, device_reg_id: 'd_lamp', device_type: 'Coordinator', neighbors: [
{ ieee: '00124b0000000001', relationship: 'Child' },
{ ieee: '00124b0000000006', lqi: 140, relationship: 'Child' },
] },
{ ieee: '00124b0000000003', device_reg_id: 'd_mower', device_type: 'EndDevice', neighbors: [
{ ieee: '00124b0000000003', nwk: 3, device_reg_id: 'd_mower', device_type: 'EndDevice', neighbors: [
{ ieee: '00124b0000000001', lqi: 75, relationship: 'Parent' },
] },
{ ieee: '00124b0000000004', device_reg_id: 'd_temp', device_type: 'EndDevice', neighbors: [
{ ieee: '00124b0000000004', nwk: 4, device_reg_id: 'd_temp', device_type: 'EndDevice', neighbors: [
{ ieee: '00124b0000000006', lqi: 20, relationship: 'Parent' },
] },
{ ieee: '00124b0000000005', device_reg_id: 'd_tv', device_type: 'EndDevice', neighbors: [
{ ieee: '00124b0000000001', lqi: 120, relationship: 'Sibling' },
{ ieee: '00124b0000000005', nwk: 5, device_reg_id: 'd_tv', device_type: 'EndDevice', neighbors: [
{ ieee: '00124b0000000001', lqi: 128, relationship: 'Parent' },
] },
{ ieee: '00124b0000000006', device_reg_id: 'not_on_plan', device_type: 'Router', neighbors: [
{ ieee: '00124b0000000006', nwk: 6, device_reg_id: 'not_on_plan', device_type: 'Router',
name: 'Hallway <router> & "target"',
routes: [{ dest_nwk: '0x0000', next_hop: '0x0000', route_status: 'Active' }], neighbors: [
{ ieee: '00124b0000000002', lqi: 130, relationship: 'Parent' },
{ ieee: '00124b0000000004', lqi: 25, relationship: 'Child' },
] },
{ ieee: '00124b0000000007', nwk: 7, device_reg_id: 'd_kettle', device_type: 'EndDevice', neighbors: [
{ ieee: '00124b0000000001', lqi: 255, relationship: 'Sibling' },
] },
];
} };
await card._ensureEditorRuntime();
@@ -72,10 +90,35 @@ const result = await page.evaluate(async () => {
await wait(() => !!settings?.shadowRoot?.querySelector('button'), 'settings button');
await settings._readZha();
await wait(() => settings._snapshot?.states?.zha?.phase === 'ready', 'ZHA ready');
// #800: this fixture intentionally has the old integration's config/get.
// backend_required may be cached from the overlay's earlier failed check.
// Reopening settings legitimately checks again. Drain the actual in-flight
// transport and its queued runtime/Lit notifications before the isolated age
// fixture; checking the old error string alone is not a completion barrier.
await wait(() => settings._snapshot?.backendError === 'backend_required'
&& pendingConfigReads === 0, 'old backend capability reads settled');
await new Promise(resolve => setTimeout(resolve, 0));
await settings.updateComplete;
out.oldBackendStillAllowsZha = settings._snapshot.states.zha.phase === 'ready';
out.explicitZhaRead = zhaCalls === 1;
const settingsSnapshot = settings._snapshot;
const renderedProviderStatus = () => [...settings.shadowRoot.querySelectorAll('.status,.hpf-actions .hpf-hint')]
.map((node) => node.textContent).find((value) => /Received /.test(value)) || '';
const ageFixtureTime = Date.now() - 5 * 60 * 1000 + 300;
settings._acceptSnapshot({ ...settingsSnapshot, states: { ...settingsSnapshot.states,
zha: { phase: 'ready', obtainedAt: ageFixtureTime },
} });
await settings.updateComplete;
const initiallyFreshSettings = !/stale/i.test(renderedProviderStatus());
await wait(() => /stale/i.test(renderedProviderStatus()), 'mounted settings snapshot ages');
out.settingsAgeTimerWithoutRefetch = initiallyFreshSettings && zhaCalls === 1
&& settings._snapshot.states.zha.obtainedAt === ageFixtureTime;
settings._acceptSnapshot(settingsSnapshot);
await settings.updateComplete;
card._settingsDialog = null;
card.requestUpdate();
await card.updateComplete;
out.settingsDisconnectClearsTimer = !settings.isConnected && settings._staleTimer === undefined;
const frame = () => new Promise((resolve) => requestAnimationFrame(() =>
requestAnimationFrame(resolve)));
@@ -170,7 +213,7 @@ const result = await page.evaluate(async () => {
const devlayer = root().querySelector('.devlayer');
const endpoints = [...devlayer.querySelectorAll('[data-hp-zigbee-topology-endpoint]')]
.map((marker) => marker.dataset.id).sort();
const overlayLayer = Number.parseInt(getComputedStyle(overlay).zIndex, 10);
const overlayLayer = Number.parseInt(getComputedStyle(overlay.shadowRoot.querySelector('svg')).zIndex, 10);
const unrelatedFloorLayer = Number.parseInt(getComputedStyle(unrelated, '::before').zIndex, 10);
const unrelatedShellLayer = Number.parseInt(
getComputedStyle(unrelated.querySelector('.device-shell')).zIndex, 10,
@@ -196,10 +239,13 @@ const result = await page.evaluate(async () => {
const arrowBox = routeArrow?.getBoundingClientRect();
out.localRouteArrow = !!routeLine && routeArrow?.getAttribute('data-direction') === 'toward-neighbor'
&& !!lineBox && !!arrowBox && arrowBox.width > 3 && arrowBox.height > 3;
out.onlyConfirmedArrows = overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-arrow"]').length === 2
&& !overlay.shadowRoot.querySelector('line[data-direction="none"]')
&& !root().querySelector('.dev[data-id="d_kettle"]').hasAttribute('data-hp-zigbee-topology-endpoint');
out.crossSpaceCount = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-remote"]')
?.textContent.trim() === '+1 in other spaces';
out.pointerTransparent = getComputedStyle(overlay).pointerEvents === 'none'
&& [...overlay.shadowRoot.querySelectorAll('svg,line,polygon,.halo,.remote,.parent-bubble')]
&& [...overlay.shadowRoot.querySelectorAll('svg,line,polygon,.halo,.remote,.parent-bubble,.route-status')]
.every((node) => getComputedStyle(node).pointerEvents === 'none');
const unrelatedRect = unrelated.getBoundingClientRect();
const hitTarget = root().elementFromPoint(
@@ -216,23 +262,34 @@ const result = await page.evaluate(async () => {
out.pointerHitTarget = !!hitTarget && unrelated.contains(hitTarget) && clickReachedMarker;
const casing = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]');
const unknownCore = overlay.shadowRoot.querySelector(
'[data-hp="zigbee-topology-line"][stroke-dasharray="5 5"]',
'[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]',
);
const knownCore = overlay.shadowRoot.querySelector(
'[data-hp="zigbee-topology-line"][data-direction="toward-origin"]',
);
const knownCore = [...overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-line"]')]
.find((line) => !line.hasAttribute('stroke-dasharray'));
out.unknownCasingContract = !!casing && !!unknownCore && !!knownCore
&& overlay.shadowRoot.querySelectorAll('[data-hp="zigbee-topology-line-casing"]').length === 1
&& casing.getAttribute('stroke') === '#2e2e2e'
&& casing.getAttribute('stroke-width') === '4'
&& unknownCore.getAttribute('stroke-width') === '2'
&& casing.getAttribute('stroke-dasharray') === unknownCore.getAttribute('stroke-dasharray')
&& casing.getAttribute('stroke-dashoffset') === unknownCore.getAttribute('stroke-dashoffset')
&& getComputedStyle(casing).stroke === 'rgb(0, 0, 0)'
&& Math.abs(Number.parseFloat(getComputedStyle(casing).strokeWidth) - (2 + 2 * card._zoom)) < 0.001
&& Number.parseFloat(getComputedStyle(unknownCore).strokeWidth) === 2
&& getComputedStyle(unknownCore).stroke === 'rgb(145, 155, 165)'
&& casing.getAttribute('x1') === unknownCore.getAttribute('x1')
&& casing.getAttribute('y1') === unknownCore.getAttribute('y1')
&& casing.getAttribute('x2') === unknownCore.getAttribute('x2')
&& casing.getAttribute('y2') === unknownCore.getAttribute('y2')
&& getComputedStyle(casing).strokeLinecap === getComputedStyle(unknownCore).strokeLinecap
&& getComputedStyle(casing).vectorEffect === 'non-scaling-stroke';
out.unknownArrowOutline = !!routeArrow
&& getComputedStyle(routeArrow).fill === 'rgb(145, 155, 165)'
&& getComputedStyle(routeArrow).stroke === 'rgb(0, 0, 0)'
&& Math.abs(Number.parseFloat(getComputedStyle(routeArrow).strokeWidth) - 2 * card._zoom) < 0.001
&& getComputedStyle(routeArrow).vectorEffect === 'non-scaling-stroke';
out.allLinksSolid = [...overlay.shadowRoot.querySelectorAll('line,polygon')]
.every((node) => getComputedStyle(node).strokeDasharray === 'none');
out.knownLineArrowMatch = getComputedStyle(knownCore).stroke === 'rgb(255, 255, 0)'
&& getComputedStyle(overlay.shadowRoot.querySelector(
'[data-hp="zigbee-topology-arrow"][data-direction="toward-origin"]',
)).fill === getComputedStyle(knownCore).stroke;
const oldNeighbor = unknownNeighbor;
const replacementNeighbor = oldNeighbor.cloneNode(true);
@@ -246,6 +303,7 @@ const result = await page.evaluate(async () => {
'endpoint ownership returned to original marker');
const runtimeBeforeInvalidation = overlay._runtime;
window.__zigbeeSmokeBaseline = runtimeBeforeInvalidation;
overlay._acceptRuntime({
revision: runtimeBeforeInvalidation.revision + 1,
topologies: [],
@@ -260,6 +318,20 @@ const result = await page.evaluate(async () => {
await overlay.updateComplete;
await wait(() => overlay.shadowRoot.querySelector('line'), 'route restored after runtime invalidation');
const noRoute = root().querySelector('.dev[data-id="d_kettle"]');
noRoute.dispatchEvent(mouse('pointerover'));
await wait(() => overlay.shadowRoot.querySelector('.route-status'), 'unknown route label');
out.unknownRouteHasNoInventedLine = !overlay.shadowRoot.querySelector('line,polygon')
&& /no route data/i.test(overlay.shadowRoot.querySelector('.route-status').textContent)
&& !noRoute.hasAttribute('data-hp-zigbee-topology-endpoint');
unknownNeighbor.dispatchEvent(mouse('pointerover'));
await wait(() => overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]'), 'coordinator incoming route');
out.coordinatorHasOnlyIncoming = !/no route data/i.test(
overlay.shadowRoot.querySelector('.route-status')?.textContent || '',
)
&& !overlay.shadowRoot.querySelector('[data-direction="toward-neighbor"]')
&& !!overlay.shadowRoot.querySelector('[data-direction="toward-origin"]');
card._commitSpace('garden', true);
card.requestUpdate();
await card.updateComplete;
@@ -271,8 +343,9 @@ const result = await page.evaluate(async () => {
?.querySelector('[data-hp="zigbee-topology-parent-bubble"]'), 'remote parent bubble');
let activeOverlay = root().querySelector('hp-zigbee-topology-overlay');
const firstTitle = card._serverCfg.spaces.find((space) => space.id === 'f1')?.title;
const remoteParentName = card.hass.devices.d_light1.name_by_user || card.hass.devices.d_light1.name;
out.remoteParentBubble = activeOverlay.shadowRoot
.querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.textContent.trim() === firstTitle
.querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.textContent.trim() === `${firstTitle} (${remoteParentName})`
&& activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-bubble"]')
?.getAttribute('data-kind') === 'remote-space'
&& !!activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]')
@@ -287,8 +360,11 @@ const result = await page.evaluate(async () => {
await wait(() => root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot
?.querySelector('[data-kind="unplaced-device"]'), 'unplaced device bubble');
activeOverlay = root().querySelector('hp-zigbee-topology-overlay');
out.unplacedDeviceBubble = activeOverlay.shadowRoot.querySelector('[data-kind="unplaced-device"]')
?.textContent.trim() === 'device is not on the plan';
const missingParentBubble = activeOverlay.shadowRoot.querySelector('[data-kind="unplaced-device"]');
out.unplacedDeviceBubble = missingParentBubble?.textContent.trim()
=== 'Device is not on the plan (Hallway <router> & "target")';
out.unplacedNameIsSafeText = !missingParentBubble?.querySelector('router,img,script')
&& !missingParentBubble?.textContent.includes('Temperature sensor');
activeOverlay.devices = activeOverlay.devices.map((device) => (
device.id === 'd_lamp' ? { ...device, hidden: true } : device
@@ -301,8 +377,19 @@ const result = await page.evaluate(async () => {
out.unplacedCoordinatorBubble = activeOverlay.shadowRoot
.querySelector('[data-kind="unplaced-coordinator"]')?.textContent.trim()
=== 'coordinator is not on the plan';
out.parentRouteHasNoCasing = !!activeOverlay.shadowRoot.querySelector('line.parent-route')
&& !activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]');
const parentCore = activeOverlay.shadowRoot.querySelector('line.parent-route');
const parentArrow = activeOverlay.shadowRoot.querySelector('[data-hp="zigbee-topology-parent-arrow"]');
out.parentRouteHasUnknownOutline = !!parentCore && !!parentArrow
&& getComputedStyle(parentCore).stroke === 'rgb(145, 155, 165)'
&& getComputedStyle(parentArrow).fill === 'rgb(145, 155, 165)'
&& getComputedStyle(parentArrow).stroke === 'rgb(0, 0, 0)'
&& Math.abs(Number.parseFloat(getComputedStyle(parentArrow).strokeWidth) - 2 * card._zoom) < 0.001
&& [...activeOverlay.shadowRoot.querySelectorAll('line')].some((line) => (
getComputedStyle(line).stroke === 'rgb(0, 0, 0)'
&& Math.abs(Number.parseFloat(getComputedStyle(line).strokeWidth) - (2 + 2 * card._zoom)) < 0.001
))
&& [...activeOverlay.shadowRoot.querySelectorAll('line,polygon')]
.every((node) => getComputedStyle(node).strokeDasharray === 'none');
router.dispatchEvent(mouse('pointerout', root().querySelector('.stage')));
await activeOverlay.updateComplete;
@@ -357,6 +444,172 @@ const result = await page.evaluate(async () => {
return out;
});
// Exercise the actual rendered line/arrow pair, not the palette helper in isolation.
// Remote and unplaced targets used to be permanently grey even with known LQI.
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
const visual = await page.evaluate(async ({ theme }) => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const wait = async (predicate, label = 'render') => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > 5000) throw new Error(`topology visual contract timeout: ${theme} ${label}; `
+ root().querySelector('hp-zigbee-topology-overlay')?.shadowRoot?.textContent?.slice(-600));
await new Promise((resolve) => setTimeout(resolve, 20));
}
};
const hover = (marker) => marker.dispatchEvent(new PointerEvent('pointerover', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200,
}));
document.documentElement.style.colorScheme = theme;
card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true },
themes: { ...(card.hass.themes || {}), darkMode: theme === 'dark' } };
card.setAttribute('data-pointer-hover', '');
await window.__hpTest.setMode('view');
card.requestUpdate();
await card.updateComplete;
await wait(() => root().querySelector('hp-zigbee-topology-overlay'));
// The preceding editor/View probe starts a real camera transition. Let its
// saved View camera settle before measuring the explicit zoom matrix.
await wait(() => !card._modeTransitionBusy, 'View camera settled');
await frame();
const expectedColors = [
[0, 'rgb(255, 0, 0)'], [64, 'rgb(255, 128, 0)'], [128, 'rgb(255, 255, 0)'],
[192, 'rgb(126, 255, 0)'], [255, 'rgb(0, 255, 0)'], [null, 'rgb(145, 155, 165)'],
];
const out = {};
const diagnostics = [];
let revision = window.__zigbeeSmokeBaseline.revision + 100;
const snapshot = (lqi) => ({
...window.__zigbeeSmokeBaseline, revision: revision++,
topologies: window.__zigbeeSmokeBaseline.topologies.map((topology) => ({
...topology, obtainedAt: Date.now(),
uplinkEvidence: topology.uplinkEvidence.map((evidence) => ({ ...evidence, lqi: lqi ?? undefined })),
links: topology.links.map((link) => ({ ...link,
aToB: link.aToB ? { ...link.aToB, lqi: lqi ?? undefined } : undefined,
bToA: link.bToA ? { ...link.bToA, lqi: lqi ?? undefined } : undefined,
})),
})),
});
for (const [kind, space, markerId] of [
['local', 'f1', 'd_light1'], ['remote', 'garden', 'd_mower'], ['unplaced', 'f1', 'd_temp'],
]) {
card._commitSpace(space, true);
card.requestUpdate();
await card.updateComplete;
for (const [zoomName, zoom] of [['min', 1 / 3], ['default', 1], ['max', 8]]) {
card._applyView(zoom, 0.32, 0.36);
card.requestUpdate();
await card.updateComplete;
await frame();
const overlay = root().querySelector('hp-zigbee-topology-overlay');
await overlay.updateComplete;
const marker = root().querySelector(`.dev[data-id="${markerId}"]`);
hover(marker);
for (const [lqi, expected] of expectedColors) {
overlay._acceptRuntime(snapshot(lqi));
await overlay.updateComplete;
const cores = [...overlay.shadowRoot.querySelectorAll('.link-core,.parent-route')];
const arrows = [...overlay.shadowRoot.querySelectorAll('.route-arrow')];
const casings = [...overlay.shadowRoot.querySelectorAll('.link-casing')];
const key = `${kind}_${zoomName}_${lqi ?? 'unknown'}`;
out[`${key}_requestedZoom`] = Math.abs(card._zoom - zoom) < 0.000001
&& Math.abs(overlay.zoom - zoom) < 0.000001;
out[`${key}_lineAndArrowColor`] = cores.length > 0 && arrows.length === cores.length
&& cores.every((line) => getComputedStyle(line).stroke === expected)
&& arrows.every((arrow) => getComputedStyle(arrow).fill === expected);
out[`${key}_solid`] = [...cores, ...arrows, ...casings]
.every((node) => getComputedStyle(node).strokeDasharray === 'none');
out[`${key}_outline`] = lqi === null
? casings.length === cores.length
&& casings.every((line) => getComputedStyle(line).stroke === 'rgb(0, 0, 0)'
&& Math.abs(Number.parseFloat(getComputedStyle(line).strokeWidth) - (2 + 2 * zoom)) < 0.001
&& getComputedStyle(line).vectorEffect === 'non-scaling-stroke')
&& cores.every((line) => Number.parseFloat(getComputedStyle(line).strokeWidth) === 2
&& getComputedStyle(line).vectorEffect === 'non-scaling-stroke')
&& arrows.every((arrow) => getComputedStyle(arrow).stroke === 'rgb(0, 0, 0)'
&& Math.abs(Number.parseFloat(getComputedStyle(arrow).strokeWidth) - 2 * zoom) < 0.001
&& getComputedStyle(arrow).vectorEffect === 'non-scaling-stroke'
&& getComputedStyle(arrow).paintOrder.startsWith('stroke'))
: casings.length === 0 && arrows.every((arrow) => getComputedStyle(arrow).stroke === 'none');
if (!out[`${key}_outline`]) diagnostics.push({ key, zoom, cardZoom: card._zoom, overlayZoom: overlay.zoom,
cores: cores.map((line) => ({ width: getComputedStyle(line).strokeWidth, effect: getComputedStyle(line).vectorEffect })),
casings: casings.map((line) => ({ width: getComputedStyle(line).strokeWidth, color: getComputedStyle(line).stroke,
effect: getComputedStyle(line).vectorEffect })),
arrows: arrows.map((arrow) => ({ width: getComputedStyle(arrow).strokeWidth, color: getComputedStyle(arrow).stroke,
effect: getComputedStyle(arrow).vectorEffect, order: getComputedStyle(arrow).paintOrder })),
});
}
}
}
card._commitSpace('f1', true);
card.requestUpdate();
await card.updateComplete;
await frame();
const overlay = root().querySelector('hp-zigbee-topology-overlay');
await overlay.updateComplete;
const knownSource = root().querySelector('.dev[data-id="d_light1"]');
hover(knownSource);
const stale = snapshot(null);
stale.topologies = stale.topologies.map((topology) => ({ ...topology,
obtainedAt: Date.now() - 6 * 60 * 1000,
warnings: [...topology.warnings, { code: 'provider_scan_failure' }],
}));
overlay._acceptRuntime(stale);
await overlay.updateComplete;
const staleStatus = overlay.shadowRoot.querySelector('.route-status');
out.stalePartialRetainsKnownRoute = staleStatus?.getAttribute('data-outgoing') === 'known'
&& /stale data/i.test(staleStatus.textContent) && /incomplete data/i.test(staleStatus.textContent)
&& !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]')
&& [...overlay.shadowRoot.querySelectorAll('line,polygon')]
.every((node) => getComputedStyle(node).strokeDasharray === 'none');
const failedRefresh = snapshot(null);
failedRefresh.states = { ...failedRefresh.states, zha: {
phase: 'error', error: 'provider', obtainedAt: failedRefresh.topologies[0].obtainedAt, stale: true,
} };
overlay._acceptRuntime(failedRefresh);
await overlay.updateComplete;
out.failedRefreshMarksRetainedRouteStale = /stale data/i.test(
overlay.shadowRoot.querySelector('.route-status')?.textContent || '',
) && !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]');
const expiring = snapshot(null);
expiring.topologies = expiring.topologies.map((topology) => ({
...topology, obtainedAt: Date.now() - 5 * 60 * 1000 + 300,
}));
const fetchCount = window.__zigbeeSmokeFetchCount();
overlay._acceptRuntime(expiring);
await overlay.updateComplete;
const initiallyFresh = !/stale data/i.test(overlay.shadowRoot.querySelector('.route-status')?.textContent || '');
await wait(() => /stale data/i.test(overlay.shadowRoot.querySelector('.route-status')?.textContent || ''));
out.ageTimerUpdatesWithoutRefetch = initiallyFresh && window.__zigbeeSmokeFetchCount() === fetchCount
&& !!overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"]');
overlay._acceptRuntime(window.__zigbeeSmokeBaseline);
await overlay.updateComplete;
if (theme === 'dark') {
const originalHass = overlay.hass;
overlay.hass = { ...overlay.hass, language: 'ru', locale: { ...overlay.hass.locale, language: 'ru' } };
await overlay.updateComplete;
hover(root().querySelector('.dev[data-id="d_temp"]'));
await wait(() => overlay.shadowRoot.querySelector('[data-kind="unplaced-device"]')?.textContent
.includes('Устройства нет на плане'), 'localized missing target');
const named = overlay.shadowRoot.querySelector('[data-kind="unplaced-device"]');
out.localizedTargetName = named.textContent.trim() === 'Устройства нет на плане (Hallway <router> & "target")'
&& !named.querySelector('router,img,script');
hover(root().querySelector('.dev[data-id="d_kettle"]'));
await wait(() => overlay.shadowRoot.querySelector('.route-status')?.textContent.includes('Нет данных о маршруте'));
out.localizedUnknownRoute = !overlay.shadowRoot.querySelector('line,polygon');
overlay.hass = originalHass;
await overlay.updateComplete;
}
return { checks: out, diagnostics };
}, { theme });
if (visual.diagnostics.length) console.log('Topology visual diagnostics:', JSON.stringify(visual.diagnostics));
for (const [name, value] of Object.entries(visual.checks)) result[`visual_${theme}_${name}`] = value;
}
for (const [aspect, viewport] of Object.entries({
wide: { width: 1100, height: 500 },
tall: { width: 480, height: 900 },
@@ -476,8 +729,10 @@ for (const [aspect, viewport] of Object.entries({
}
}
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await page.setViewportSize({ width: 1100, height: 620 });
const rasterProbe = await page.evaluate(async () => {
const rasterProbe = await page.evaluate(async ({ theme }) => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
const wait = (predicate, timeout = 5000) => new Promise((resolve, reject) => {
@@ -490,7 +745,9 @@ const rasterProbe = await page.evaluate(async () => {
tick();
});
document.querySelector('#host').style.width = '1060px';
card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true } };
card.hass = { ...card.hass, user: { ...card.hass.user, is_admin: true },
themes: { ...(card.hass.themes || {}), darkMode: theme === 'dark' } };
document.documentElement.style.colorScheme = theme;
card._setMode('view');
card._commitSpace('f1', true);
card._zoom = 1;
@@ -516,6 +773,7 @@ const rasterProbe = await page.evaluate(async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const renderedLabel = root.querySelector('.roomlabel');
root.querySelectorAll('[data-hp="zigbee-topology-room-label-raster-control"]').forEach((node) => node.remove());
label = renderedLabel?.cloneNode(true) || document.createElement('div');
label.className = 'roomlabel';
label.dataset.hp = 'zigbee-topology-room-label-raster-control';
@@ -534,7 +792,10 @@ const rasterProbe = await page.evaluate(async () => {
await overlay.updateComplete;
const svg = overlay.shadowRoot.querySelector('svg');
const line = overlay.shadowRoot.querySelector(
'[data-hp="zigbee-topology-line"][stroke-dasharray="5 5"]',
'[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]',
);
const arrow = overlay.shadowRoot.querySelector(
'[data-hp="zigbee-topology-arrow"][data-direction="toward-neighbor"]',
);
const matrix = svg.getScreenCTM();
const start = new DOMPoint(Number(line.getAttribute('x1')), Number(line.getAttribute('y1')))
@@ -546,6 +807,11 @@ const rasterProbe = await page.evaluate(async () => {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
};
const labelCentre = label ? centre(label) : null;
const arrowPoints = arrow.getAttribute('points').trim().split(/\s+/).map((pair) => {
const [x, y] = pair.split(',').map(Number);
const point = new DOMPoint(x, y).matrixTransform(matrix);
return { x: point.x, y: point.y };
});
return {
dpr: devicePixelRatio,
line: { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y } },
@@ -554,9 +820,11 @@ const rasterProbe = await page.evaluate(async () => {
unrelated: centre(unrelated),
label: labelCentre ? { x: labelCentre.x, y: start.y } : null,
labelContainsLine: !labelCentre || Math.abs(labelCentre.y - start.y) <= 14,
arrowPoints,
};
});
const activeRaster = await page.screenshot({ animations: 'disabled' });
}, { theme });
const activeRaster = await page.screenshot({ animations: 'disabled',
path: fileURLToPath(new URL(`known-route-missing-lqi-${theme}.png`, visualArtifacts)) });
await page.evaluate(() => {
const card = window.__card;
(card.shadowRoot || card.renderRoot).querySelector('hp-zigbee-topology-overlay').style.visibility = 'hidden';
@@ -580,7 +848,7 @@ const rasterEvidence = await page.evaluate(async ({ active, baseline, probe }) =
const b = await decode(baseline);
if (a.width !== b.width || a.height !== b.height) return {
unrelatedAbove: false, labelAbove: false, endpointsAbove: false,
casingInk: false, transparentGaps: false,
casingInk: false, continuousCore: false, arrowBlackOutline: false,
};
const changed = (x, y, threshold = 24) => {
const px = Math.max(0, Math.min(a.width - 1, Math.round(x * probe.dpr)));
@@ -605,35 +873,168 @@ const rasterEvidence = await page.evaluate(async ({ active, baseline, probe }) =
const length = Math.hypot(dx, dy);
const ux = dx / length; const uy = dy / length;
const nx = -uy; const ny = ux;
let coreAndCasing = 0; let transparentGap = 0;
for (let distance = 35; distance < Math.min(length - 35, length * 0.42); distance += 1) {
let coreAndCasing = 0; let paintedCore = 0; let samples = 0;
for (let distance = Math.max(35, length * 0.18); distance < Math.min(length - 35, length * 0.38); distance += 1) {
const x = probe.line.start.x + ux * distance;
const y = probe.line.start.y + uy * distance;
const core = changed(x, y);
const edge = changed(x + nx * 1.75, y + ny * 1.75)
|| changed(x - nx * 1.75, y - ny * 1.75);
const quietCore = !changed(x, y, 60);
const quietEdge = !changed(x + nx * 1.75, y + ny * 1.75, 60)
&& !changed(x - nx * 1.75, y - ny * 1.75, 60);
samples++;
if (core) paintedCore++;
if (core && edge) coreAndCasing++;
if (quietCore && quietEdge) transparentGap++;
}
// Sample just outside both sloping polygon sides. A fill-only triangle cannot
// supply dark pixels beyond its geometry; the black 1px outer outline can.
const darkPixel = (x, y) => {
const px = Math.max(0, Math.min(a.width - 1, Math.floor(x * probe.dpr)));
const py = Math.max(0, Math.min(a.height - 1, Math.floor(y * probe.dpr)));
const i = (py * a.width + px) * 4;
return Math.max(a.data[i], a.data[i + 1], a.data[i + 2]) < 85;
};
const arrowCentre = probe.arrowPoints.reduce((sum, point) => ({
x: sum.x + point.x / 3, y: sum.y + point.y / 3,
}), { x: 0, y: 0 });
let blackSides = 0;
for (const side of [1, 2]) {
const tip = probe.arrowPoints[0]; const corner = probe.arrowPoints[side];
const dx = corner.x - tip.x; const dy = corner.y - tip.y;
const sideLength = Math.hypot(dx, dy);
const mid = { x: (tip.x + corner.x) / 2, y: (tip.y + corner.y) / 2 };
let nx = -dy / sideLength; let ny = dx / sideLength;
if ((arrowCentre.x - mid.x) * nx + (arrowCentre.y - mid.y) * ny > 0) { nx = -nx; ny = -ny; }
let ink = 0;
for (const t of [0.25, 0.4, 0.55, 0.7]) {
const x = tip.x + dx * t + nx * 0.5;
const y = tip.y + dy * t + ny * 0.5;
if (darkPixel(x, y) && changed(x, y)) ink++;
}
if (ink >= 2) blackSides++;
}
return {
unrelatedAbove: unrelatedInk >= 3,
labelAbove: labelInk >= 2,
endpointsAbove: endpointInk <= 2,
casingInk: coreAndCasing >= 6,
transparentGaps: transparentGap >= 1,
continuousCore: samples >= 12 && paintedCore / samples >= 0.98,
arrowBlackOutline: blackSides === 2,
};
}, {
active: activeRaster.toString('base64'),
baseline: baselineRaster.toString('base64'),
probe: rasterProbe,
});
result.rasterLabelContainsLine = rasterProbe.labelContainsLine;
result[`raster_${theme}_labelContainsLine`] = rasterProbe.labelContainsLine;
for (const [name, value] of Object.entries(rasterEvidence)) {
result[`raster_${name}`] = value;
result[`raster_${theme}_${name}`] = value;
}
}
// Owner contract: the outline grows with the plan, including the transient
// camera transform on the common device layer. Use actual screen pixels here:
// computed SVG stroke widths alone cannot distinguish ancestor CSS scaling.
await page.evaluate(async () => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
const layer = root.querySelector('.devlayer');
const source = root.querySelector('.dev[data-id="d_light1"]');
const target = root.querySelector('.dev[data-id="d_lamp"]');
window.__zigbeeOutlineProbeStyles = [layer, source, target].map((node) => [node, node.getAttribute('style')]);
// The camera owns the layer's inline transform and can clear it on a queued
// commit. Keep this synthetic live-transform fixture stable across captures.
layer.setAttribute('data-hp-zigbee-outline-probe', '');
const style = document.createElement('style');
root.append(style);
window.__zigbeeOutlineProbeStyle = style;
source.style.left = '10%'; source.style.top = '30%';
target.style.left = '42%'; target.style.top = '30%';
const overlay = root.querySelector('hp-zigbee-topology-overlay');
overlay.viewKey = { outlineGrowth: true };
source.dispatchEvent(new PointerEvent('pointerover', {
pointerType: 'mouse', bubbles: true, composed: true, clientX: 200, clientY: 200,
}));
await overlay.updateComplete;
});
const outlinePixelMass = [];
for (const scale of [0.5, 1, 2]) {
const probe = await page.evaluate(async ({ scale }) => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
window.__zigbeeOutlineProbeStyle.textContent = `[data-hp-zigbee-outline-probe] {
transform: scale(${scale}) !important; transform-origin: 0 0 !important; background: #fff !important;
}
/* #802: isolate route ink. At scale=.5 the ordinary pointer tooltip can
cover the target arrow; its dark background is not outline coverage.
Real tooltip/badge coexistence is witnessed in smoke_zigbee_tooltip_layout. */
[data-hp-live-tip] { display: none !important; }`;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const overlay = root.querySelector('hp-zigbee-topology-overlay');
const svg = overlay.shadowRoot.querySelector('svg');
const line = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]');
const arrow = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-arrow"][data-direction="toward-neighbor"]');
const point = new DOMPoint(
Number(line.getAttribute('x1')) * 0.55 + Number(line.getAttribute('x2')) * 0.45,
Number(line.getAttribute('y1')),
).matrixTransform(svg.getScreenCTM());
const rect = arrow.getBoundingClientRect();
return { point: { x: point.x, y: point.y }, scale, dpr: devicePixelRatio,
arrow: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
}, { scale });
const pixels = await page.screenshot({ animations: 'disabled',
path: fileURLToPath(new URL(`outline-live-scale-${scale}.png`, visualArtifacts)) });
outlinePixelMass.push(await page.evaluate(async ({ image, probe }) => {
const bitmap = await createImageBitmap(await (await fetch(`data:image/png;base64,${image}`)).blob());
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const pixels = ctx.getImageData(0, 0, bitmap.width, bitmap.height).data;
// White fixture backdrop and grey core contribute zero. Black casing
// contributes one per pixel; antialiased coverage contributes a fraction.
const black = (x, y) => {
const i = (y * bitmap.width + x) * 4;
return Math.max(0, (165 - Math.max(pixels[i], pixels[i + 1], pixels[i + 2])) / 165);
};
const x = Math.round(probe.point.x * probe.dpr); const y = Math.round(probe.point.y * probe.dpr);
let line = 0;
const radius = Math.ceil(4 * probe.scale * probe.dpr);
for (let dx = -3; dx <= 3; dx++) for (let dy = -radius; dy <= radius; dy++) {
line += black(x + dx, y + dy) / (7 * probe.dpr);
}
const pad = 2 * probe.scale;
const bounds = {
left: Math.floor((probe.arrow.x - pad) * probe.dpr),
top: Math.floor((probe.arrow.y - pad) * probe.dpr),
right: Math.ceil((probe.arrow.x + probe.arrow.width + pad) * probe.dpr),
bottom: Math.ceil((probe.arrow.y + probe.arrow.height + pad) * probe.dpr),
};
let arrow = 0;
const inFrame = bounds.left >= 0 && bounds.top >= 0
&& bounds.right < bitmap.width && bounds.bottom < bitmap.height;
if (inFrame) for (let py = bounds.top; py <= bounds.bottom; py++) for (let px = bounds.left; px <= bounds.right; px++) {
arrow += black(px, py) / (probe.dpr * probe.dpr);
}
return { scale: probe.scale, line, arrow, inFrame };
}, { image: pixels.toString('base64'), probe }));
}
result.livePlanScaleGrowsLineOutline = outlinePixelMass.every((sample) => sample.inFrame && sample.line > 0)
&& outlinePixelMass[1].line > outlinePixelMass[0].line * 1.4
&& outlinePixelMass[2].line > outlinePixelMass[1].line * 1.4;
result.livePlanScaleGrowsArrowOutline = outlinePixelMass.every((sample) => sample.arrow > 0)
&& outlinePixelMass[1].arrow > outlinePixelMass[0].arrow * 2.5
&& outlinePixelMass[2].arrow > outlinePixelMass[1].arrow * 2.5;
console.log('Topology live outline pixel mass:', JSON.stringify(outlinePixelMass));
await page.evaluate(async () => {
window.__zigbeeOutlineProbeStyle.remove();
for (const [node, style] of window.__zigbeeOutlineProbeStyles) {
node.removeAttribute('data-hp-zigbee-outline-probe');
if (style === null) node.removeAttribute('style');
else node.setAttribute('style', style);
}
const card = window.__card;
const overlay = (card.shadowRoot || card.renderRoot).querySelector('hp-zigbee-topology-overlay');
overlay.viewKey = { outlineGrowth: false };
await overlay.updateComplete;
});
const realHoverPoint = await page.evaluate(() => {
const card = window.__card;
@@ -658,7 +1059,7 @@ result.realPointerEndpointWins = await page.evaluate(async () => {
return source.matches(':hover')
&& source.hasAttribute('data-hp-zigbee-topology-endpoint')
&& Number.parseInt(getComputedStyle(source).zIndex, 10)
> Number.parseInt(getComputedStyle(overlay).zIndex, 10);
> Number.parseInt(getComputedStyle(overlay.shadowRoot.querySelector('svg')).zIndex, 10);
});
await page.emulateMedia({ forcedColors: 'active' });
@@ -668,10 +1069,10 @@ result.forcedColorsPreserved = await page.evaluate(() => {
const overlay = root.querySelector('hp-zigbee-topology-overlay');
const casing = overlay.shadowRoot.querySelector('[data-hp="zigbee-topology-line-casing"]');
const core = overlay.shadowRoot.querySelector(
'[data-hp="zigbee-topology-line"][stroke-dasharray="5 5"]',
'[data-hp="zigbee-topology-line"][data-direction="toward-neighbor"]',
);
return !!casing && !!core && getComputedStyle(casing).stroke === getComputedStyle(core).stroke
&& getComputedStyle(casing).stroke !== 'rgb(46, 46, 46)';
&& getComputedStyle(casing).stroke !== 'rgb(0, 0, 0)';
});
await page.emulateMedia({ forcedColors: 'none' });
+222
View File
@@ -0,0 +1,222 @@
// #800: production settings + WS transport, with a server-owned fixture outside
// the page. A reload really loses all browser state; backend time is controlled,
// not a 15-minute wall-clock sleep. HA/MQTT lifecycle is covered by HA tests.
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
const output = {};
const record = (name, actual) => { output[name] = actual; check(name, actual); };
const artifacts = new URL('../artifacts/zigbee-topology-800/', import.meta.url);
mkdirSync(artifacts, { recursive: true });
const server = { available: true, session: 'server-800', revision: 0, sequence: 0, publishes: 0, cancels: 0, provider: null };
const state = () => ({ kind: 'state', session_id: server.session, revision: server.revision,
provider: structuredClone(server.provider) });
const initial = () => [{ kind: 'reset', session_id: server.session, revision: server.revision,
topics: server.provider ? [server.provider.topic] : [] }, ...(server.provider ? [state()] : [])];
await page.exposeFunction('__scanRpc', async (message) => {
if (message.type === 'houseplan/config/get') return { available: server.available };
if (message.type === 'houseplan/zigbee/subscribe') return initial();
if (message.type === 'houseplan/zigbee/start') {
if (server.provider?.phase !== 'loading') {
server.publishes++;
const last = server.provider;
server.provider = { topic: 'zigbee2mqtt', job_id: `job-${++server.sequence}`,
phase: 'loading', stage: 'waiting', started_at: Date.now(), elapsed_ms: 0,
cancel_after_ms: 600000, ...(last?.result ? {
result: last.result, obtained_at: last.obtained_at, stale: true,
} : {}) };
server.revision++;
}
return state();
}
if (message.type === 'houseplan/zigbee/cancel') {
if (message.job_id !== server.provider?.job_id) throw new Error('conflict');
if (server.provider.elapsed_ms < 600000) throw new Error('not_ready');
if (server.provider.phase === 'loading') {
server.cancels++;
server.provider.phase = 'cancelled'; server.provider.stale = true;
server.revision++;
}
return state();
}
throw new Error(`unexpected fixture RPC: ${message.type}`);
});
const install = async (embedded = false) => page.evaluate(async (isEmbedded) => {
const card = window.__card;
await card._ensureEditorRuntime();
card._openSettingsDialog(); await card.updateComplete;
(card.shadowRoot || card.renderRoot)
.querySelector('hp-dialog[data-kind="settings"] [data-hp="dialog-cancel"]').click();
await card.updateComplete;
const original = card.hass;
window.__scanCallbacks = new Set();
window.__scanStats = { subscriptions: 0, active: 0, mqtt: 0 };
const connection = {
...original.connection,
subscribeMessage: async (callback, message) => {
if (message.type !== 'houseplan/zigbee/subscribe') {
if (message.type.startsWith('mqtt/')) window.__scanStats.mqtt++;
return original.connection.subscribeMessage(callback, message);
}
window.__scanStats.subscriptions++; window.__scanStats.active++;
window.__scanCallbacks.add(callback);
for (const event of await window.__scanRpc(message)) callback(event);
let active = true;
return () => {
if (!active) return; active = false;
window.__scanCallbacks.delete(callback); window.__scanStats.active--;
};
},
};
const hass = { ...original, language: 'en', user: { ...original.user, id: 'admin-800', is_admin: true }, connection,
callWS: async (message) => {
if (message.type.startsWith('houseplan/zigbee/')) return window.__scanRpc(message);
if (message.type === 'houseplan/config/get' && !(await window.__scanRpc(message)).available) {
throw { code: 'not_ready' };
}
const result = await original.callWS(message);
if (message.type === 'houseplan/config/get') return {
...result, zigbee_scan_api: 1,
};
return result;
},
callService: async (...args) => {
if (args[0] === 'mqtt') window.__scanStats.mqtt++;
return original.callService(...args);
},
};
window.__scanHass = hass;
const element = document.createElement('hp-zigbee-topology-settings');
element.id = 'scan-settings'; element.hass = hass;
element.value = { enabled: true, z2mBaseTopics: ['zigbee2mqtt'] };
element.savedEnabled = true; element.embedded = isEmbedded;
element.style.cssText = 'position:fixed;inset:0;overflow:auto;padding:20px;box-sizing:border-box;background:#fff;color:#171735;z-index:99999;font:16px sans-serif';
// Reproduce the tokens normally inherited from hp-dialog .hpf-form. The
// demo document itself is dark; changing only background would fake a theme.
for (const [key, value] of Object.entries({
'--primary-text-color': '#171735', '--secondary-text-color': '#666879',
'--card-background-color': '#fff', '--divider-color': '#dce5e7',
'--hpf-surface': '#fff', '--hpf-line': '#dce5e7', '--hpf-muted': '#666879',
'--hpf-accent': '#f09600', '--hpf-canvas': '#f5f5f8',
})) element.style.setProperty(key, value);
document.body.append(element); await element.updateComplete;
}, embedded);
const settings = () => page.locator('#scan-settings');
const refresh = () => settings().getByRole('button', { name: /Update map/ });
const cancel = () => settings().locator('[data-hp="zigbee-scan-cancel"]');
const elapsed = () => settings().locator('[data-hp="zigbee-scan-elapsed"]');
const waitState = async (phase) => page.waitForFunction((expected) =>
document.getElementById('scan-settings')?._snapshot?.states?.['z2m:zigbee2mqtt']?.phase === expected, phase);
const deliver = async () => page.evaluate((event) => {
for (const callback of window.__scanCallbacks) callback(event);
}, state());
try {
await install();
await refresh().click(); await waitState('loading');
record('onePublishAndNoBrowserMqtt', server.publishes === 1
&& await page.evaluate(() => window.__scanStats.mqtt === 0));
record('stageElapsedAndCloseHint', await elapsed().isVisible()
&& await settings().locator('[data-hp="zigbee-scan-stage"]').isVisible()
&& await settings().locator('[data-hp="zigbee-scan-background"]').isVisible());
record('noInventedPercentOrEarlyCancel', !(await settings().innerText()).includes('%')
&& await cancel().count() === 0);
await page.evaluate(async () => {
const element = document.getElementById('scan-settings');
for (let i = 0; i < 4; i++) {
element.hass = { ...element.hass, states: { ...element.hass.states } };
await element.updateComplete;
}
});
record('ordinaryHassUpdatesKeepOneSubscription', await page.evaluate(() =>
window.__scanStats.subscriptions === 1 && window.__scanStats.active === 1));
server.provider.elapsed_ms = 599100; server.revision++; await deliver();
record('599SecondsStillNoCancel', await cancel().count() === 0);
await cancel().waitFor({ state: 'visible', timeout: 4000 });
record('cancelAppearsFromLocalClockWithoutServerTick', server.provider.elapsed_ms === 599100);
// Remove settings and reload the entire document, while fixture server lives.
await page.evaluate(() => document.getElementById('scan-settings').remove());
record('closingReleasesOnlyUi', await page.evaluate(() => window.__scanStats.active === 0)
&& server.provider.phase === 'loading');
server.provider.elapsed_ms = 901000; server.revision++;
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._booting === false);
await install(true); await waitState('loading');
record('newPageRestoresSameJobAfter15Minutes', server.publishes === 1
&& /15:0[1-9]/.test(await elapsed().innerText()));
record('longWaitHasCancelAndHonestHint', await cancel().isVisible()
&& await settings().locator('[data-hp="zigbee-scan-long-wait"]').isVisible()
&& await settings().locator('[data-hp="zigbee-scan-cancel-hint"]').isVisible());
// Narrow layout and real keyboard activation, not a direct private handler.
await page.setViewportSize({ width: 390, height: 844 });
await cancel().scrollIntoViewIfNeeded();
const rect = await cancel().boundingBox();
record('mobileCancelTouchTargetAndNoOverflow', rect?.height >= 44 && rect?.width >= 44
&& await settings().evaluate((element) => element.scrollWidth <= element.clientWidth + 1));
await page.screenshot({ path: fileURLToPath(new URL('narrow-light-wait.png', artifacts)) });
await settings().evaluate((element) => {
element.style.background = '#202126'; element.style.color = '#eee';
element.style.setProperty('--card-background-color', '#202126');
element.style.setProperty('--primary-text-color', '#eee');
element.style.setProperty('--secondary-text-color', '#aaa');
element.style.setProperty('--hpf-surface', '#202126');
element.style.setProperty('--hpf-line', '#555');
element.style.setProperty('--hpf-muted', '#aaa');
element.style.setProperty('--hpf-canvas', '#17181d');
});
await page.screenshot({ path: fileURLToPath(new URL('narrow-dark-wait.png', artifacts)) });
await cancel().focus(); await page.keyboard.press('Enter'); await waitState('cancelled');
record('keyboardCancelsExactJobWithoutRepublish', server.cancels === 1 && server.publishes === 1);
await refresh().click(); await waitState('loading');
record('newExplicitScanOnly', server.publishes === 2 && server.provider.job_id === 'job-2');
server.provider = { ...server.provider, phase: 'ready', elapsed_ms: 960000,
obtained_at: Date.now(), stale: false,
result: { status: 'ok', transaction: 'job-2', data: { type: 'raw', value: {
nodes: [{ ieeeAddr: '00124b0000000001', type: 'Coordinator', networkAddress: 0 }], links: [],
} } },
}; server.revision++;
// Finish with zero observers, then restore the cached result on remount.
await page.evaluate(() => document.getElementById('scan-settings').remove());
await install(); await waitState('ready');
record('resultWhileAbsentRestoresWithoutNewScan', server.publishes === 2
&& await elapsed().count() === 0 && await refresh().isEnabled());
record('receivedMapNormalized', await page.evaluate(() =>
document.getElementById('scan-settings')._snapshot.topologies.length === 1));
await page.setViewportSize({ width: 1000, height: 850 });
await page.screenshot({ path: fileURLToPath(new URL('desktop-ready.png', artifacts)) });
const obsolete = state();
await page.evaluate((event) => {
for (const callback of window.__scanCallbacks) callback(event);
}, { kind: 'closed', session_id: server.session, revision: ++server.revision });
server.session = 'server-800-reloaded'; server.revision = 0; server.provider = null;
await page.waitForFunction(() => !document.getElementById('scan-settings')._snapshot.states['z2m:zigbee2mqtt']);
record('integrationUnloadClearsUiWithoutClosingHaSocket', await refresh().isEnabled()
&& await page.evaluate(() => window.__scanStats.active === 0));
server.available = false;
await refresh().click(); await waitState('error');
record('retryDuringReloadDoesNotStartRadioOrDisableRetry', server.publishes === 2
&& await refresh().isEnabled());
server.available = true;
await refresh().click(); await waitState('loading');
await page.evaluate((event) => {
for (const callback of window.__scanCallbacks) callback(event);
}, obsolete);
record('explicitScanAfterIntegrationReloadReobservesNewSession', server.publishes === 3
&& await page.evaluate(() => window.__scanStats.active === 1
&& document.getElementById('scan-settings')._snapshot.states['z2m:zigbee2mqtt'].jobId === 'job-3'));
await page.evaluate(async () => {
const element = document.getElementById('scan-settings');
element.hass = { ...element.hass, user: { id: 'viewer-800', is_admin: false } };
await element.updateComplete;
});
record('permissionLossDropsObserverAndMap', await page.evaluate(() =>
window.__scanStats.active === 0
&& document.getElementById('scan-settings')._snapshot.topologies.length === 0));
} finally {
await finish(browser, output);
}
File diff suppressed because one or more lines are too long
+313 -285
View File
@@ -1,136 +1,141 @@
{
"schema": 1,
"fingerprint": "ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172",
"fingerprint": "32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 300133,
"initialViewGzipBytes": 302533,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301657,
"initialPanelGzipBytes": 304058,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2328,
"initialPanelOnlyGzipBytes": 2330,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/fr-BJJefRIG.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"houseplan-assets/iso-scene-render-CR7lxY-w.js",
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"houseplan-assets/live-interaction-runtime-C1983oH8.js",
"houseplan-assets/moon-runtime-Ca1mxhJl.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js",
"houseplan-assets/space-editor-EDOQT48H.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/de-Bks1kqD-.js",
"houseplan-assets/editor-C9cO7LMo.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/fr-Cyx-vulq.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"houseplan-assets/hp-zigbee-topology-overlay-GWA8--2F.js",
"houseplan-assets/iso-scene-render-B7_Yb29a.js",
"houseplan-assets/led-strip-editor-cVPwZHfh.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/led-strip-runtime-XyrltA_5.js",
"houseplan-assets/live-interaction-runtime-BPpAmdEl.js",
"houseplan-assets/live-tip-placement-C_m0HUe7.js",
"houseplan-assets/moon-runtime-3jMWVufG.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/pdf-export-k7dc6e6q.js",
"houseplan-assets/space-editor-D57grVhE.js",
"houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js",
"houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js"
],
"lazyGzipBytes": 478817,
"lazyGzipBytes": 486591,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/editor-C9cO7LMo.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js"
],
"lazyEditorGzipBytes": 240843,
"lazyEditorGzipBytes": 244539,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"lazyOnboardingGzipBytes": 28402,
"lazyOnboardingGzipBytes": 28721,
"lazyLocaleFiles": [
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/fr-BJJefRIG.js"
"houseplan-assets/de-Bks1kqD-.js",
"houseplan-assets/fr-Cyx-vulq.js"
],
"lazyLocaleGzipBytes": 53876,
"lazyLocaleGzipBytes": 53875,
"lazyIsometricFiles": [
"houseplan-assets/iso-scene-render-CR7lxY-w.js"
"houseplan-assets/iso-scene-render-B7_Yb29a.js"
],
"lazyIsometricGzipBytes": 12348,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js"
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js"
],
"lazyFurnitureArtGzipBytes": 16936,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js"
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/pdf-export-k7dc6e6q.js"
],
"lazyPdfGzipBytes": 126934,
"lazyMoonFiles": [
"houseplan-assets/moon-runtime-Ca1mxhJl.js"
"houseplan-assets/moon-runtime-3jMWVufG.js"
],
"lazyMoonGzipBytes": 11713,
"lazyMoonGzipBytes": 11714,
"lazyLedFiles": [
"houseplan-assets/led-strip-field-ObbckCkp.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js"
"houseplan-assets/led-strip-field-CIA1-5AA.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/led-strip-runtime-XyrltA_5.js"
],
"lazyLedGzipBytes": 10613,
"lazyLedGzipBytes": 10685,
"lazyLedEditorFiles": [
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/led-strip-editor-cVPwZHfh.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"lazyLedEditorGzipBytes": 10707,
"lazyLedEditorGzipBytes": 10712,
"lazyNamespaceLocaleFiles": [
"houseplan-assets/led-de-yp1GefDC.js",
"houseplan-assets/led-fr-CHODsRJm.js",
"houseplan-assets/led-ru-D2-9Yy6l.js",
"houseplan-assets/settings-de-ZP6F6dIF.js",
"houseplan-assets/settings-fr-doqd_TsB.js",
"houseplan-assets/settings-ru-CQ1W2gik.js",
"houseplan-assets/support-de-haZQ7q8i.js",
"houseplan-assets/support-fr-DnRyZ_Ga.js",
"houseplan-assets/support-ru-B7zzXsuL.js",
"houseplan-assets/tools-de-CYXPPfUH.js",
"houseplan-assets/tools-fr-DvygRB10.js",
"houseplan-assets/tools-ru-2xmu2w_8.js",
"houseplan-assets/topology-de-COda8n9n.js",
"houseplan-assets/topology-fr-BT9yhzay.js",
"houseplan-assets/topology-ru-C-d06yvD.js"
"houseplan-assets/led-de-p2Lf-Tk6.js",
"houseplan-assets/led-fr-CYUJP7Ri.js",
"houseplan-assets/led-ru-CACJZ6TJ.js",
"houseplan-assets/settings-de-p06UIc1i.js",
"houseplan-assets/settings-fr-CcDGizn4.js",
"houseplan-assets/settings-ru-DLUtY0xm.js",
"houseplan-assets/support-de-B2N_YYL8.js",
"houseplan-assets/support-fr-D6zFUxMl.js",
"houseplan-assets/support-ru-D6-IM08K.js",
"houseplan-assets/tools-de-l34ZmKd7.js",
"houseplan-assets/tools-fr-DfUkWkg4.js",
"houseplan-assets/tools-ru-C-24FEHa.js",
"houseplan-assets/topology-de-DsW3wYBw.js",
"houseplan-assets/topology-fr-pMOG69_N.js",
"houseplan-assets/topology-ru-5D8r7xvs.js"
],
"lazyNamespaceLocaleGzipBytes": 30082,
"lazyNamespaceLocaleGzipBytes": 31951,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DHF8oaNH.js",
"sha256": "7262c67e3f88f44ee6c6a0973073d1f7c3c9f177ff8254758a21c4e3867c5b06",
"rawBytes": 55329,
"gzipBytes": 18027,
"path": "houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"sha256": "31a9fe5cf5d17e26359207c3585deeefdc0ec01aa9bdb70679f070093d0c9082",
"rawBytes": 54564,
"gzipBytes": 17740,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": [
"houseplan-assets/settings-de-ZP6F6dIF.js",
"houseplan-assets/settings-fr-doqd_TsB.js",
"houseplan-assets/settings-ru-CQ1W2gik.js"
"houseplan-assets/settings-de-p06UIc1i.js",
"houseplan-assets/settings-fr-CcDGizn4.js",
"houseplan-assets/settings-ru-DLUtY0xm.js"
]
},
{
"path": "houseplan-assets/de-C4xCvmYr.js",
"sha256": "e3778d3139c05e075281f96460e15a6aaafe8dd9200306b0a3799a1bc5710828",
"path": "houseplan-assets/de-Bks1kqD-.js",
"sha256": "63bbdd6bd7bb7d4c2fd8576aae125dac5e4f9e0339decca8e5854475c1ef29cd",
"rawBytes": 89873,
"gzipBytes": 27169,
"isEntry": false,
@@ -138,19 +143,28 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CFo6BxSe.js",
"sha256": "81d90441a06989a01a09c19e8c73567b80cd4d6ba52e4ea4796e096e1e5b7aa4",
"path": "houseplan-assets/editor-C9cO7LMo.js",
"sha256": "57bd14e0dc4dd07911e802edee30dcf6ccd92f390888db10a11031b6566d20dd",
"rawBytes": 3833,
"gzipBytes": 1584,
"gzipBytes": 1586,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-B6HsmMPA.js",
"sha256": "d18d3eb8bf1dd9dda6d6fdeed84edc16d3945e8a7e23b1d88e5f2edcef0a0af6",
"path": "houseplan-assets/floating-surface-Cze1Izpe.js",
"sha256": "43281844d891390fa37657261a593dae0eebc03e486718401c4c1c4b3bd71546",
"rawBytes": 1099,
"gzipBytes": 579,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-CNHrqe_D.js",
"sha256": "dcaacdb8cdd47c74ed028b3566a4f1c8eb87e4593671881aa6fc64093941a292",
"rawBytes": 25605,
"gzipBytes": 5059,
"isEntry": false,
@@ -158,17 +172,17 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BJJefRIG.js",
"sha256": "4932ff13f6fdc339e9d67123105f9bd97f051395a3c3310240fc07b119d87024",
"path": "houseplan-assets/fr-Cyx-vulq.js",
"sha256": "1f091fc1d4144f348569d20cbe2467798399ff9d594fd0d9d96ef5a0cac3d4b5",
"rawBytes": 92266,
"gzipBytes": 26707,
"gzipBytes": 26706,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"sha256": "10a0b09e4b1513bd02ffba047d6931add1d1106c453af03f6b3c778b5bb55e15",
"path": "houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"sha256": "08c128df8bfd3faefe807e044343d63403eed6b494766a8c651102d85343b13d",
"rawBytes": 52035,
"gzipBytes": 16936,
"isEntry": false,
@@ -176,297 +190,311 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-BOdiRDFR.js",
"sha256": "37c784960d53789a66988a4e4ab8a8afe88aeddb80c5f244f3d453ef86c3ba8c",
"rawBytes": 1056405,
"gzipBytes": 299329,
"path": "houseplan-assets/houseplan-card-DTtVq_VD.js",
"sha256": "95aa41b2fe6704f6f92864bbfcac0058a90cc16276dd626f46c592e2125f0749",
"rawBytes": 1063661,
"gzipBytes": 301728,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-C4xCvmYr.js",
"houseplan-assets/editor-CFo6BxSe.js",
"houseplan-assets/fr-BJJefRIG.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"houseplan-assets/iso-scene-render-CR7lxY-w.js",
"houseplan-assets/led-strip-editor-kcNyoue_.js",
"houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"houseplan-assets/live-interaction-runtime-C1983oH8.js",
"houseplan-assets/moon-runtime-Ca1mxhJl.js",
"houseplan-assets/pdf-export-C-Y4BSVu.js",
"houseplan-assets/space-editor-EDOQT48H.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js"
"houseplan-assets/de-Bks1kqD-.js",
"houseplan-assets/editor-C9cO7LMo.js",
"houseplan-assets/fr-Cyx-vulq.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"houseplan-assets/hp-zigbee-topology-overlay-GWA8--2F.js",
"houseplan-assets/iso-scene-render-B7_Yb29a.js",
"houseplan-assets/led-strip-editor-cVPwZHfh.js",
"houseplan-assets/led-strip-runtime-XyrltA_5.js",
"houseplan-assets/live-interaction-runtime-BPpAmdEl.js",
"houseplan-assets/moon-runtime-3jMWVufG.js",
"houseplan-assets/pdf-export-k7dc6e6q.js",
"houseplan-assets/space-editor-D57grVhE.js",
"houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-CqreRULk.js",
"sha256": "772b538d5b87265a0e88117f01034a3ccadd5f4e928ced77cb99254ab74e299a",
"rawBytes": 695353,
"gzipBytes": 194530,
"path": "houseplan-assets/houseplan-editor-runtime-BRI7PBKq.js",
"sha256": "3924aa8128a3d2c43094d78b20ae93bf989267228d7c9b69574049facd3cce65",
"rawBytes": 700823,
"gzipBytes": 196271,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js"
],
"dynamicImports": [
"houseplan-assets/support-de-haZQ7q8i.js",
"houseplan-assets/support-fr-DnRyZ_Ga.js",
"houseplan-assets/support-ru-B7zzXsuL.js",
"houseplan-assets/tools-de-CYXPPfUH.js",
"houseplan-assets/tools-fr-DvygRB10.js",
"houseplan-assets/tools-ru-2xmu2w_8.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
"houseplan-assets/support-de-B2N_YYL8.js",
"houseplan-assets/support-fr-D6zFUxMl.js",
"houseplan-assets/support-ru-D6-IM08K.js",
"houseplan-assets/tools-de-l34ZmKd7.js",
"houseplan-assets/tools-fr-DfUkWkg4.js",
"houseplan-assets/tools-ru-C-24FEHa.js",
"houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-DOfjrx0M.js",
"sha256": "242ae570916ec2a000a8a0079a948826faeb45c0968cc1006e9670f9d3c4088b",
"rawBytes": 15886,
"gzipBytes": 4907,
"path": "houseplan-assets/houseplan-onboarding-runtime-CXsHvfN3.js",
"sha256": "95fe9b90f66e33e5d76b34baa5a305a0ea858f9b65c3a9f1854ab1c6781c173e",
"rawBytes": 15930,
"gzipBytes": 4930,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DHF8oaNH.js",
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/backdrop-pick-DBl1SMsQ.js",
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-BAaZNyAl.js",
"sha256": "8cb7c3f010cf7ef792a676bfd994c4498f31222dd12ded79e3e2f37dfae4f52e",
"rawBytes": 10958,
"gzipBytes": 3768,
"path": "houseplan-assets/hp-zigbee-topology-overlay-GWA8--2F.js",
"sha256": "8dddc2789d4d3c64feecc67c6057df047ba3ef7e7b9b29cc027e07cd4878f5f7",
"rawBytes": 16748,
"gzipBytes": 5535,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js",
"houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/live-tip-placement-C_m0HUe7.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js",
"houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-CR7lxY-w.js",
"sha256": "1986dd43c8b76580f893c1629d494f7747f02aa8d544baff20d56fe10b470bb5",
"rawBytes": 36700,
"path": "houseplan-assets/iso-scene-render-B7_Yb29a.js",
"sha256": "25781538401f3c5a869bf1209fbecb055753d16212d15b6c840f75b54a8a2523",
"rawBytes": 36701,
"gzipBytes": 12348,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-de-yp1GefDC.js",
"sha256": "eb9ce8bb536ac20e11cf4fe925d68f28b1ba8a5ea02c61abf9c3bb939de0a4c4",
"path": "houseplan-assets/led-de-p2Lf-Tk6.js",
"sha256": "dee1c52b983cdfb1b3abcbb0c5afdf37b76c71196a8beeb5f64b58fa12663a76",
"rawBytes": 1907,
"gzipBytes": 921,
"gzipBytes": 922,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-fr-CHODsRJm.js",
"sha256": "adb428bd63b6157ef0a5d749badf94e95204c9c3b83e5f3022accf65d304435e",
"path": "houseplan-assets/led-fr-CYUJP7Ri.js",
"sha256": "1d6b597d0a3870f0fd39edd5ab97ffbe061c00a788fb2ffd16b0369022a47dd4",
"rawBytes": 1886,
"gzipBytes": 906,
"gzipBytes": 907,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-ru-D2-9Yy6l.js",
"sha256": "bc5575139b30d6e93e07673f47865c332e71011796929a52043645a904d6df31",
"path": "houseplan-assets/led-ru-CACJZ6TJ.js",
"sha256": "949021fdbc9f4beb50386d6dc73a3fd9b815e027709b109a37b10fd1ca33b22f",
"rawBytes": 2525,
"gzipBytes": 1106,
"gzipBytes": 1107,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-editor-kcNyoue_.js",
"sha256": "dac9fdcaae6444caa1773a07384155cc92b916d2ebf3498e1dfc1b3525a84753",
"path": "houseplan-assets/led-strip-editor-cVPwZHfh.js",
"sha256": "f3fbd37e65394b6848c5313e60806af58890da2e44d184bd980ccc24c2730a55",
"rawBytes": 21704,
"gzipBytes": 7381,
"gzipBytes": 7383,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": [
"houseplan-assets/led-de-yp1GefDC.js",
"houseplan-assets/led-fr-CHODsRJm.js",
"houseplan-assets/led-ru-D2-9Yy6l.js"
"houseplan-assets/led-de-p2Lf-Tk6.js",
"houseplan-assets/led-fr-CYUJP7Ri.js",
"houseplan-assets/led-ru-CACJZ6TJ.js"
]
},
{
"path": "houseplan-assets/led-strip-field-ObbckCkp.js",
"sha256": "b85d8de01906a7e70f28baff534cf1a211216c99b72005b6d77a0d19cbb5ce8f",
"rawBytes": 7920,
"gzipBytes": 3321,
"path": "houseplan-assets/led-strip-field-CIA1-5AA.js",
"sha256": "32f9670f0c5ced199d42c4945b72d341d916fefb9a4567bb501c69b3a020d8e7",
"rawBytes": 8066,
"gzipBytes": 3381,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-geometry-DaLifxqY.js",
"sha256": "2dfbe1509da809008c9a525a7ed02226c026aaa5c0dafcb9f3dc1c44151ed63e",
"path": "houseplan-assets/led-strip-geometry-C0tm4EW4.js",
"sha256": "668708a49b6c22a165aa02aed5b37d0ec26e0bd800b912c5c4a1b9bdc257bf58",
"rawBytes": 7055,
"gzipBytes": 2917,
"gzipBytes": 2916,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-runtime-Bx6qpdWk.js",
"sha256": "6fd2d3578e550dfbe8c38308923e21f9fddeb469f64e8fcf295dc292949ddf32",
"rawBytes": 11179,
"gzipBytes": 4375,
"path": "houseplan-assets/led-strip-runtime-XyrltA_5.js",
"sha256": "da89974ff9af4ecbd58ab09d01e739a2791034d0193a1a94cc5b4e27fad7cc5d",
"rawBytes": 11248,
"gzipBytes": 4388,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/led-strip-geometry-DaLifxqY.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/led-strip-geometry-C0tm4EW4.js"
],
"dynamicImports": [
"houseplan-assets/led-strip-field-ObbckCkp.js"
"houseplan-assets/led-strip-field-CIA1-5AA.js"
]
},
{
"path": "houseplan-assets/live-interaction-runtime-C1983oH8.js",
"sha256": "04be552ac07f99c03713f162ce0b6129c8491dfd89da9cd8e07cd71866fe309c",
"rawBytes": 8370,
"gzipBytes": 3281,
"path": "houseplan-assets/live-interaction-runtime-BPpAmdEl.js",
"sha256": "c3b063a58be71532e681d1a74b4db516679ccce8dd14cb2e2d3763c3a3f4df35",
"rawBytes": 9694,
"gzipBytes": 3782,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/floating-surface-Cze1Izpe.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/live-tip-placement-C_m0HUe7.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-Ca1mxhJl.js",
"sha256": "97b9f55cb8e2915acb5eaa260f21e2103bc08f0e654c1c0b03c3000604319730",
"path": "houseplan-assets/live-tip-placement-C_m0HUe7.js",
"sha256": "ec891435f961d5d2b5aa3a7780bf29d7dd6eaa37e0781c3157bc6324b672d7e9",
"rawBytes": 1191,
"gzipBytes": 684,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-3jMWVufG.js",
"sha256": "88d30102ad1ba68b433e45586cb43ff7bfe27bcb2fd46039e6ef663617c47a4d",
"rawBytes": 30292,
"gzipBytes": 11713,
"gzipBytes": 11714,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/namespace-language-D86tJUeD.js",
"sha256": "012aa30cbf8bbbb2eff6b7ef05970ff90fb2b4b576910578e3527b75e68ffda6",
"path": "houseplan-assets/namespace-language-CPu79Qai.js",
"sha256": "c7b1e603fb5ad465a2fc022d9b6b764b447752816c79d03e58a3578433f458c8",
"rawBytes": 675,
"gzipBytes": 409,
"gzipBytes": 413,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-C-Y4BSVu.js",
"sha256": "fba700bc14c66c695f1b3de3e82abdf5fb6b14b70773c3d0f0e185dcf2989491",
"path": "houseplan-assets/pdf-export-k7dc6e6q.js",
"sha256": "6d0bcf018933f1ee2c119436c9c9383dc729e2652503663673b8cd430c1e5d71",
"rawBytes": 236714,
"gzipBytes": 109998,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-XIg9ZqQe.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/furniture-plan-art.generated-C-_0PC_4.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-de-ZP6F6dIF.js",
"sha256": "12dd3028d2de2e4fc3bfcf2299e45e253075ca23b95235436ef1cf9156738a3e",
"rawBytes": 8886,
"gzipBytes": 3411,
"path": "houseplan-assets/settings-de-p06UIc1i.js",
"sha256": "3d1613488d11ed598043ac7ec6d6099d408f5b17a6c0a9d2c9ac5fbebc3396ee",
"rawBytes": 9039,
"gzipBytes": 3463,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-fr-doqd_TsB.js",
"sha256": "0c1f01b69c2b1704ddd29e99dd7609ad679655b5241b3e2b8921057198005ff3",
"rawBytes": 9297,
"gzipBytes": 3385,
"path": "houseplan-assets/settings-fr-CcDGizn4.js",
"sha256": "2ff305cd2808bcc9e9426fd2ca6969c59b247257ea542b53b47dd2e5cbf097a1",
"rawBytes": 9462,
"gzipBytes": 3439,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-ru-CQ1W2gik.js",
"sha256": "bad5c925aac84429d0831a1036ea3162ead8d4431db35ea577b0e806c8fbdbf1",
"rawBytes": 12284,
"gzipBytes": 3900,
"path": "houseplan-assets/settings-ru-DLUtY0xm.js",
"sha256": "1925b094a7e6af7667fb5388ed3a9afec65e65398f3d118f04a07a6b7d7937dc",
"rawBytes": 12482,
"gzipBytes": 3962,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-editor-EDOQT48H.js",
"sha256": "47339ab7329ffd1a0cbd21980c1cf2a2041d7a514ed5992e73a07abdce6fb764",
"path": "houseplan-assets/space-editor-D57grVhE.js",
"sha256": "fb5e2d934ff64b56a639ad52c16d2358715be36c560ef37d8ac4f43475e95834",
"rawBytes": 2619,
"gzipBytes": 1130,
"gzipBytes": 1131,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-BZnGwuVh.js",
"sha256": "516e3462d3896a9ca782ee0f2f3266a4da6e6b6664e92a619a23b8991e600795",
"rawBytes": 14862,
"gzipBytes": 3427,
"path": "houseplan-assets/summary-panel-editor-Cc7hoZrc.js",
"sha256": "c0c13b1be403b029f2442d5cb9e3db8fad77a05c6a93b96948aa59ba0aad2f38",
"rawBytes": 14867,
"gzipBytes": 3430,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js"
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-D_ZXPJxz.js",
"sha256": "3be26833b041416cef359e4c7086a0b0e6788680dda8f1583ae76d9256f944fa",
"path": "houseplan-assets/summary-panel-metrics-lOZWVec6.js",
"sha256": "37e5de39c2464734bd4a440d80311262ffb79b5733c14a07f784840b6f45d4a5",
"rawBytes": 2701,
"gzipBytes": 1348,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-CcLP5B5v.js",
"sha256": "7db94205b54996355041487465d6bade0530e5ac0d13a0eb9b7645086171ce58",
"path": "houseplan-assets/summary-panel-runtime-loaded-CsQP3jRJ.js",
"sha256": "1cb8d8fa330fe9987749fe16f265c98726b4bfd16771c0c7fc4c8ec484822aa8",
"rawBytes": 73476,
"gzipBytes": 20491,
"gzipBytes": 20488,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-B6HsmMPA.js",
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/form-kit.styles-CNHrqe_D.js",
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-BZnGwuVh.js",
"houseplan-assets/summary-panel-metrics-D_ZXPJxz.js"
"houseplan-assets/summary-panel-editor-Cc7hoZrc.js",
"houseplan-assets/summary-panel-metrics-lOZWVec6.js"
]
},
{
"path": "houseplan-assets/support-de-haZQ7q8i.js",
"sha256": "f377e9d552978c850f069eeb05b20c7572f81c7a8c7b2d60838a7907f1c2b3ef",
"path": "houseplan-assets/support-de-B2N_YYL8.js",
"sha256": "3f8c9e159b0d73b169ea547c9c57b9254fb9118d232f6d5e0ea7ad828e8115e3",
"rawBytes": 6167,
"gzipBytes": 2400,
"isEntry": false,
@@ -474,8 +502,8 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/support-fr-DnRyZ_Ga.js",
"sha256": "bb66f839f3b63a74212dc83d273db535dd4597e5f43c5c1b35f0e1fac401e424",
"path": "houseplan-assets/support-fr-D6zFUxMl.js",
"sha256": "76ec9da724ebb6bd8ae006d5169356d57f758a316941e3ba530ce988582e80b1",
"rawBytes": 6333,
"gzipBytes": 2433,
"isEntry": false,
@@ -483,8 +511,8 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/support-ru-B7zzXsuL.js",
"sha256": "63ec59059f3a10c994e4292c4c2b125bb7b2916326dbae27f9975c14be008822",
"path": "houseplan-assets/support-ru-D6-IM08K.js",
"sha256": "b6a2ffdecec86a2df3539d5e3b2ab4699d195a7bb9a717c310dd5d483f99fd0e",
"rawBytes": 8376,
"gzipBytes": 2878,
"isEntry": false,
@@ -492,26 +520,26 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-de-CYXPPfUH.js",
"sha256": "411c98cf876e42d7255882d0faa486602453f871d443b0c2b59c22e3b2552efb",
"path": "houseplan-assets/tools-de-l34ZmKd7.js",
"sha256": "236791eab4ea72a33d832e10c91c232f175f3c41aa894808c64ec9eb09359009",
"rawBytes": 4130,
"gzipBytes": 1378,
"gzipBytes": 1379,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-fr-DvygRB10.js",
"sha256": "f7d35402d5f3504baa8c00dc8bd5170487535c1b87f9a8367a28e332cda92bf2",
"path": "houseplan-assets/tools-fr-DfUkWkg4.js",
"sha256": "ebf7d82ffa772d33c569ddcb5bd16fa7f2899ffae465beeb0ed3a5bd63601a27",
"rawBytes": 4136,
"gzipBytes": 1360,
"gzipBytes": 1361,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/tools-ru-2xmu2w_8.js",
"sha256": "14e6c4c22d615293903fc4f25d831f0af042b4e48234d4f2e114ef7d8817d57a",
"path": "houseplan-assets/tools-ru-C-24FEHa.js",
"sha256": "65959c38f358fbf048c68f033bbcf272dc546c717dcb3c94260299a6ac3d3399",
"rawBytes": 5633,
"gzipBytes": 1697,
"isEntry": false,
@@ -519,80 +547,80 @@
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-de-COda8n9n.js",
"sha256": "88ff4a8cccc94e3eba040af628d9a85db664cbf20586f95704e0823a6215c1f9",
"rawBytes": 2698,
"gzipBytes": 1336,
"path": "houseplan-assets/topology-de-DsW3wYBw.js",
"sha256": "a9119255ef3894816f06e00403cc88b71cd67519f9a7f36b227fefc1e8428df9",
"rawBytes": 4237,
"gzipBytes": 1891,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-fr-BT9yhzay.js",
"sha256": "60cb68c5c9086ec5f4901627a6baa3e3c8cdfda430b0977622b260984122fcfa",
"rawBytes": 2718,
"gzipBytes": 1356,
"path": "houseplan-assets/topology-fr-pMOG69_N.js",
"sha256": "b5b8eb3ba26aa1e32888fd0f798523a8c912480df6ba77ff5980263fbad53804",
"rawBytes": 4321,
"gzipBytes": 1873,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-ru-C-d06yvD.js",
"sha256": "ca9a68f252b8275c954ae7ff7ea56d07a8ea5cf14f18eaac285fd0c63a7bad8f",
"rawBytes": 3669,
"gzipBytes": 1615,
"path": "houseplan-assets/topology-ru-5D8r7xvs.js",
"sha256": "d9a196027c5afd27c4986d18afd2e35853e013e4bf098c52176dbebb16ac9ae9",
"rawBytes": 5880,
"gzipBytes": 2239,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-BG_a6cIb.js",
"sha256": "98d8e54ec59e2d1810b124a0a0c6c2f528c112ab17d12bc3c0a129e13c456c72",
"rawBytes": 10958,
"gzipBytes": 4298,
"path": "houseplan-assets/zigbee-topology-FHYQyNDc.js",
"sha256": "411c7a13eb929ec2a613ef75c6561984e32067c295e1f80e5a2622a13c39ac46",
"rawBytes": 15905,
"gzipBytes": 5955,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js"
],
"dynamicImports": [
"houseplan-assets/topology-de-COda8n9n.js",
"houseplan-assets/topology-fr-BT9yhzay.js",
"houseplan-assets/topology-ru-C-d06yvD.js"
"houseplan-assets/topology-de-DsW3wYBw.js",
"houseplan-assets/topology-fr-pMOG69_N.js",
"houseplan-assets/topology-ru-5D8r7xvs.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CynQ2m3K.js",
"sha256": "966ef9fa87888f0ed561d1061b36f0192c27700f040305ae038fe9509c7a7e33",
"rawBytes": 3983,
"gzipBytes": 1789,
"path": "houseplan-assets/zigbee-topology-runtime-DeR7IsvY.js",
"sha256": "0e6ac1b8d5676ef64b062dfee29feb3010a83c89c410f4b4d707d8a9f6d0f33a",
"rawBytes": 7883,
"gzipBytes": 2880,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js",
"houseplan-assets/namespace-language-D86tJUeD.js",
"houseplan-assets/zigbee-topology-BG_a6cIb.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js",
"houseplan-assets/namespace-language-CPu79Qai.js",
"houseplan-assets/zigbee-topology-FHYQyNDc.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "80338f4a64b84431f95438482aeabe1cc6b0f399ccb0c600b318c0f6e6cccc38",
"sha256": "979e98a40327df3a4043bb501c8829a25d66e0c5ca98358e0ee63c75b39ef8b0",
"rawBytes": 1205,
"gzipBytes": 804,
"gzipBytes": 805,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "ce8c313af85073d1c54ccc74e2111c1f14c5d8e3e8539bc59ec436d6d3e0d574",
"sha256": "b3797a5767db43a78bf91ecba3e5369fb257691624731f8ea310239ec7aad6f2",
"rawBytes": 6165,
"gzipBytes": 2328,
"gzipBytes": 2330,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-BOdiRDFR.js"
"houseplan-assets/houseplan-card-DTtVq_VD.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="ab07da35681c15ab6136c323500b69766580ea4a788e399ea88f9e7497549172";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-BOdiRDFR.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")},_=this._schema,f=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),u=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";import{aI as e,l as o,aL as t,eI as s,A as a,eJ as i,eK as l,eL as n,E as r,c}from"./houseplan-card-DTtVq_VD.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
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${u(_.slice(0,3))}
${f?c`<div class="default-floor-error" role="alert"
${d(f.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:f})}
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${u(_.slice(3))}
${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);
+1
View File
@@ -0,0 +1 @@
function t(t){const h=t.visualViewport;return h&&Number.isFinite(h.width)&&Number.isFinite(h.height)?{left:Number(h.offsetLeft)||0,top:Number(h.offsetTop)||0,width:Math.max(0,Number(h.width)||0),height:Math.max(0,Number(h.height)||0)}:{left:0,top:0,width:t.innerWidth,height:t.innerHeight}}function h(t,h,a,e=7,i=8){const o=a.left+i,n=a.top+i,m=Math.max(o,a.left+a.width-i),r=Math.max(n,a.top+a.height-i),M=Math.max(0,m-o),f=Math.max(0,r-n),u=Math.min(Math.max(0,h.width),M),d=Math.min(Math.max(0,h.height),f);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 c="bottom",b=x;return x+d>r&&s>=n?(c="top",b=Math.min(Math.max(n,s),Math.max(n,r-d))):b=Math.min(Math.max(n,x),Math.max(n,r-d)),{left:Math.round(p),top:Math.round(b),side:c,maxWidth:Math.round(M),maxHeight:Math.round(f)}}function a(t){const h=t.HTMLElement?.prototype;return"function"==typeof h?.showPopover&&"function"==typeof h?.hidePopover}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="32dff4c9872036b127a40336c9499b516e3901baccc7a107162dd7343e67f750";export{a,t as f,h as p};

Some files were not shown because too many files have changed in this diff Show More