Compare commits

...
Author SHA1 Message Date
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
Matysh 841bad4e79 fix(process): drain label input before reporting guard membership (#793)
Avoid grep -q closing the pipe before printf completes under pipefail.
Exercise the actual guard with a large label snapshot and retain refusal cases.

Issue: #793
User-Visible: no
2026-10-05 09:29:54 +03:00
Matysh 9ebc430983 test(process): align publication summary assertions (#767)
Проверка (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
Keep success and push-refusal assertions in sync with the explicit beta heading.

Issue: #767
User-Visible: no
2026-10-05 08:39:31 +03:00
Matysh 0a098c1e51 docs(process): align reusable ship-review header (#767)
Issue: #767
User-Visible: no
2026-10-05 08:34:38 +03:00
Matysh cf1c90fb5b chore(process): align ship review guidance and guard inventory counts (#767)
Clarify nightly review and beta coverage without changing gates or schedules.
Cover the real night/beta publication summaries and reject stale category
counts, totals and membership in the browser-guard documentation.

Issue: #767
User-Visible: no
2026-10-05 08:33:38 +03:00
claude[bot] 7fb20c658b docs(reviews): индекс после сдвига каталога (#791)
Проверка (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
Issue: #791
User-Visible: no
2026-10-04 17:13:49 +03:00
Matysh 05c8e164c2 docs(review): self-review LED mutation witness by owner exception (#791) 2026-10-04 17:13:49 +03:00
Matysh 9deb1dcfd6 test: keep LED owner mutant type-correct for runtime proof (#791)
Issue: #791
User-Visible: no
2026-10-04 17:10:40 +03:00
Matysh a5e06b0527 test: restore the missing LED runtime owner witness (#791)
Exercise a non-null marker absent from the render-device roster alongside a
valid lit neighbour. The previous null-marker smoke returned before the
mutated guard, so it never tested this defensive consumer boundary.
Move this witness to the direct Node runtime suite and update its inventory.

Issue: #791
User-Visible: no
2026-10-04 17:09:23 +03:00
Matysh 14f5b90c24 docs(review): record Windows harness portability limitation (#789)
Проверка (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
Issue: #789
User-Visible: no
2026-10-04 16:21:52 +03:00
Matysh 4de49e0db2 docs(review): accept LED zoom-only performance improvement (#789)
Issue: #789
User-Visible: no
2026-10-04 16:14:57 +03:00
Matysh 80cba30895 perf(light): reduce LED gradient paint only during scale changes
Retain the 48-band DOM and exact idle raster, paint 24 midpoint bands during actual zoom, and restore after 160 ms. Keep input, HA and lifecycle guards observable; measure the full camera/restore/restart cycle without relaxing historical budgets.

Issue: #789
User-Visible: yes
2026-10-04 15:54:34 +03:00
Matysh 96f89717bd docs(review): approve zoom-only LED quality specification for #789 2026-10-04 15:39:26 +03:00
Matysh d8949cfffc test(light): preserve empty render contract and guard late static imports
Follow the render-local barrier wrapper without weakening empty-space or
confirmation assertions. Register the cold-import connection guard mutation.

Issue: #789
User-Visible: no
2026-10-04 07:17:00 +03:00
Matysh 7777b8a0a8 perf(light): batch source entry and reuse render-local barriers
Coalesce source-entry updates without changing the 500 ms transition or
field geometry. Resolve shared barrier revisions once within a synchronous
render and always recheck content on the next pass. Release main/static
LED owners on no-strip exits and static owner/config teardown.

Protect scheduling, real browser fades, cold imports and the bundled
render-pass wiring. Performance acceptance remains a separate exact-SHA
full Linux run; this commit alone does not assert that AC3 has passed.

Issue: #789
User-Visible: yes
2026-10-04 07:13:27 +03:00
Matysh 45aeadb38e docs(review): approved independent specification review for #789
Issue: #789
User-Visible: no
2026-10-04 06:52:27 +03:00
Matysh b07ef67dcb docs(review): independent approved code review for #790
Проверка (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
Issue: #790
User-Visible: no
2026-10-04 06:48:42 +03:00
Matysh d58a5d0dad chore(golden): accept active LED core colour (#790)
Accept only the six reviewed active-LED frames from the complete canonical
WSL capture. All other baseline PNG bytes are preserved. Both off-LED
captures are byte-identical to their reviewed baselines.

Root and an independent reviewer inspected the candidates and diffs.
Separate DOM-white controls localize the intended core colour change;
residual old-baseline differences remain at Glow rims (48/74/212 pixels
above delta 10, all below the unchanged .0005 ratio threshold). Rectangle
control also records 10 baseline-to-white residual pixels and five
white-to-colour raster/repaint pixels outside the core near the door.
No claim is made that the old-baseline RGBA diff is exclusively the core,
or that the residual's historical cause has been established. No golden
threshold, product geometry, field alpha or field algorithm is changed.

Issue: #790
User-Visible: no
Release: v1.79.0
Baseline-Reviewed-Local: sha256:aaf9ebcb3613071dc8048506f68a889dab6bdea8e62ba5ce677b12d611e9eae7
2026-10-04 00:13:47 +03:00
Matysh 181b61ae8e fix(led): colour the active core from its resolved light
Keep off and unavailable presentation unchanged. Extend the rendered colour
and surface matrix and retain the independent white-source geometry oracle.

Issue: #790
User-Visible: yes
2026-10-03 23:54:21 +03:00
Matysh 24e48935c3 Release v1.79.0 candidate: reconcile mirrored main workflow history
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-backdrop) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (led-strips) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Canceled after 0s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Canceled after 0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (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
Merge the previously mirrored #716 workflow callers. Both already match dev
byte-for-byte; the candidate Git tree is unchanged. This makes the stable
promotion a fast-forward without rewriting main or dev history.

Release: v1.79.0
Issue: #716
Issue: #780
Issue: #788
User-Visible: no
2026-10-03 22:26:34 +03:00
Matysh 6b0d7d66dd Release v1.79.0 candidate
Promote published v1.79.0-beta.7 without feature-source changes.
Aggregate stable highlights since v1.78.0 and rebuild versioned assets.

Release: v1.79.0
Issue: #780
Issue: #788
User-Visible: yes
2026-10-03 22:24:42 +03:00
Matysh 0588fa8f0a Release v1.79.0-beta.7 candidate
Release: v1.79.0-beta.7
Issue: #788
User-Visible: yes
2026-10-03 20:12:10 +03:00
claude[bot] 019cfa5c5a docs: accept derived artifacts on dev for v1.79.0-beta.7
Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697).
Прогон: https://github.com/Matysh/houseplan-card/actions/runs/37139464966
Скриншоты документации: отпечаток исходников; изменённые кадры: нет.

Issue: #697
User-Visible: no
2026-10-03 17:11:26 +00:00
Matysh 5fff48f7b1 docs(led): record full regression acceptance and self-review
Issue: #788
User-Visible: no
2026-10-03 19:38:27 +03:00
Matysh a74a50d331 test(led): align mutation witness after occluder filtering
Keep the executable shared-masonry proof without adding new text anchors
against the monolith. Update the unchanged circle-event mutation oracle.

Issue: #788
User-Visible: no
2026-10-03 19:21:46 +03:00
Matysh 416d2fae71 perf(led): avoid redundant tube and barrier calculations
Preserve exact light fields while culling provably distant occluders,
reusing physical tube paths across camera updates and excluding LED emitter
data from aligned architectural fingerprints. Cover output equivalence,
in-place invalidation, resize tags and hyphenated benchmark chunk hashes.

Issue: #788
User-Visible: yes
2026-10-03 19:18:49 +03:00
Matysh 08bc58cc0e chore(golden): accept corrected LED end-cap feather
Reviewed Linux Validate 37134548622 on c142e4f7. The right free cap now
fades through the valid wall-circle crescent instead of cutting it away.
One declared frame changed; 199 baselines retained, 101 exact witnesses.
Private household screenshots were not uploaded or committed.

Issue: #788
User-Visible: no
Release: v1.79.0-beta.7
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/37134548622
2026-10-03 18:58:09 +03:00
Matysh c142e4f7b5 fix(led): preserve strip corners and feather the complete light field
Keep real end and corner emitters, robust decimal joins and wall-circle
sweep events. Render one positive-winding compound visibility clip so
Chromium cannot cancel or cut away overlapping light regions.

Add independent pixel oracles for glow falloff and wall-following tubes.
Replace the lossy fan-count limit with explicit cached-path bounds while
retaining the original timing and warm-cycle heap-growth limits.

Issue: #788
User-Visible: yes
2026-10-03 18:46:35 +03:00
Matysh ad10a5b099 chore(release): prepare v1.79.0-beta.6
Build the beta candidate, date both changelogs, refresh release notes and tighten release ratchets.

Release: v1.79.0-beta.6
Issue: #786
Issue: #787
User-Visible: yes
2026-10-03 17:58:43 +03:00
Matysh 76310fc981 docs(screenshots): обновить fingerprint после #787 2026-10-03 17:39:20 +03:00
Matysh 0f466743c3 chore(golden): эталон прямоугольной LED-ленты (#787)
Issue: #787
User-Visible: no
Release: v1.79.0-beta.6
Baseline-Reviewed-Local: sha256:44eb42f580fc9f973509af68f47565b4cad2a9a6200e445e728f6198e785ef98
2026-10-03 17:26:56 +03:00
Matysh 35ef231191 fix(led): сохранить прямоугольник через проёмы (#787)
Issue: #787
User-Visible: yes
2026-10-03 17:17:50 +03:00
Matysh fab28af26f docs(screenshots): обновить fingerprint после #786 2026-10-03 17:00:16 +03:00
Matysh a54429d4a3 chore(golden): эталон гладких торцов LED-ленты (#786)
Issue: #786
User-Visible: no
Release: v1.79.0-beta.6
Baseline-Reviewed-Local: sha256:1c7933e47079b24064413013a999ed10376a0aad05f8c8f57f1c683bb64c6ae7
2026-10-03 16:51:39 +03:00
Matysh 9598768167 fix(led): сгладить торцы поля свечения (#786)
Issue: #786
User-Visible: yes
2026-10-03 16:41:10 +03:00
Matysh aa1105468b chore(release): prepare v1.79.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
Build the beta candidate, date both changelogs, refresh release notes and tighten release ratchets.

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

Issue: #697
User-Visible: no
2026-10-03 11:22:39 +00:00
Matysh 8b88a6f606 chore(golden): accept LED strip continuity scenes (#785)
Issue: #785
User-Visible: no
Release: v1.79.0-beta.5
Baseline-Reviewed-Local: sha256:c30cae622673c0e45a45de8a63d12e8c0f7cc5cc1ff5bf147450849a7158e35e
2026-10-03 14:12:46 +03:00
Matysh 4c7e9815f3 fix(led): сохранить непрерывный Glow и толщину ленты (#785)
Issue: #785
User-Visible: yes
2026-10-03 14:00:14 +03:00
Matysh 1deb29e163 chore(release): prepare v1.79.0-beta.4
Build the beta candidate, date both changelogs, refresh release notes and tighten release ratchets.

Release: v1.79.0-beta.4
Issue: #783
Issue: #784
User-Visible: yes
2026-10-03 13:23:36 +03:00
Matysh 3048ac9195 test(led): wait for the no-Glow fade to finish (#784)
The state and room-setting updates are separate renders. Assert the stable no-Glow contract after the legitimate 500 ms leaving phase instead of racing it on fast CI runners.

Release: v1.79.0-beta.4

Issue: #784
User-Visible: no
2026-10-03 11:15:38 +03:00
Matysh 654cb9d404 docs(screenshots): refresh source fingerprint for #784
All 11 documentation frames remain pixel-identical after the LED strip runtime changes; only the source fingerprint is refreshed.

Issue: #784

User-Visible: no

Release: v1.79.0-beta.4
2026-10-03 11:01:35 +03:00
Matysh 8dbfe5e6b2 test(golden): accept polished LED strip glow (#784)
Reviewed all three changed WSL frames: the 30 cm field is continuous at
straight cuts and corners in 2D light/dark and 2.5D scenes.

Issue: #784
User-Visible: no
Release: v1.79.0-beta.4
Baseline-Reviewed-Local: sha256:cf474aa1ff676d2262f1c9e442114e7df0e91867c69c78bdc82c923a48355650
2026-10-03 10:54:55 +03:00
Matysh 706eb02d70 fix(led): polish glow and interactions (#784)
Issue: #784
User-Visible: yes
2026-10-03 10:47:28 +03:00
Matysh 4962de1d5c fix(ci): parse beta-derived docs CSV lists (#783)
Keep repeated --expect-change flags compatible while accepting the comma-separated workflow input and ignoring whitespace, empty values, and duplicates.

Issue: #783
User-Visible: no
2026-10-03 10:04:12 +03:00
Matysh 31b4ddda55 Release v1.79.0-beta.3 candidate
Publish the three integrated S8 issues since beta.2. Synchronize all seven version sources, rebuild committed frontend assets, tighten ratchets to measured facts, and update release metadata for LED strips. Canonical documentation screenshots were reviewed from Linux CI and accepted in the preceding derived commit.

Local checks: build/typecheck, bundle policy, bundle budgets, monolith checks and release-contract tests passed. Native-Windows unit run passed 3517 tests; two GNU-bash tests and one TypeScript diagnostic-shape test remain Linux-only. Full exact-SHA Validate is mandatory before publication.

Release: v1.79.0-beta.3
Issue: #765
Issue: #780
Issue: #781
User-Visible: yes
2026-10-03 09:40:14 +03:00
Matysh 072fb108cf docs: accept derived artifacts on dev for v1.79.0-beta.3
Производные артефакты беты приняты вручную по резервному пути релиз-менеджера (PROCESS.md §8, §11.4, #697). Каноническая съёмка: https://github.com/Matysh/houseplan-card/actions/runs/37102465043. Все 11 кадров просмотрены; изменения #780 и тотальный байтовый сдвиг Chromium 151 приняты с записанным --no-witnesses. Golden полного Validate 37102870712 зелёный и эталонов не меняет.

Issue: #697
User-Visible: no
2026-10-03 09:31:48 +03:00
Matysh 24671d4358 perf: снизить стоимость маски LED-лент (#780)
Проверка (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
Issue: #780
User-Visible: no
2026-10-03 00:10:28 +03:00
Matysh 6607a34973 perf: упростить SVG-поле LED-лент (#780)
Проверка (CI) / Смоки в браузере (шард 2 из 3) (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) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #780
User-Visible: no
2026-10-02 23:38:35 +03:00
Matysh e9e6eba5d1 docs(review): yellow code review #780 r2
Exact-SHA performance keeps the result yellow: the 50x50 LED profile
exceeds warm-ready and camera Long Task budgets on all seven samples.

Issue: #780
User-Visible: no
2026-10-02 22:59:42 +03:00
Codex a20ef2b9b0 chore(monolith): raise bundleBytes for the r1 LED fixes (#780)
bundleBytes 2 667 879 → 2 670 566 (+2 687, over the 2 000 band): the value
badge of a strip on the card and on the static card, the visibility gate
before import(), the release of a card's LED caches on disconnect and the
cache statistics of the lazy runtime/field chunks (r1 M1, M4, M5). The
badge method moves below `_renderDevice`, outside the vacuum section the
isometric contract reads. The review index is rebuilt after the rebase.

Issue: #780
User-Visible: no
2026-10-02 20:58:11 +03:00
Codex e447364636 perf(led): the led-strips-v1 profile proves caches, disconnect and a late import (#780)
r1 M5: the runtime and field chunks release a card's frame and field cache
on disconnect (ledRelease from disconnectedCallback), never cache for a
disconnected card, and a chunk that lands after disconnect renders nothing
(the card's LED hook is connected-only). The profile now reports the three
caches of the shown space separately (shapes ≤ 50, visibility ≤ 50, retained
per-emitter fans ≤ 2500) through the runtime's ledStats, asserts 0 retained
entries and 0 live LED timers/frames/observers after every disconnect,
judges the Long Tasks of a 100-step camera series as well as the interaction
profile's camera scenario, runs one extra cold mount whose runtime response
is held while the card is removed, and enforces ≥ 7 samples after ≥ 1
warm-up — also on reports merged from parts (--warmup-only, --merge).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex b40e8ff58d fix(led): badge at the anchor, room_id wins, chain stays in its space, hidden loads nothing (#780)
r1 of the code review:
- M1: a strip keeps its marker's value badge, passive, at the half-length
  anchor on the card and on the static card (no icon core, pulse or slot).
- M2: one room resolver for the strip's Glow — an explicit valid room_id of
  the marker wins over the anchor room; a stale one falls back (stripRoom).
- M3: the chain remembers the space it is drawn in; a space switch finishes
  it there, never in the space shown next, and opens no picker over it.
- M4: visibility is decided before import(): a hidden marker or an
  HA-disabled device loads no LED chunk (ledVisible, also checks the stored
  marker so a just-hidden one does not slip through a stale device list).
- M6: the static card is a full light_pools × live_states browser matrix.

Unit tests for M2/M3, smokes for M1/M3/M4/M6, five registered mutants.

Issue: #780
User-Visible: yes
2026-10-02 20:54:50 +03:00
Codex ef3e7a5440 fix(config): an ordinary save without led_strips keeps the stored shapes (#780)
r1 H1: a previous frontend sends every space without the unknown field and
the ordinary write path replaced the document with it, erasing every LED
strip. The shared ordinary-writer helper now copies the stored shapes of a
space the payload omits (config/set and Optimize); an explicit list from a
new client still deletes, a removed space takes its strips, and a link to a
marker the same write deleted unbinds by the usual rule. config/set reports
the normalisation counters after the preservation.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
MatyshandCodex 403bcca680 docs(reviews): independent code review of LED strips (#780)
Manual review requested after the automated reviewer stopped before a verdict.
Material 2c5b59aa: red, one High and six Medium findings.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex 91b57ae60e test(golden): accept the LED strip scenes and the Devices toolbar shift (#780)
Accepted from the golden-images artifact of the full Validate on 63e751ad,
every frame reviewed side by side:
- new: led-strip-design-reference-off-light, led-strip-design-reference-on-light,
  lighting-led-strip-glow-dark, led-strip-off-light, iso-led-strip-dark (the
  designer's four strips on #868D94; compared with Led-On/Led-Off in
  docs/design/led-strips/ACCEPTANCE.md);
- changed: geometry-devices-editor-dark, device-inbox-narrow-ru-dark,
  device-dialog-desktop-en/de, toggle-entity-dialog-desktop-en/mobile-ru (the
  «LED strip» tool after «Add»), geometry-decor-editor-dark,
  furniture-categories-light, room-discard-dialog-mobile-ru,
  support-phone-success-dark-en, support-phone-validation-light-ru — icons
  the stale demo icon map lacked are drawn now.
demo/srv/assets/icons.js is the output of demo/gen_icons.mjs again (restores
63e751ad; the 4283cb13 trim is dropped so the frames match the accepted run).

Issue: #780
User-Visible: no
Release: v1.79.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/36999711281
2026-10-02 20:54:50 +03:00
Codex 3d2c5ab3ee fix(demo): add only the LED tool icons to the demo icon map (#780)
A full regeneration of demo/srv/assets/icons.js also picked up 29 icons other
tasks reference and dropped one, shifting unrelated golden frames. Keep the
committed map and add exactly mdi:led-strip-variant and mdi:link-variant(-off).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex 5d3f29c249 fix(card): static «Show as LED strip» in the device dialog; demo icons (#780)
The full Validate on a6c4001c found two things:
- smoke_help_affordance: the device dialog shifted when the LED tool chunk
  arrived after it opened (the representation section appeared late). A
  device without a strip now gets the static section at once (label in the
  base dictionary); the press loads the tool, leaves the dialog through its
  own guard and starts drawing for the same marker. A strip's device still
  loads the tool with the dialog. The dialog alone no longer loads anything.
- smoke_lazy_admin_locale counted nine namespace chunks (stale since the
  `tools` namespace); it now reads NAMESPACE_LOCALE_CHUNKS and accepts the
  editor's own `tools-de`.
- demo/srv/assets/icons.js regenerated: the demo/golden ha-icon stub lacked
  mdi:led-strip-variant and mdi:link-variant(-off).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex ec9d968c8a test(smoke): the Devices toolbar has the LED strip tool after Add (#780)
Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex 8915e49a58 test(smoke): LED strip smokes enter through __hpTest and the DOM (#780)
setServerConfig/setLayout/setMode/openMarkerDialog/close and the tray's own
«Device settings» + Delete + confirm instead of private card writes; config
writes pass through the fixture so revisions stay consistent.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex d515c42177 feat: LED strips — editor notes, docs, demo, smokes, golden scenes, profile (#780)
Stage 5 of #780.

- Import summary: «Strips left unbound after import: {n}» from the backend
  `unbound_led_strips` count; «Optimize plans» reports strips passing
  through walls per space and edits none (AC16).
- Linear field for long strips (ТЗ §13.2): pieces of at most the radius
  along the polyline, emitters thinned to r/4, each piece clipped to the
  visibility fans of its own emitters as separate clipPath children (no
  boolean pass per piece), one floor clip for the whole layer, no fan at
  all where nothing blocks within the radius; a grid index of body faces
  and boxed inside tests; unchanged fields skip re-diffing. 50×50 on the
  large house: first stable frame ~1.4 s, warm space ~1.1 s locally.
- led-strips-v1 profile: demo/benchmark_led_strips.mjs with the derived
  large-house fixture (10×5, 50×50, none), absolute limits of the ТЗ table
  in demo/performance/budgets-led-strips.json, exact counters (zero
  recomputes on HA ticks/camera/colour, ≤50 cache entries, no growth over
  20 cycles); added to the full performance workflow.
- Bundle: LAZY_LED_GZIP_CEILING 10 KiB, LAZY_LED_EDITOR_GZIP_CEILING 11 KiB
  (measured + 10 %, rounded up); overlaps with the initial and editor
  graphs refused; the lazy editor graph stays inside its ceiling.
- Smokes smoke_led_strip_draw/bind/glow, linked in smoke-links; 13 mutants
  in the registry (7 browser guards in the inventory); config field registry
  entry `spaces[].led_strips`.
- Golden: five new scenes on the `golden-led` space of the visual fixture
  (`ledStrips` option, the designer's four strips on #868D94), matrix v71.
- Docs: LIGHT, DEVICE-PRESENTATION, USER-GUIDE (en/ru), UX-MODES,
  ARCHITECTURE, ISOMETRIC, CONFIG-COMPATIBILITY, TOUCH-SUPPORT, demo/stand
  README, performance README; docs/design/led-strips with the unchanged
  designer archive, two paired frames and ACCEPTANCE.md; both changelogs.

Issue: #780
User-Visible: yes
2026-10-02 20:54:50 +03:00
Codex ade3078d53 chore(monolith): raise hostRefs and bundleBytes for the LED strips (#780)
hostRefs 4924 → 5050 (+126): the new lazy LED editor reads card state through
its own structural port `LedEditorHost` (src/led-strip-editor.ts) — not the
HouseplanEditorHostPort — the same way the stairs editor does. bundleBytes
2 607 854 → 2 664 132: the three new LED chunks (runtime, field, editor),
their ru/de/fr dictionaries and the shared geometry chunk. Re-measured at the
end of the task.

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
Codex d21fba11d6 feat(card): the LED strip tool in the Devices editor (#780)
Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with
clean clicks only (pan, pinch, a second finger, cancel and the synthetic
click after navigation add nothing), snaps to the grid and to physical wall
faces / zero-wall axes, stops at the first face of masonry, partitions,
columns and windows (doors, gates and passages are cut by geometry), Shift
gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a
double click or a click on the first point (≥3 vertices) closes; fewer than
two distinct points write nothing. A finished strip opens the device picker
(lights first, taken markers explained, «New device…» binds in that
dialog's own write, «Later» keeps unbound geometry).

A selected strip shows its vertex handles (a drag re-checks both neighbours
and the whole path) and a new Devices tray branch: device settings,
bind/change, unbind, show as icon, delete. The device dialog gets the
representation section: show as strip (restores a hidden shape at once or
draws one for this marker, behind the dialog's own save/discard guard),
show as icon, unbind/delete for a hidden shape. Every geometry or
representation change is one optimistic write and one LED command of the
device history; Undo/Redo restores only its own strip record and refuses
when a newer change sits on it. A rebinding renames the link in the marker
save, a deleted marker leaves an unbound strip, a bound marker may not move
to another space. Plan/Background show strips as a passive translucent mark.

The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement
geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only
on the tool, an editable strip in the shown space or a strip device's
dialog. The initial graph gets the loader and delegation only
(src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling.
Mutant anchors follow the moved code (marker dialog guard, static LED layer).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
claude[bot]andCodex ed238059e1 refactor(card): the LED linear field is its own lazy chunk (#780)
ТЗ §13.1: the static card with light_pools:false must not load the linear
field. led-strip-field.ts (2.1 KB gzip) is a dynamic import of the LED
runtime (5.0 KB gzip), loaded only when a strip is on in a Glow room with
a light scene, with the same fingerprint handshake and hashed-URL retry
(manifest role led-field, retry token counted).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00
claude[bot]andCodex 8237bbc1cc feat(card): LED strips in the 2.5D View (#780)
Stage 3c of #780 (ТЗ §7). In the volumetric View the stripe is raised like
a device tile: body lifted by ISO_TILE.lift (0.075 D), the edge swept
ISO_TILE.depth (0.1 D) below it in isoEdgeColor, an inert blurred floor
shadow from isoTileShadow for the current theme and floor; D takes the
shared ISO_ICON_SCALE. The field stays on the floor plane, the hit path
moves with the raised body, everything is in plan units so zoom cannot
detach it. Editors and the static card keep Flat.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex 05d62f053a feat(card): LED strips on the static space card (#780)
Stage 3b of #780 (ТЗ §8). houseplan-space-card draws a represented marker
as a passive strip through the same lazy runtime: no icon, no auto slot, no
round pool, no hit path, focus or actions. The stripe uses the drawn wall
geometry for the face offset, so light_pools:false builds no barriers,
visibility or timers; the linear field appears only with light_pools:true
and Glow; with live_states:false the stripe is neutral. Strip points vote
in the content frame. Probed in the demo: plain card — one passive stripe
with the source-colour core, no field, no hit path; light_pools — the field.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex 3a1cd7b1ad feat(card): LED strips in View — lazy chunk, stripe, linear light (#780)
Stage 3a of #780.

- src/led-strip-gate.ts is the only initial-graph foothold (ТЗ §13.1): which
  markers a space shows as a strip (active and bound), the half-length
  anchor that replaces their icon position, and one page-wide load of the
  lazy led-strip-runtime chunk (fingerprint handshake, a hashed-URL retry on
  the next explicit entry, never in a loop).
- The card: a represented marker takes no auto slot, draws no icon, casts no
  round pool and is placed at the anchor; two call sites render the stripe
  layer and the linear field from the chunk. The space model carries the
  stored strips untouched; scaling, validation and geometry are in the chunk.
- src/led-strip-runtime.ts: the stripe is two strokes of one derived path
  (outline #383838 t, core t/2, round joins and caps, t = 0.08/0.12 D),
  white off, white core + field on with Glow, source-colour core without
  Glow, grey dashed unavailable without a field. The hit path takes the
  card's own device handlers (one action path) with radius max(22 px, t/2)
  and the nearest stripe as owner. The linear field is the exact distance
  field with the shared falloff: opaque grey bands of a luminance mask per
  piece, pieces joined by lighten (the maximum), each piece clipped to what
  its own emitters see, so a hidden part never lights through another part's
  visibility; buried emitters emit nothing; a failed clip is dark. A bounded
  per-space cache (50) counts geometry rebuilds.
- The initial View graph had 501 B of headroom. The furniture library copy
  (furn.*, 104 keys × 4 languages, editor-only) moves into a new lazy `tools`
  namespace (#627 mechanism) that the editor runtime awaits; the initial
  View graph is 298 686 B gzip with the LED gate in it (−1 879 B vs the base).

Tests: test/led-strip-runtime.test.mjs (6: representation, gate anchor =
geometry anchor, falloff, states and radius, per-piece clipping and buried
strips, bounded cache), bundle and i18n fixtures for the LED chunk and the
fourth namespace.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex fb3071d3c1 feat(card): pure LED strip geometry and the space model field (#780)
Stage 2 of #780. src/led-strip-geometry.ts (pure, not imported by the
initial graph): the anchor at half the polyline length; stripPieces and
visibleStripPath — a segment lying on a thick body face within
epsilonGeom is shifted t/2 into free floor, free floor and zero-wall axes
stay at 0, a face→floor transition is a connector without gap; emitter
samples epsilon outward on a face and none inside a body; placement that
stops at the first face and lets a strip touch and slide along it, a
vertex drag clamped on its path and both neighbours; the screen hit owner
with radius max(22 px, t/2) and a stable-id tie.

SpaceModel gains optional led_strips (render units, data only, no geometry
import in space-geometry.ts).

Tests: test/led-strip-geometry.test.mjs (10).

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
claude[bot]andCodex 870237f3fb feat(backend): LED strips are stored, normalised and transferred (#780)
Stage 1 of #780 — the data model. A space carries an optional
`led_strips: [{id, points, marker, active?}]` (custom_components/houseplan/
led_strips.py, pure, strict mypy).

- Type schema inside SPACE_SCHEMA: 2–50 finite numeric points (no strings,
  booleans, NaN or off-canvas values), ≤50 strips per space including hidden
  shapes, strict boolean `active`, marker = non-empty string or null.
- A config-level step after coordinate canonicalisation judges the shape
  (two distinct points, non-zero length, a closed strip needs three distinct
  vertices, a hidden shape needs a marker, unique ids per space) and the links
  in the order the spec fixes: duplicates are rejected before any
  normalisation (two links to the same missing id still conflict); a link to
  a marker that is not live becomes an unbound strip (marker null, active
  true, id and points kept), so a client that does not know strips can delete
  a bound marker without its save failing; a live marker with an empty space
  adopts the strip's space; a non-empty foreign space rejects the write.
- config/set answers with `led_strips: {unbound, space_adopted}` when the
  write was normalised, so a new client re-reads; old clients ignore it.
- Space import remaps links through the marker id map; a skipped or
  virtualised duplicate leaves the strip unbound; a coinciding old id never
  binds. Plan-only export keeps geometry and nulls every link. Import details
  report `unbound_led_strips`, computed by the server, never read from the file.
- Coordinates get JSON-noise cleanup only, like stairs (face contacts are
  off-lattice), in both canonicalisers with a shared fixture case.
- Support package: counters only (total/unbound/hidden), no coordinates or ids.

Tests: tests_backend/test_led_strips.py (41, pure), test_ha_import_export
(6 cases: full round trip with a hidden shape, orphan count, remap against a
coinciding id, skip and virtual duplicates, plan-only), test_ha_websocket
(old client deletes a bound marker → save stands, counters, foreign space
rejects without a new revision). Full backend with the HA harness: 969 passed.
Mutating the shape check, the duplicate check, the orphan normalisation or the
space adoption each turns the pure suite red.

Issue: #780
User-Visible: no
2026-10-02 20:54:49 +03:00
Codexandclaude[bot] 9fa5efbdac docs(reviews): manual code review of the pending-round witness (#781)
Проверка (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
Manual review requested by the owner after the automated reviewer failed.
Material e931a949: green, no High/Medium findings, one non-blocking Low.

Issue: #781
User-Visible: no
2026-10-02 19:57:08 +03:00
Matyshandclaude[bot] 73b0cfff5b test(process): поймать оживление прошлого запроса (#781)
Issue: #781
User-Visible: no
2026-10-02 19:57:08 +03:00
claude[bot] 13d8df5554 docs: review document for #780
Проверка (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) / Смоки: все шарды зелёные (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
Issue: #780
User-Visible: no
2026-10-02 10:47:04 +03:00
claude[bot] 9e1a2f4a07 docs(reviews): rebuild the review index after rebase onto dev 2026-10-02 10:18:38 +03:00
Matyshandclaude[bot] 16069d1dcc docs(reviews): independent code review of pipeline snapshot (#765)
Manual review requested by the owner after the automated reviewer failed.
Material af09d36d: green, no High/Medium findings, one non-blocking Low.

Issue: #765
User-Visible: no
2026-10-02 10:18:29 +03:00
claude[bot] 23f822475b fix(process): prepare and the usage step run pipeline scripts from one pinned dev snapshot (#765)
The body of _process.yml is read from dev (@dev, #623). After "Перейти на
ветку задачи" the working copy of job prepare is the task branch, and a
show/ship branch with a clean merge is not rebased before review: its
scripts/ may lag dev or be replaced. #749 fixed job integrate; prepare
still ran four control scripts from the material — the issue-body digest
for the anchor, --reuse of a green verdict (#499), validate-gate (#510)
and the spec-change check (#517). The material decided its own admission:
a branch whose review-doc-guard.mjs prints reuse=true merges without the
model. model_review took model-usage.mjs from the material too: a lagging
branch has none, and the publication silently wrote reason=missing.

Now prepare extracts one snapshot right after setup-node, before the
branch switch: `git rev-parse origin/dev` once and `git archive <sha>
scripts .github/workflows/validate.yml`, so the git fetch of the track and
rebase steps cannot mix versions. Every repo script of the job runs from
it via TOOLS — the track step and the rebase guard lose their own
extractions. The SHA goes out as job output tools_sha; the usage step of
model_review archives the same commit inside itself, so a snapshot failure
is a failure of the reporting step (continue-on-error), not of the stage.
The model session runs on that runner, so the usage line stays untrusted
input parsed strictly (#556, #737).

withMaterialAnchors is idempotent: a repeated call drops the separator the
previous call wrote instead of piling up `---` lines.

Tests: test/process-prepare-tools.test.mjs — the job contract (no step
calls scripts/ from the working copy, one pinned archive before the branch
switch, tools_sha reaches model_review) and the steps as they are, on real
bash and git: a branch behind dev without model-usage.mjs and with a
substituted review-doc-guard.mjs; the anchor digest, reuse and the spec
check come from dev, the usage line is data even after dev moved. Red on
the old workflow: all four. Harnesses of process-track, rebase-generated,
review-doc-guard and model-usage take the prepare snapshot. Three registry
mutants (reuse from the material, usage from moving dev, separators).

Canon: PROCESS.md §10.4 «Скрипты конвейера — из dev» covers prepare and
the usage step; «Расход модели» names it a pipeline step, not the reviewer's.

Issue: #765
User-Visible: no
2026-10-02 10:18:29 +03:00
claude[bot] ceee249210 docs: review document for #780
Issue: #780
User-Visible: no
2026-10-02 10:09:11 +03:00
Claude 2022e183a5 ci: mirror thin workflow callers in main after #716
Проверка (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
Кнопку и запуск workflow_dispatch GitHub даёт только workflow, чей файл
лежит в ветке по умолчанию: ship-review.yml (#696) и beta-derived.yml (#697)
жили только в dev и не запускались вовсе. Два тонких вызывающих файла —
кнопка, входы, run-name, потолок прав и concurrency; тела `_ship-review.yml`
и `_beta-derived.yml` вызываются `@dev`. Файлы байт-в-байт равны ветке
задачи issue/716-register-dispatch-workflows@5451542e. В dev она сливается
после этого коммита: в обратном порядке preflight workflow_sync на dev
нашёл бы файлы, которых нет в main.

Issue: #716
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-30 21:40:31 +03:00
367 changed files with 24366 additions and 5568 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 }} \
+71 -22
View File
@@ -83,7 +83,9 @@ jobs:
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
labels=$(printf '%s\n' "$current" | paste -sd, -)
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
# Не grep -q: раннее закрытие pipe даёт printf SIGPIPE, а pipefail
# превращает найденную метку в ложный отказ (#793). Читаем до конца.
has() { printf '%s\n' "$current" | grep -Fx -- "$1" > /dev/null; }
BLOCKED=false; EXHAUSTED=false
has blocked && BLOCKED=true
has review-4 && EXHAUSTED=true
@@ -331,6 +333,9 @@ jobs:
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
duration_seconds: ${{ steps.duration.outputs.seconds }}
# #765: SHA снимка скриптов конвейера — одна версия на заход: model_review
# снимает расход скриптом из того же коммита dev.
tools_sha: ${{ steps.tools.outputs.sha }}
track: ${{ steps.track.outputs.track }}
mutants: ${{ steps.track.outputs.mutants }}
full: ${{ steps.track.outputs.full }}
@@ -383,6 +388,29 @@ jobs:
node-version: 22
cache: npm
# #765 (продолжение #749): тело читается из dev (@dev, #623), значит, и
# управляющие скрипты подготовки — из dev. Рабочая копия после «Перейти на
# ветку задачи» — материал: ветка show/ship с чистым слиянием до ревью не
# ребейзится (§10.4) и может нести scripts/, отставшие на дни или
# подменённые. Раньше якорь тела issue, reuse (#499), Validate-gate (#510)
# и проверку ТЗ (#517) исполняли скрипты ветки — материал сам участвовал в
# решении о своём допуске к ревью. Снимок один на заход: SHA закрепляется
# здесь, до любого git fetch трека и ребейза, и выходом `tools_sha` уходит
# в model_review. validate.yml в снимке — по той же причине, что в #749:
# ci-proof.mjs (validate-gate) читает его по пути от себя. Git, дифф и
# документы по-прежнему судятся в рабочей копии — снимок только исполняет.
- name: Скрипты конвейера — из dev (#765)
id: tools
run: |
set -euo pipefail
git fetch -q origin dev
sha=$(git rev-parse origin/dev)
tools="$RUNNER_TEMP/dev-tools"
rm -rf "$tools" && mkdir -p "$tools"
git archive "$sha" scripts .github/workflows/validate.yml | tar -x -C "$tools"
{ echo "dir=$tools"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT"
echo "скрипты конвейера: dev ${sha:0:12}"
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
@@ -423,9 +451,9 @@ jobs:
# ОДИН вызов process-track.mjs по меткам, диффу и комментариям; bash только
# исполняет: пишет комментарий из файла скрипта и меняет метки по его
# флагу. Выходы (`track`, `mutants`, `full`, `ship`, `risk_note`,
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт берётся из dev, как
# помощник ребейза: show/ship до ревью не ребейзятся, и ветка, отставшая от
# dev, его может ещё не нести.
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт — из снимка dev
# (#765): show/ship до ревью не ребейзятся, и ветка, отставшая от dev, его
# может ещё не нести.
- name: "Трек задачи и рамки ship (#696)"
id: track
env:
@@ -435,17 +463,15 @@ jobs:
LABELS: ${{ needs.guard.outputs.labels }}
BRANCH: ${{ steps.branch.outputs.name }}
OWNER: ${{ github.repository_owner }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
git fetch -q origin dev
tools="$RUNNER_TEMP/track-tools"
rm -rf "$tools" && mkdir -p "$tools"
git archive origin/dev scripts | tar -x -C "$tools"
# Комментарии — для строки владельца «Трек: <x> — решение владельца»
# (#707). Не прочитаны — пустой файл: происхождение не установлено.
comments="$RUNNER_TEMP/track-comments.json"
gh issue view "$NUM" --repo "${{ github.repository }}" --json comments > "$comments" 2>/dev/null \
|| : > "$comments"
out=$(node "$tools/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
out=$(node "$TOOLS/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
--branch="$BRANCH" --base=origin/dev --head=HEAD --comments="$comments" --owner="$OWNER" \
--out="$RUNNER_TEMP/track" \
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")
@@ -479,6 +505,7 @@ jobs:
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
TRACK: ${{ steps.track.outputs.track }}
TOOLS: ${{ steps.tools.outputs.dir }}
# rebase, в отличие от commit, не принимает -c user.*: он запускает
# свои процессы и требует личность в окружении, иначе падает с
# «unable to auto-detect email address».
@@ -515,13 +542,10 @@ jobs:
# сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если
# ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом
# другом. Список конфликтов он снимает ДО abort (#364) и печатает в
# stdout по строке. Помощник берётся из dev, а не из ветки: ветка,
# отставшая от dev, его ещё не несёт.
tools="$RUNNER_TEMP/rebase-tools"
rm -rf "$tools" && mkdir -p "$tools"
git archive origin/dev scripts | tar -x -C "$tools"
# stdout по строке. Помощник — из снимка dev (#765), а не из ветки:
# ветка, отставшая от dev, его ещё не несёт.
code=0
files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
files=$(node "$TOOLS/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
# Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой —
# сбой самого помощника, конфликтом ветки он не выдаётся.
if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then
@@ -564,7 +588,7 @@ jobs:
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:refs/heads/$BRANCH" 2> "$push_err"; then
refusal="$RUNNER_TEMP/push-refusal.md"
kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
--branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--comment="$refusal" --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
@@ -621,6 +645,7 @@ jobs:
NUM: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
@@ -646,7 +671,8 @@ jobs:
body=$(mktemp)
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
digest=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { pathToFileURL } = require("node:url");
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
const { readFileSync } = await import("node:fs");
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
});
@@ -676,8 +702,9 @@ jobs:
# зелёного вердикта: иначе вызов модели пропускается и находку
# «ТЗ менялось» некому напечатать (#517 AC6).
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
--issue-body="${ISSUE_BODY}")
printf '%s\n' "$out"
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
@@ -757,6 +784,7 @@ jobs:
MUTANTS: ${{ steps.track.outputs.mutants }}
# #697: `ci:full`/`ci:golden` — полный набор на материале.
FULL: ${{ steps.track.outputs.full }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
@@ -770,7 +798,7 @@ jobs:
# S7), страховка — process-reconcile. Зелёный или красный завершённый
# прогон гейт и без ожидания возвращает сразу.
set +e
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
node "$TOOLS/scripts/validate-gate.mjs" --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
--mutants="${MUTANTS:-false}" --full="${FULL:-false}"
code=$?
set -e
@@ -899,13 +927,15 @@ jobs:
env:
NUM: ${{ github.event.issue.number }}
DIGEST: ${{ steps.material.outputs.issue_body }}
TOOLS: ${{ steps.tools.outputs.dir }}
run: |
if [ -z "$DIGEST" ]; then
echo "хеша тела нет — сравнивать не с чем"
exit 0
fi
out=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { pathToFileURL } = require("node:url");
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
const { execFileSync } = await import("node:child_process");
const [num, digest] = process.argv.slice(1);
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
@@ -1001,8 +1031,8 @@ jobs:
contents: read
issues: write
needs: [guard, prepare]
# #696: `ship` в рамках сливается без модели — пакетное ревью диапазона
# перед бетой (PROCESS.md §11.7, ship-review.yml).
# #696/#727: `ship` в рамках сливается без модели — пакетное ревью ночью;
# перед бетой проверка покрытия и непрочитанная дельта (PROCESS.md §11.7).
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
runs-on: ubuntu-24.04
concurrency:
@@ -1312,14 +1342,33 @@ jobs:
# execution_file — все сообщения сессии, с результатами инструментов —
# не выгружается и не печатается: скрипт берёт из него только `result`.
# `always()` — расход упавшей сессии хотя бы виден в сводке.
#
# #765: рабочая копия здесь — материал ревью. На отставшей ветке show/ship
# model-usage.mjs нет, и публикация молча писала `reason=missing`; ветка
# могла и подменить скрипт. Скрипт берётся из того же коммита dev, что и
# снимок подготовки (`tools_sha`). Снимок извлекается здесь, а не отдельным
# шагом до Review: его сбой — сбой отчётного шага (continue-on-error), а не
# всей стадии. От сессии модели, исполнявшейся на этом раннере, снимок не
# защищает — поэтому строка остаётся недоверенным вводом и разбирается
# строго при публикации (#556, #737).
- name: Снять расход модели
id: usage
if: always()
continue-on-error: true
env:
EXEC: ${{ steps.review.outputs.execution_file }}
TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }}
run: |
line=$(node scripts/model-usage.mjs --execution-file="$EXEC")
set -euo pipefail
if [ -z "$TOOLS_SHA" ]; then
echo "::error::нет SHA снимка скриптов из prepare — расход не снят"
exit 1
fi
tools="$RUNNER_TEMP/dev-tools"
rm -rf "$tools" && mkdir -p "$tools"
git cat-file -e "${TOOLS_SHA}^{commit}" 2>/dev/null || git fetch -q origin "$TOOLS_SHA"
git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"
line=$(node "$tools/scripts/model-usage.mjs" --execution-file="$EXEC")
echo "line=$line" >> "$GITHUB_OUTPUT"
echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY"
+10 -5
View File
@@ -1,10 +1,11 @@
name: "Бета: пакетное ревью ship · тело (#716)"
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
# диапазоне есть ship-задачи.
# правка в механических рамках и зелёный лёгкий Validate. Их код читается
# пакетно ночью; перед бетой — только непрочитанные или изменённые задачи.
# Гейт публикации беты (`ship-review.mjs check`) требует покрытие всех ship-задач
# диапазона «прошлый тег..кандидат»; ночного документа с тем же патч-набором
# достаточно, отдельный документ тега беты нужен не всегда (#727).
#
# Три job, как у release-review.yml (#551, #556): детерминированный вход,
# модель без права записи, детерминированная публикация документа в `dev`.
@@ -375,7 +376,11 @@ jobs:
commit -q -F "$msg"
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
if [ "$MODE" = "nightly" ]; then
echo "### Ночное пакетное ревью ship" >> "$GITHUB_STEP_SUMMARY"
else
echo "### Предрелизное пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
fi
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
exit 0
+12
View File
@@ -48,6 +48,7 @@ jobs:
- overlay
- space-default
- space-glow
- led-strips
steps:
- name: Check out candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -175,6 +176,14 @@ jobs:
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
fi
;;
led-strips)
# #780 led-strips-v1: base predates LED strips, so the profile is
# judged by its absolute limits inside the runner; the zero-LED
# View stays covered by the relative interaction profile above.
npm run benchmark:led-strips -- --size=10x5 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-10x5.json
npm run benchmark:led-strips -- --size=50x50 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-50x50.json
npm run benchmark:led-strips -- --size=none --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-none.json
;;
*)
echo "::error::Unknown performance profile: $PROFILE"
exit 1
@@ -217,6 +226,9 @@ jobs:
space-glow)
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
;;
led-strips)
echo "led-strips-v1: absolute limits and counters already enforced by the runner (budgets-led-strips.json)"
;;
esac
- name: Upload full performance report
+32 -13
View File
@@ -608,14 +608,15 @@ patch-id кандидата слияния: вердикт к работе за
| ТЗ | строка «что меняется и чем проверить» в теле issue под `## ТЗ` | «Оценка» (§7.2) и до трёх AC в теле issue под `## ТЗ` | полное ТЗ по §7.1 |
| Ревью ТЗ | нет | нет | да, лимит 4 цикла |
| Локальный гейт | `npm run gate:small` | `npm run gate:small` плюс смоуки `smoke-select` и AC | §8 |
| Код-ревью | до слияния нет; пакетное ревью диапазона перед бетой | модель: корректность и AC | документ ревью, как в §2.7 |
| Код-ревью | до слияния нет; пакетное ревью ночью, перед бетой — проверка покрытия и непрочитанная дельта (§11.7) | модель: корректность и AC | документ ревью, как в §2.7 |
| Лимит циклов код-ревью | — | 2 | 4 |
**Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых
файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за
рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
диапазона перед бетой (§11.7).
ночью заранее; перед бетой проверяется покрытие и читается непрочитанная дельта
(§11.7).
**Проверка риска по изменённым участкам** (#707). Рамки считают строки и файлы,
но не видят, что именно тронуто: обработчик `pointerdown` в двенадцать строк
@@ -675,7 +676,8 @@ patch-id кандидата слияния: вердикт к работе за
`ux-contract`, `perf-touch`, `undocumented`. Шаг решения по вердикту ставит
`track:ask`, снимает `track:show` и переводит задачу в `S3-spec` комментарием:
критерий словами, документы код-ревью, бюджет. Код остаётся в ветке, полное ТЗ
пишется в теле issue, код класса A не пушится до `S5`; бюджет код-ревью не
пишется в теле issue; до `S5` запрещён push всей ветки, включая тесты и документы
(§11.8), а не только кода класса A; бюджет код-ревью не
обнуляется (§4). Подтверждённый владельцем `show` конвейер не повышает: задача
получает `blocked` и вопрос владельцу с вариантом по умолчанию «повысить до
`ask`». `reclassify` без критерия из списка, на `ask` или на ревью ТЗ —
@@ -1327,16 +1329,27 @@ Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly`
origin/dev scripts .github/workflows/validate.yml` и зовёт из него каждый
repo-скрипт: `review-result-gate`, `review-doc-guard`, `reviews-index`,
`merge-candidate`, `process-track route`, `status-label`. Снимок один на job —
одна версия на весь заход, а не смесь по шагам. Так же уже работают шаг трека
(#707), страж ребейза (#698) и разбор отказа push (#723). Рабочая копия ветки
одна версия на весь заход, а не смесь по шагам. Job `prepare` (#765) делает
то же до перехода на ветку задачи, но архивом **закреплённого** SHA
(`git rev-parse origin/dev` один раз — `git fetch` трека и ребейза его не
сдвигают): из снимка идут шаг трека (#707), страж ребейза (#698) с разбором
отказа push (#723), хеш тела issue для якоря, reuse зелёного
вердикта (#499), Validate-гейт (#510) и проверка изменившегося ТЗ (#517).
Прежде четыре последних исполняли скрипты ветки — материал сам решал, допущен
ли он к ревью: подменённый `review-doc-guard.mjs` выдавал себе `reuse=true`.
SHA уходит выходом `tools_sha` в `model_review`. Рабочая копия ветки
задачи — только материал: git-команды, документ и пути судятся в ней. Ветка
`show`/`ship` с чистым слиянием до ревью не ребейзится и может нести
`scripts/`, отставшие от `dev` на дни: её `review-doc-guard.mjs` молча
проглотил бы неизвестный флаг, её `merge-candidate.mjs` слил бы по-старому.
`validate.yml` в снимке обязателен: `workflow-jobs.mjs` читает его по пути от
себя, без него доказательство Validate кандидата — `failed (#622)`. Исключение
— job `model_review`: ревьюер исполняет тесты и скрипты материала, это его
работа, а не конвейера. Следствие для задач, меняющих сам конвейер: их
себя, без него доказательство Validate кандидата — `failed (#622)`. В job
`model_review` скрипты материала исполняет ревьюер — это его работа, а не
конвейера; детерминированный шаг «Снять расход модели» (#737) — работа
конвейера, и `model-usage.mjs` он берёт архивом `tools_sha` (#765): на
отставшей ветке скрипта нет, и публикация молча писала `reason=missing`. От
сессии модели на том же раннере снимок не защищает, поэтому строка расхода
по-прежнему недоверенный ввод и разбирается строго. Следствие для задач, меняющих сам конвейер: их
слияние судит версия `dev`, новая начинает действовать со следующей задачи.
Правка контракта доказательства Validate (имена потребляемых job
`validate.yml`, политики `ci-proof.mjs`, #622) пишется совместимой с
@@ -1400,7 +1413,7 @@ limit`) и своей логики трека не держит; ответ comp
|---|---|---|---|
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | лёгкий, как у `ship` (#709) |
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
| Ревью модели | нет — пакетное ревью ночью; перед бетой проверка покрытия и непрочитанная дельта (§11.7) | корректность и AC; окружение — по нужде | полное |
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | как у `ship` (#709) |
Мутантов по диффу нет ни на одном треке (#709, §2.7). Рамки `ship`
@@ -1470,7 +1483,8 @@ merge. Timeout/cancel/failure называет конкретную стадию
**Расход модели** (#737). Шаг `Review` отдаёт `execution_file` — все сообщения
сессии SDK, а журнал Actions расход намеренно прячет. Шаг «Снять расход модели»
сразу после `Review` (`if: always()`, `continue-on-error`) берёт из файла только
сразу после `Review` (`if: always()`, `continue-on-error`) — шаг конвейера, а не
ревьюера, со скриптом из снимка `dev` (#765) — берёт из файла только
последнее сообщение `result` (`scripts/model-usage.mjs`) и отдаёт одну строку
выходом job `usage`: `<!-- hp:usage input_tokens=N output_tokens=N
cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` — суммы
@@ -1703,7 +1717,8 @@ Golden, браузерные смоки, performance и полный HA-харн
Это исключение из правила «каждое изменение проходит код-ревью» (§5, §7.1).
Второе такое место — трек `ship` (§5): его ревью не отменяется, а переносится
на пакетное ревью диапазона перед бетой. Здесь же исключение относится только к
на пакетное ревью ночью и проверку покрытия с чтением непрочитанной дельты перед
бетой (§11.7). Здесь же исключение относится только к
окну между `S8-merged` и выпуском.
### 11.5 Независимое ревью линии перед стабильным релизом
@@ -1772,7 +1787,9 @@ action ревью пускает ровно этого бота (`allowed_bots`,
выпуск после такого отказа, исправляют причину и получают новый совместимый
полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата.
### 11.7 Пакетное ревью `ship` перед бетой
<a id="117-пакетное-ревью-ship-перед-бетой"></a>
### 11.7 Пакетное ревью `ship`: ночью и перед бетой
Решение владельца 2026-09-28, issue #696.
@@ -1781,7 +1798,9 @@ action ревью пускает ровно этого бота (`allowed_bots`,
до того, как она уйдёт пользователям беты. Одна сессия на все ship-задачи
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
**Шаг.** Перед публикацией беты — `ship-review.yml`
**Шаг.** Ночью код читается заранее (режим ниже). Перед публикацией беты
проверяется покрытие; непрочитанные или изменённые после ревью задачи читает
`ship-review.yml`
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N` или кнопка
«Run workflow» в Actions). Это тонкий вызывающий файл, он лежит и в `main`:
без этого GitHub запуск не даёт. Тело `_ship-review.yml` читается из `dev` при
+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-beta.2"
VERSION = "1.80.0-beta.4"
DECOR_ASSETS_API_VERSION = 1
SUMMARY_PANEL_API_VERSION = 1
@@ -86,6 +86,16 @@ def _lattice_points(value: Any) -> None:
_lattice_point(point)
def _scalar_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
if not isinstance(point, list):
continue
for index in range(min(2, len(point))):
point[index] = canonicalize_number(point[index])
def canonicalize_position(position: Any) -> Any:
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
result = copy.deepcopy(position)
@@ -182,6 +192,12 @@ def canonicalize_config_geometry(config: Any) -> Any:
_lattice_point(span.get("a"))
_lattice_point(span.get("b"))
for strip in _records(space.get("led_strips")):
# #780: LED strips snap to physical wall faces, like stairs and
# furniture: remove JSON noise only, never pull a face contact
# onto a lattice node.
_scalar_points(strip.get("points"))
for marker in _records(root.get("markers")):
_scalar_fields(marker, ("angle",))
@@ -1,466 +1,626 @@
{
"schema": 1,
"fingerprint": "2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004",
"fingerprint": "2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 300565,
"initialViewGzipBytes": 301040,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 302087,
"initialPanelGzipBytes": 302564,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2323,
"initialPanelOnlyGzipBytes": 2327,
"lazyFiles": [
"houseplan-assets/backdrop-pick-erCrvaVF.js",
"houseplan-assets/de-d9TwEFPV.js",
"houseplan-assets/editor-C_sFCxgH.js",
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/fr-CWO2BzcK.js",
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
"houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
"houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
"houseplan-assets/hp-zigbee-topology-overlay-CpILp2jS.js",
"houseplan-assets/iso-scene-render-R0PpaYmJ.js",
"houseplan-assets/live-interaction-runtime-zvKxzkbX.js",
"houseplan-assets/moon-runtime-DXG94qOm.js",
"houseplan-assets/namespace-language-CPNZBROg.js",
"houseplan-assets/pdf-export-ByjBIvRJ.js",
"houseplan-assets/space-editor-Ch0tH8G9.js",
"houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js",
"houseplan-assets/zigbee-topology-DZ_-WbFy.js",
"houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js"
"houseplan-assets/backdrop-pick-rLk02S_E.js",
"houseplan-assets/de-Df0SMMtx.js",
"houseplan-assets/editor-Ber3pzUs.js",
"houseplan-assets/floating-surface-CAk--Y2g.js",
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/fr-9DZosQeR.js",
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
"houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
"houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
"houseplan-assets/hp-zigbee-topology-overlay-BTD4QxJr.js",
"houseplan-assets/iso-scene-render-D0hnGVLf.js",
"houseplan-assets/led-strip-editor-AS9V253I.js",
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
"houseplan-assets/led-strip-runtime-CYCZOpBw.js",
"houseplan-assets/live-interaction-runtime-CX7iT0NT.js",
"houseplan-assets/live-tip-placement-Cmnwu_lZ.js",
"houseplan-assets/moon-runtime-qD4uu5bk.js",
"houseplan-assets/namespace-language-qIVz1CjX.js",
"houseplan-assets/pdf-export-BLeQ2whw.js",
"houseplan-assets/space-editor-CSjiCH5v.js",
"houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js",
"houseplan-assets/zigbee-topology-CRIMYnAF.js",
"houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js"
],
"lazyGzipBytes": 464846,
"lazyGzipBytes": 485830,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-erCrvaVF.js",
"houseplan-assets/editor-C_sFCxgH.js",
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
"houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
"houseplan-assets/namespace-language-CPNZBROg.js",
"houseplan-assets/zigbee-topology-DZ_-WbFy.js"
"houseplan-assets/backdrop-pick-rLk02S_E.js",
"houseplan-assets/editor-Ber3pzUs.js",
"houseplan-assets/floating-surface-CAk--Y2g.js",
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
"houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
"houseplan-assets/namespace-language-qIVz1CjX.js",
"houseplan-assets/zigbee-topology-CRIMYnAF.js"
],
"lazyEditorGzipBytes": 239369,
"lazyEditorGzipBytes": 243803,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-erCrvaVF.js",
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
"houseplan-assets/namespace-language-CPNZBROg.js"
"houseplan-assets/backdrop-pick-rLk02S_E.js",
"houseplan-assets/floating-surface-CAk--Y2g.js",
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
"houseplan-assets/namespace-language-qIVz1CjX.js"
],
"lazyOnboardingGzipBytes": 28396,
"lazyOnboardingGzipBytes": 28679,
"lazyLocaleFiles": [
"houseplan-assets/de-d9TwEFPV.js",
"houseplan-assets/fr-CWO2BzcK.js"
"houseplan-assets/de-Df0SMMtx.js",
"houseplan-assets/fr-9DZosQeR.js"
],
"lazyLocaleGzipBytes": 56069,
"lazyLocaleGzipBytes": 53867,
"lazyIsometricFiles": [
"houseplan-assets/iso-scene-render-R0PpaYmJ.js"
"houseplan-assets/iso-scene-render-D0hnGVLf.js"
],
"lazyIsometricGzipBytes": 12345,
"lazyIsometricGzipBytes": 12348,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js"
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js"
],
"lazyFurnitureArtGzipBytes": 16942,
"lazyFurnitureArtGzipBytes": 16939,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
"houseplan-assets/pdf-export-ByjBIvRJ.js"
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
"houseplan-assets/pdf-export-BLeQ2whw.js"
],
"lazyPdfGzipBytes": 126940,
"lazyPdfGzipBytes": 126937,
"lazyMoonFiles": [
"houseplan-assets/moon-runtime-DXG94qOm.js"
"houseplan-assets/moon-runtime-qD4uu5bk.js"
],
"lazyMoonGzipBytes": 11716,
"lazyMoonGzipBytes": 11715,
"lazyLedFiles": [
"houseplan-assets/led-strip-field-DYtgd8wB.js",
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
"houseplan-assets/led-strip-runtime-CYCZOpBw.js"
],
"lazyLedGzipBytes": 10684,
"lazyLedEditorFiles": [
"houseplan-assets/led-strip-editor-AS9V253I.js",
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
"houseplan-assets/namespace-language-qIVz1CjX.js"
],
"lazyLedEditorGzipBytes": 10705,
"lazyNamespaceLocaleFiles": [
"houseplan-assets/settings-de-Cu0ARW9H.js",
"houseplan-assets/settings-fr-BIO-QwTT.js",
"houseplan-assets/settings-ru-gpeXJk2y.js",
"houseplan-assets/support-de-CH0Mth3e.js",
"houseplan-assets/support-fr-5un_aGUp.js",
"houseplan-assets/support-ru-BUP8Z3Kb.js",
"houseplan-assets/topology-de-DoQ2W7CB.js",
"houseplan-assets/topology-fr-4M_CQtH4.js",
"houseplan-assets/topology-ru-DLnNeJR5.js"
"houseplan-assets/led-de-DAcR1G-Q.js",
"houseplan-assets/led-fr-_e9yQX3D.js",
"houseplan-assets/led-ru-BcCmiv05.js",
"houseplan-assets/settings-de-D2uOhVdg.js",
"houseplan-assets/settings-fr-Dkv1OQnI.js",
"houseplan-assets/settings-ru-Dm61M7J6.js",
"houseplan-assets/support-de-CuOKWMOQ.js",
"houseplan-assets/support-fr-Cg72SrF2.js",
"houseplan-assets/support-ru-DE0eoEHZ.js",
"houseplan-assets/tools-de-CkfSvdjK.js",
"houseplan-assets/tools-fr-CLTqQchi.js",
"houseplan-assets/tools-ru-uXSPneqf.js",
"houseplan-assets/topology-de-BrwHoXfr.js",
"houseplan-assets/topology-fr-gjGrARkP.js",
"houseplan-assets/topology-ru-DSq63N19.js"
],
"lazyNamespaceLocaleGzipBytes": 22672,
"lazyNamespaceLocaleGzipBytes": 31767,
"files": [
{
"path": "houseplan-assets/backdrop-pick-erCrvaVF.js",
"sha256": "58bdb0ca63168828ebdd1fe564ba18c096ee124ae01aa9795465497e3469c868",
"rawBytes": 55329,
"gzipBytes": 18026,
"path": "houseplan-assets/backdrop-pick-rLk02S_E.js",
"sha256": "141b16e51a818ad137bbf001efb6bed28a20791e8d992b0430edd79bfa3d0bec",
"rawBytes": 54425,
"gzipBytes": 17699,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/namespace-language-CPNZBROg.js"
"houseplan-assets/floating-surface-CAk--Y2g.js",
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/namespace-language-qIVz1CjX.js"
],
"dynamicImports": [
"houseplan-assets/settings-de-Cu0ARW9H.js",
"houseplan-assets/settings-fr-BIO-QwTT.js",
"houseplan-assets/settings-ru-gpeXJk2y.js"
"houseplan-assets/settings-de-D2uOhVdg.js",
"houseplan-assets/settings-fr-Dkv1OQnI.js",
"houseplan-assets/settings-ru-Dm61M7J6.js"
]
},
{
"path": "houseplan-assets/de-d9TwEFPV.js",
"sha256": "350157130d9bc87112a94a0062e6fdc5c1061efe8816be115a3c7836f54225d7",
"rawBytes": 93620,
"gzipBytes": 28263,
"path": "houseplan-assets/de-Df0SMMtx.js",
"sha256": "ce067613e40e29223f9a0e46516035e7bf153bc30145083239945e82096803ab",
"rawBytes": 89873,
"gzipBytes": 27164,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-C_sFCxgH.js",
"sha256": "cb88aa19af05a9921272bd04e9023fdd75f0fbe63a8e490f7fd3c22652847c76",
"path": "houseplan-assets/editor-Ber3pzUs.js",
"sha256": "9a8614dc89d1f5df419b66152f97872ac3a1cee8aff47bbc190e28ca3dd87317",
"rawBytes": 3833,
"gzipBytes": 1582,
"gzipBytes": 1585,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/form-kit.styles-BBYWe4Py.js",
"sha256": "88f80b20957ade215c2028113fab371dfb8588d93d19774cfebcc03f38ab6e6c",
"rawBytes": 25600,
"gzipBytes": 5055,
"path": "houseplan-assets/floating-surface-CAk--Y2g.js",
"sha256": "a6fccd3fb4ad315ba51316e958d65b49cf3e4d51707ddd98e47019e83b8467f2",
"rawBytes": 1104,
"gzipBytes": 579,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-CWO2BzcK.js",
"sha256": "ca7a4fc1e696690c6e971c94f46d856816583cd247fe66a64ec4ae371e196631",
"rawBytes": 96021,
"gzipBytes": 27806,
"path": "houseplan-assets/form-kit.styles-BXaoYN6g.js",
"sha256": "d67cd5482adce817b474e68ba31e1d3c0ddec55ff8f7c912e78395a265bb881d",
"rawBytes": 25605,
"gzipBytes": 5059,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
"sha256": "1c9a0ca09ec566715248c691386bfd17e23d3ae821a0c21608f9513602c72095",
"path": "houseplan-assets/fr-9DZosQeR.js",
"sha256": "14738a9cb6f99a1b7c56b425696dd354bb0589c58f569db280bab7aa313dab08",
"rawBytes": 92266,
"gzipBytes": 26703,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
"sha256": "75937cebabec17ca652b22606bfe3ae511df5a8e44921d05d4f84fe3ab353e74",
"rawBytes": 52035,
"gzipBytes": 16942,
"gzipBytes": 16939,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DGdbxgxC.js",
"sha256": "1a9aa573e7c525eaa850feecfa59c215ed926706908872937424881b5799f501",
"rawBytes": 1058576,
"gzipBytes": 299764,
"path": "houseplan-assets/houseplan-card-BOhGeQlv.js",
"sha256": "2891b5898e812f6433322a6b750077f5c543a6308c2b04e19e349f3003101830",
"rawBytes": 1059200,
"gzipBytes": 300237,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-d9TwEFPV.js",
"houseplan-assets/editor-C_sFCxgH.js",
"houseplan-assets/fr-CWO2BzcK.js",
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
"houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
"houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
"houseplan-assets/hp-zigbee-topology-overlay-CpILp2jS.js",
"houseplan-assets/iso-scene-render-R0PpaYmJ.js",
"houseplan-assets/live-interaction-runtime-zvKxzkbX.js",
"houseplan-assets/moon-runtime-DXG94qOm.js",
"houseplan-assets/pdf-export-ByjBIvRJ.js",
"houseplan-assets/space-editor-Ch0tH8G9.js",
"houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js"
"houseplan-assets/de-Df0SMMtx.js",
"houseplan-assets/editor-Ber3pzUs.js",
"houseplan-assets/fr-9DZosQeR.js",
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
"houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
"houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
"houseplan-assets/hp-zigbee-topology-overlay-BTD4QxJr.js",
"houseplan-assets/iso-scene-render-D0hnGVLf.js",
"houseplan-assets/led-strip-editor-AS9V253I.js",
"houseplan-assets/led-strip-runtime-CYCZOpBw.js",
"houseplan-assets/live-interaction-runtime-CX7iT0NT.js",
"houseplan-assets/moon-runtime-qD4uu5bk.js",
"houseplan-assets/pdf-export-BLeQ2whw.js",
"houseplan-assets/space-editor-CSjiCH5v.js",
"houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-6Ti0yXA0.js",
"sha256": "a9aae4b426f09d16563c0fef14db3190c1d71f7ca8eb603fff9824d6fa632fdc",
"rawBytes": 690723,
"gzipBytes": 193056,
"path": "houseplan-assets/houseplan-editor-runtime-BMlHsBKo.js",
"sha256": "2706f9bbc0201e8902598eb20463ee9e417520e40ebb9536be857098f7b56ca3",
"rawBytes": 698926,
"gzipBytes": 195577,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-erCrvaVF.js",
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/namespace-language-CPNZBROg.js",
"houseplan-assets/zigbee-topology-DZ_-WbFy.js"
"houseplan-assets/backdrop-pick-rLk02S_E.js",
"houseplan-assets/floating-surface-CAk--Y2g.js",
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/namespace-language-qIVz1CjX.js",
"houseplan-assets/zigbee-topology-CRIMYnAF.js"
],
"dynamicImports": [
"houseplan-assets/support-de-CH0Mth3e.js",
"houseplan-assets/support-fr-5un_aGUp.js",
"houseplan-assets/support-ru-BUP8Z3Kb.js",
"houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js"
"houseplan-assets/support-de-CuOKWMOQ.js",
"houseplan-assets/support-fr-Cg72SrF2.js",
"houseplan-assets/support-ru-DE0eoEHZ.js",
"houseplan-assets/tools-de-CkfSvdjK.js",
"houseplan-assets/tools-fr-CLTqQchi.js",
"houseplan-assets/tools-ru-uXSPneqf.js",
"houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-D2g8WweX.js",
"sha256": "ddb0ec7750f3269ba2a7d8d259f69f640ae4ddc7cf3bce378c70daece30d21c6",
"rawBytes": 15886,
"gzipBytes": 4907,
"path": "houseplan-assets/houseplan-onboarding-runtime-9BSwcl9-.js",
"sha256": "715f8a6fecb9d2772c0184834a4e7fd673fb8ba8e2b19f25e084d5139ab63ff9",
"rawBytes": 15930,
"gzipBytes": 4932,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-erCrvaVF.js",
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/namespace-language-CPNZBROg.js"
"houseplan-assets/backdrop-pick-rLk02S_E.js",
"houseplan-assets/floating-surface-CAk--Y2g.js",
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/namespace-language-qIVz1CjX.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-CpILp2jS.js",
"sha256": "123c7685bcfb9a2e4b47fcd4ecbc19b2e7acc463afbfea4a00678dcf94287250",
"rawBytes": 10958,
"gzipBytes": 3765,
"path": "houseplan-assets/hp-zigbee-topology-overlay-BTD4QxJr.js",
"sha256": "d4a06ecef69eef43807011f234e9ea4f41525512e3c46cd192f71c7530da5797",
"rawBytes": 16715,
"gzipBytes": 5525,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/namespace-language-CPNZBROg.js",
"houseplan-assets/zigbee-topology-DZ_-WbFy.js",
"houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/live-tip-placement-Cmnwu_lZ.js",
"houseplan-assets/namespace-language-qIVz1CjX.js",
"houseplan-assets/zigbee-topology-CRIMYnAF.js",
"houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-R0PpaYmJ.js",
"sha256": "729b5d1c02300c80614c8fd14c28d9ca9ea310d0824c9630327e38d4b651bea5",
"path": "houseplan-assets/iso-scene-render-D0hnGVLf.js",
"sha256": "6865d06163a910a4f641d0bb29bc99cb8215902708b83427de0b466c4d796914",
"rawBytes": 36700,
"gzipBytes": 12345,
"gzipBytes": 12348,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-zvKxzkbX.js",
"sha256": "a02ff870b438bd2070f53a164cbcf69efda578358fb14a32c32221e67fd43657",
"rawBytes": 8370,
"gzipBytes": 3277,
"path": "houseplan-assets/led-de-DAcR1G-Q.js",
"sha256": "622c426b50781de7a4be6aadb935cadcc7fa774179b2ef409f65fa64e9064e65",
"rawBytes": 1907,
"gzipBytes": 922,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-fr-_e9yQX3D.js",
"sha256": "1f10f0098bb13ce8ef24f60a1dda556b0aa6f47239a0bfa124d016fc92ede20d",
"rawBytes": 1886,
"gzipBytes": 907,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-ru-BcCmiv05.js",
"sha256": "8c367a70a2fd03504863c42afb168803905dd7f16145e489e227f77c0b61e7fe",
"rawBytes": 2525,
"gzipBytes": 1107,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-editor-AS9V253I.js",
"sha256": "4f25c8551b2db798fc062c5f9e0a9733d8a18bd6f7d3d03da73c33753623403c",
"rawBytes": 21704,
"gzipBytes": 7381,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/led-strip-geometry-CFq4tPIv.js",
"houseplan-assets/namespace-language-qIVz1CjX.js"
],
"dynamicImports": [
"houseplan-assets/led-de-DAcR1G-Q.js",
"houseplan-assets/led-fr-_e9yQX3D.js",
"houseplan-assets/led-ru-BcCmiv05.js"
]
},
{
"path": "houseplan-assets/led-strip-field-DYtgd8wB.js",
"sha256": "47fd14662b9836229b7f2aa55fb027f4aa56313b0825be70f5b1a6a674541d17",
"rawBytes": 8066,
"gzipBytes": 3383,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/led-strip-geometry-CFq4tPIv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-DXG94qOm.js",
"sha256": "8f3839a242d5e6293134ffa5a10edfe63516d0f89e3097dfe68ff07f2111e246",
"path": "houseplan-assets/led-strip-geometry-CFq4tPIv.js",
"sha256": "d59b35b494d23921b63faf4f584f1ab9dea3342e5a43b7399964e3d4732d40fe",
"rawBytes": 7055,
"gzipBytes": 2914,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/led-strip-runtime-CYCZOpBw.js",
"sha256": "2675363403be48fddf536c7101422e2492b1e4172aa701851a15e46ce1f80ce2",
"rawBytes": 11248,
"gzipBytes": 4387,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/led-strip-geometry-CFq4tPIv.js"
],
"dynamicImports": [
"houseplan-assets/led-strip-field-DYtgd8wB.js"
]
},
{
"path": "houseplan-assets/live-interaction-runtime-CX7iT0NT.js",
"sha256": "744e65ad2f843f7f6e09176ff449b5b4b83e935856a98b2ec128aebdf93ebc8d",
"rawBytes": 9694,
"gzipBytes": 3782,
"isEntry": false,
"imports": [
"houseplan-assets/floating-surface-CAk--Y2g.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/live-tip-placement-Cmnwu_lZ.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-tip-placement-Cmnwu_lZ.js",
"sha256": "597bb1bc933908ff200057dd7ba941f3b5a974c9a2090acc56b0f715da39ad6f",
"rawBytes": 1191,
"gzipBytes": 683,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/moon-runtime-qD4uu5bk.js",
"sha256": "c70c2405448d5aa0444ed2ad0ef9446a568e06981c9e14665a53c7c0af030604",
"rawBytes": 30292,
"gzipBytes": 11716,
"gzipBytes": 11715,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/namespace-language-CPNZBROg.js",
"sha256": "068f3469028297daea7cf745fc8be94a99aade466532b25d92b3b33928a6242e",
"path": "houseplan-assets/namespace-language-qIVz1CjX.js",
"sha256": "85197049ab95cd2928a157d8045508cc2e6340dcd8963079dfda1b743b9e910f",
"rawBytes": 675,
"gzipBytes": 408,
"gzipBytes": 410,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-ByjBIvRJ.js",
"sha256": "e94672f04c50bdba29636ab9b5a206ace0d600486ea1ad5d87be9a9183666fee",
"path": "houseplan-assets/pdf-export-BLeQ2whw.js",
"sha256": "05ce799acc1bb24dc0d283a44beb1bfed7c5791637ebc9c9140c7bda5652b7df",
"rawBytes": 236714,
"gzipBytes": 109998,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-CCd6mmtp.js",
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/furniture-plan-art.generated-DxnzGS7R.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-de-Cu0ARW9H.js",
"sha256": "a3755fbf40c148aa9848bf4a5191a27c636977c774dc01f137ccc2470c7a0ee5",
"path": "houseplan-assets/settings-de-D2uOhVdg.js",
"sha256": "5185e2fbba6b5ca8384a5b9f8024210dd4f7eae44a208255f8fe142012075890",
"rawBytes": 8886,
"gzipBytes": 3406,
"gzipBytes": 3410,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-fr-BIO-QwTT.js",
"sha256": "abeee9f1cef76177568d2994cca968e07a42040ebd0a6bbc89e81d6eb7eb760e",
"path": "houseplan-assets/settings-fr-Dkv1OQnI.js",
"sha256": "a1a6ce7597b21f3e8b6bbf41c374dc3fab4e5ae4baf9181d270c90a6fa58f45c",
"rawBytes": 9297,
"gzipBytes": 3379,
"gzipBytes": 3384,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/settings-ru-gpeXJk2y.js",
"sha256": "8e05df136db81011ac4c884665d889dc773e12523937215c5f078f71bf882752",
"path": "houseplan-assets/settings-ru-Dm61M7J6.js",
"sha256": "e1efb00b777f3f386ce2296f96c76cc1970460dc5b9915cf5bca0d667e814f2b",
"rawBytes": 12284,
"gzipBytes": 3897,
"gzipBytes": 3900,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/space-editor-Ch0tH8G9.js",
"sha256": "8136fb4e381881486a4f6aaec92e2cfc3d01dd5f9ea51f2c0dca1ebec9141a3e",
"path": "houseplan-assets/space-editor-CSjiCH5v.js",
"sha256": "1939fa609e45366525ac5e61cee84cb1aeae7d3a99711fa1744908fda7e9cde6",
"rawBytes": 2619,
"gzipBytes": 1124,
"gzipBytes": 1127,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-4by4BwDV.js",
"sha256": "c0a480f80507425774e9a71d465014d825b7a9b877d75f20438ee43986af1325",
"rawBytes": 14862,
"gzipBytes": 3423,
"path": "houseplan-assets/summary-panel-editor-BDa-rBgX.js",
"sha256": "abccfc8cfad10984cdbfe5b7b8f416c84c11727640b3fe707b3bb03b168ecc8a",
"rawBytes": 14867,
"gzipBytes": 3427,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js"
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-DmEWv1wn.js",
"sha256": "7ed391b93d0da3dcc1aaf6fc0c47cd5e0ee7106f6697f16143cb3f2d5dc25e80",
"path": "houseplan-assets/summary-panel-metrics-BiXNDvWo.js",
"sha256": "c0a6e64d7805b99c83dd58b3704814dea5b21e47a9c5a4f3c61d0f5582f1ae55",
"rawBytes": 2701,
"gzipBytes": 1340,
"gzipBytes": 1344,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-rO8jZiCI.js",
"sha256": "9fbaea7e6dae38df1f12013f9eaef0b02936fec9ac5866a8f214b92ac1214050",
"path": "houseplan-assets/summary-panel-runtime-loaded-Gm80_eMo.js",
"sha256": "b978d47f4babb0564c97a7b3a2d0e505a43a81eab4f49792acb3b39be38d6564",
"rawBytes": 73476,
"gzipBytes": 20488,
"isEntry": false,
"imports": [
"houseplan-assets/form-kit.styles-BBYWe4Py.js",
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/form-kit.styles-BXaoYN6g.js",
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-4by4BwDV.js",
"houseplan-assets/summary-panel-metrics-DmEWv1wn.js"
"houseplan-assets/summary-panel-editor-BDa-rBgX.js",
"houseplan-assets/summary-panel-metrics-BiXNDvWo.js"
]
},
{
"path": "houseplan-assets/support-de-CH0Mth3e.js",
"sha256": "f835d35a37d84f9972a2673f24105de7768d34e597553b22971ffbf708b36279",
"path": "houseplan-assets/support-de-CuOKWMOQ.js",
"sha256": "0250ec7cbbcca4f972f612b68bc8dd19fe7ebf6b83014e521c06291d53814416",
"rawBytes": 6167,
"gzipBytes": 2393,
"gzipBytes": 2397,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-fr-5un_aGUp.js",
"sha256": "537afde2367d66766509cfd53664f76cb77c1ef689ec07ba59abd285a75c0a9f",
"path": "houseplan-assets/support-fr-Cg72SrF2.js",
"sha256": "c67ff5b8ddc5c490e292199debc279ccf65c46cecdec2e105ecc7ee06e9c5a90",
"rawBytes": 6333,
"gzipBytes": 2427,
"gzipBytes": 2433,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/support-ru-BUP8Z3Kb.js",
"sha256": "d965e7e334b56a19d1175ba3c4b791e2585f45917582e3210904c1eb8705894f",
"path": "houseplan-assets/support-ru-DE0eoEHZ.js",
"sha256": "534214af44ba8e80b6449ad3bc42ecdb6f43103f293fd7d19deae60d1481a52e",
"rawBytes": 8376,
"gzipBytes": 2874,
"gzipBytes": 2877,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-de-DoQ2W7CB.js",
"sha256": "708ceeb7a1c7c843a438f7cb9f9e5a310f4fad8a172c6e6be1ba375c270bc21a",
"rawBytes": 2698,
"gzipBytes": 1332,
"path": "houseplan-assets/tools-de-CkfSvdjK.js",
"sha256": "205d81de2c9b9b79516e88e7284592c0726e5f4b3a9dd63242cc028b267ee8ce",
"rawBytes": 4130,
"gzipBytes": 1377,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-fr-4M_CQtH4.js",
"sha256": "72f6e5c98a4a1c9fce80de9d99193059c687045aae3c4b3b03a87b610469cd94",
"rawBytes": 2718,
"gzipBytes": 1351,
"path": "houseplan-assets/tools-fr-CLTqQchi.js",
"sha256": "3a3d842ec5befbc252261ec46afefd8cb28b2ab1d805220a55933ddd38831a2c",
"rawBytes": 4136,
"gzipBytes": 1359,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-ru-DLnNeJR5.js",
"sha256": "1cb2e1f8984a997e20d3ea05b2749379e3c773e41bf7670226ac0141fca13ccf",
"rawBytes": 3669,
"gzipBytes": 1613,
"path": "houseplan-assets/tools-ru-uXSPneqf.js",
"sha256": "46afb767891262c52bc51658de2db852264b911eb864f03f91a5f10d2573301b",
"rawBytes": 5633,
"gzipBytes": 1696,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-DZ_-WbFy.js",
"sha256": "b2deb6084e835c4864160259cf445a24f4340d54c0db574cc4e9635d47e4f6a1",
"rawBytes": 10958,
"gzipBytes": 4300,
"path": "houseplan-assets/topology-de-BrwHoXfr.js",
"sha256": "85ed4bb11a34972e23ba51a7bb74a70f7fc30b228f7b140a7a87c780cdfa93aa",
"rawBytes": 4237,
"gzipBytes": 1889,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-fr-gjGrARkP.js",
"sha256": "67b40ff27301c3aefd69dee454f5e90ff7edf43da084434ce850c3960e7bb65b",
"rawBytes": 4321,
"gzipBytes": 1871,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/topology-ru-DSq63N19.js",
"sha256": "21126337f66f9e2af553a999af9cb33a72a3d780daa86b1da8c2b5f92ffb6b85",
"rawBytes": 5880,
"gzipBytes": 2238,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-CRIMYnAF.js",
"sha256": "1fee965bb50d8d80417cae2e9a6b070a8fe1311b42171b300d83768f911eb9c4",
"rawBytes": 15905,
"gzipBytes": 5955,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/namespace-language-CPNZBROg.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/namespace-language-qIVz1CjX.js"
],
"dynamicImports": [
"houseplan-assets/topology-de-DoQ2W7CB.js",
"houseplan-assets/topology-fr-4M_CQtH4.js",
"houseplan-assets/topology-ru-DLnNeJR5.js"
"houseplan-assets/topology-de-BrwHoXfr.js",
"houseplan-assets/topology-fr-gjGrARkP.js",
"houseplan-assets/topology-ru-DSq63N19.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-runtime-Boh9TM9B.js",
"sha256": "2f82578f02c24a55de11c7591a421c19694eca62a23cc5f71ca1acf9abe8e5a2",
"rawBytes": 3983,
"gzipBytes": 1788,
"path": "houseplan-assets/zigbee-topology-runtime-BF4ZBhd8.js",
"sha256": "1d76b7f08b7659d6f3a05ba551acc4698ab3a05be495077ac498b212dadb039b",
"rawBytes": 7883,
"gzipBytes": 2880,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js",
"houseplan-assets/namespace-language-CPNZBROg.js",
"houseplan-assets/zigbee-topology-DZ_-WbFy.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js",
"houseplan-assets/namespace-language-qIVz1CjX.js",
"houseplan-assets/zigbee-topology-CRIMYnAF.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "acb67dca9358ea65c86cc0a5e6f1b6eabe364e2cf3b5ed4b67b1bf5f5b8d1c9a",
"sha256": "b1334be19eb372421b9b2886a9f79a7d86bf68f3dd856cda830865436059ad15",
"rawBytes": 1205,
"gzipBytes": 801,
"gzipBytes": 803,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "4b0022acf6b29c9904be5c5d9c69bc0ba92ee4a26e6daa6e09bc93345d8af354",
"sha256": "e1e064634e82387201d3666c7264d26f9aae2df27bd9b1229d8a872738f4ad53",
"rawBytes": 6165,
"gzipBytes": 2323,
"gzipBytes": 2327,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DGdbxgxC.js"
"houseplan-assets/houseplan-card-BOhGeQlv.js"
],
"dynamicImports": []
}
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__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{aG as e,l as o,aJ as t,eD as s,A as a,eE as i,eF as l,eG as n,E as r,c}from"./houseplan-card-DGdbxgxC.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{aG as e,l as o,aJ as t,eE as s,A as a,eF as i,eG as l,eH as n,E as r,c}from"./houseplan-card-BOhGeQlv.js";class f extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,f={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},h=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
.computeLabel=${e=>f[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${d(h.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
${d(h.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
@@ -0,0 +1 @@
function t(t){const a=t.visualViewport;return a&&Number.isFinite(a.width)&&Number.isFinite(a.height)?{left:Number(a.offsetLeft)||0,top:Number(a.offsetTop)||0,width:Math.max(0,Number(a.width)||0),height:Math.max(0,Number(a.height)||0)}:{left:0,top:0,width:t.innerWidth,height:t.innerHeight}}function a(t,a,h,e=7,i=8){const o=h.left+i,n=h.top+i,m=Math.max(o,h.left+h.width-i),f=Math.max(n,h.top+h.height-i),r=Math.max(0,m-o),M=Math.max(0,f-n),u=Math.min(Math.max(0,a.width),r),d=Math.min(Math.max(0,a.height),M);let p=t.left;p+u>m&&(p=t.right-u),p=Math.min(Math.max(o,p),Math.max(o,m-u));const x=t.bottom+e,s=t.top-e-d;let b="bottom",l=x;return x+d>f&&s>=n?(b="top",l=Math.min(Math.max(n,s),Math.max(n,f-d))):l=Math.min(Math.max(n,x),Math.max(n,f-d)),{left:Math.round(p),top:Math.round(l),side:b,maxWidth:Math.round(r),maxHeight:Math.round(M)}}function h(t){const a=t.HTMLElement?.prototype;return"function"==typeof a?.showPopover&&"function"==typeof a?.hidePopover}globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{h as a,t as f,a as p};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";var e={"led.tool":"LED-Streifen","led.hint":"Zeichnen Sie den Streifen Punkt für Punkt entlang der Wandflächen oder über den Boden; er rastet am Raster und an Wandflächen ein. Danach wählen Sie das Gerät, das er darstellt.","led.start":"Klicken, um den Streifen zu beginnen","led.start_touch":"Tippen, um den Streifen zu beginnen","led.points":"Punkte: {n} · Umschalt — 45°-Schritte · Esc — fertig · Strg+Z — Punkt entfernen","led.points_touch":"Punkte: {n} · Werkzeug wechseln zum Beenden","led.finish":"Fertig","led.stopped":"Der Streifen stößt an eine Wand","led.unbound":"Streifen ist mit keinem Gerät verbunden","led.bind":"Gerät verbinden","led.change":"Gerät wechseln","led.later":"Später","led.unbind":"Trennen","led.delete":"Streifen löschen","led.settings":"Geräteeinstellungen","led.type_note":"LED-Streifen: Form im Geräte-Editor auf dem Plan bearbeiten","led.show_strip":"Als LED-Streifen zeigen","led.show_icon":"Als Symbol zeigen","led.cancel":"Abbrechen","led.taken":"Bereits mit einem anderen LED-Streifen verbunden","led.space":"Zuerst den LED-Streifen lösen: Er gehört zu seinem Bereich","led.limit_points":"Höchstens 50 Punkte pro Streifen","led.limit_strips":"Höchstens 50 Streifen pro Bereich","led.pick_title":"Gerät für den Streifen wählen","led.pick_new":"Neues Gerät…","led.pick_empty":"Auf diesem Plan gibt es noch keine freien Geräte.","led.saved":"LED-Streifen gespeichert","led.save_failed":"Streifen nicht gespeichert: {err}","led.history_draw":"LED-Streifen","led.history_shape":"Form des LED-Streifens","led.history_view":"Darstellung des Geräts","led.optimize_walls":"Streifen durch Wände: {n} ({spaces})"};const n="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,n as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";var e={"led.tool":"Ruban LED","led.hint":"Tracez le ruban point par point le long des faces des murs ou sur le sol ; il s’aimante à la grille et aux faces des murs. Ensuite, choisissez l’appareil qu’il représente.","led.start":"Cliquez pour commencer le ruban","led.start_touch":"Touchez pour commencer le ruban","led.points":"Points : {n} · Maj — pas de 45° · Échap — terminer · Ctrl+Z — retirer le point","led.points_touch":"Points : {n} · changez d’outil pour terminer","led.finish":"Terminer","led.stopped":"Le ruban bute contre un mur","led.unbound":"Ruban non lié à un appareil","led.bind":"Lier un appareil","led.change":"Changer d’appareil","led.later":"Plus tard","led.unbind":"Délier","led.delete":"Supprimer le ruban","led.settings":"Réglages de l’appareil","led.type_note":"Ruban LED : modifiez sa forme sur le plan dans l’éditeur d’appareils","led.show_strip":"Afficher en ruban LED","led.show_icon":"Afficher en icône","led.cancel":"Annuler","led.taken":"Déjà lié à un autre ruban LED","led.space":"Détachez d’abord le ruban LED : il appartient à son espace","led.limit_points":"50 points au maximum par ruban","led.limit_strips":"50 rubans au maximum par espace","led.pick_title":"Choisissez l’appareil du ruban","led.pick_new":"Nouvel appareil…","led.pick_empty":"Aucun appareil libre sur ce plan pour l’instant.","led.saved":"Ruban LED enregistré","led.save_failed":"Ruban non enregistré : {err}","led.history_draw":"Ruban LED","led.history_shape":"Forme du ruban LED","led.history_view":"Représentation de l’appareil","led.optimize_walls":"Rubans traversant des murs : {n} ({spaces})"};const a="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,a as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const d="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,d as fingerprint};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{eu as e,eC as t,c6 as s,eJ as o,eK as i,ad as n,ee as a,O as r,eL as l,az as d,ef as c,aL as p,eM as u,eN as h,eO as f,A as m,eP as v}from"./houseplan-card-BOhGeQlv.js";import{L as g,v as w,s as $,a as y,b as _,p as k,c as b,d as C,e as P}from"./led-strip-geometry-CFq4tPIv.js";const M="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-DYtgd8wB.js"):import(new URL(`./led-strip-field-DYtgd8wB.js?${Date.now()}`,import.meta.url).href)).then(e=>{e.LED_FIELD_FINGERPRINT===o?L=e:F=""},()=>{F=e}).finally(()=>{U=null})),L||U?.then(()=>L&&t()),L}const D=e=>e.some(e=>e.glow&&"on"===e.state),R=e=>e.map(e=>[e[0],e[1]]),E="#383838";function A(o,i){if(!o)return null;const n=Array.isArray(o.masonryGeometry)?o.masonryGeometry:[],a=[...e(o.masonryGeometry),...o.opaqueBodies],r=[];for(const e of a)for(let t=0;t<e.length;t++){const s=e[t],o=e[(t+1)%e.length];s&&o&&r.push({a:[s[0],s[1]],b:[o[0],o[1]]})}const l=25,d=new Map;r.forEach((e,t)=>{const s=Math.floor(Math.min(e.a[0],e.b[0])/l),o=Math.floor(Math.max(e.a[0],e.b[0])/l),i=Math.floor(Math.min(e.a[1],e.b[1])/l),n=Math.floor(Math.max(e.a[1],e.b[1])/l);for(let e=s;e<=o;e++)for(let s=i;s<=n;s++){const o=`${e},${s}`,i=d.get(o);i?i.push(t):d.set(o,[t])}});const c=e=>{let t=1/0,s=1/0,o=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),s=Math.min(s,n[1]),o=Math.max(o,n[0]),i=Math.max(i,n[1]);return[t,s,o,i]},p=(e,t)=>e[0]>=t[0]&&e[0]<=t[2]&&e[1]>=t[1]&&e[1]<=t[3],u=n.filter(e=>e?.[0]?.length).map(e=>({box:c(e[0]),poly:e})),h=o.opaqueBodies.map(e=>({box:c(e),body:e}));return{faces:r,inside:e=>u.some(({box:s,poly:o})=>p(e,s)&&t([e[0],e[1]],[o]))||h.some(({box:t,body:o})=>p(e,t)&&s([e[0],e[1]],o)),epsilon:i,near:e=>{const t=new Set;for(let s=Math.floor(e[0]/l);s<=Math.floor(e[2]/l);s++)for(let o=Math.floor(e[1]/l);o<=Math.floor(e[3]/l);o++)for(const e of d.get(`${s},${o}`)||[])t.add(e);return[...t].map(e=>r[e])}}}function I(e){const t=e.candidate?.sourceEid||e.device.primary||"",s=t?e.hass?.states?.[t]?.state:void 0,o="unavailable"===s||"unknown"===s||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=o?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:o?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}const T=new WeakMap;function j(e,t,s){const o=P*s;const i=T.get(e);if(i&&i.faces===t&&i.epsilon===t?.epsilon&&i.value.t===o&&i.points.length===e.points.length&&i.points.every((t,s)=>t[0]===e.points[s][0]&&t[1]===e.points[s][1]))return i.value;const n=R(e.points),a=_(n,t,o/2),r={t:o,path:a,d:k(a)};return T.set(e,{faces:t,epsilon:t?.epsilon,points:n,value:r}),r}function G(e){if(!e.views.length)return d``;const t=e.handlers,s=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),o=e.iso;return d`<g class="led-strips" data-hp-led-strips="${s.length}"
data-hp-iso=${o?"raised":m}>
${o?d`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
x="-100000" y="-100000" width="200000" height="200000">
<feGaussianBlur stdDeviation="${o.shadow.sigma}"></feGaussianBlur></filter></defs>`:m}
<style>
.led-strip .led-focus { fill: none; stroke: transparent; }
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
</style>
${v(s,e=>e.strip.id,s=>{const{t:i,path:n,d:a}=j(s.strip,e.faces,e.d),r="unavailable"===s.state,l=r?"#9e9e9e":"on"===s.state&&s.appearance?s.appearance.c:"#FFFFFF",c=2*b(i*e.perUnit)/(e.perUnit||1),p=s.device,u=t=>function(e,t,s){const o=e.currentTarget,i=o?.getScreenCTM?.();if(!i||s.views.length<2)return t;const n=i.inverse(),a=[n.a*e.clientX+n.c*e.clientY+n.e,n.b*e.clientX+n.d*e.clientY+n.f],r=s.views.map(e=>{const{t:t,path:o}=j(e.strip,s.faces,s.d);return{id:e.strip.id,points:o.points.map(e=>[e[0]*s.perUnit,e[1]*s.perUnit]),closed:o.closed,thicknessPx:t*s.perUnit}}),l=C([a[0]*s.perUnit,a[1]*s.perUnit],r);return s.views.find(e=>e.strip.id===l)||t}(t,s,e).device,h=o?`translate(0 ${-o.lift})`:m;return d`<g class="led-strip state-${s.state}" data-led-strip="${s.strip.id}"
data-marker="${p.id}" data-state="${s.state}" data-closed="${n.closed?"true":"false"}">
${o?d`<path class="led-iso-shadow" d="${a}" fill="none" stroke="black"
stroke-opacity="${o.shadow.opacity}" stroke-width="${i}" stroke-linecap="round"
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
transform="translate(${o.shadow.dx} ${o.shadow.dy-o.lift})"></path>
${[1,.5].map(e=>d`<path class="led-iso-edge" d="${a}" fill="none" stroke="${o.edge}"
stroke-width="${i}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
transform="translate(0 ${-o.lift+o.depth*e})"></path>`)}`:m}
<g transform=${h}>
<path class="led-outline" d="${a}" fill="none" stroke="${E}" stroke-width="${i}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
<path class="led-core" d="${a}" fill="none" stroke="${l}" stroke-width="${i/2}"
stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
<path class="led-focus" d="${a}" stroke-width="${1.6*i}" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true"></path>
${t?d`<path class="led-hit" d="${a}" stroke-width="${c}"
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
role="button" tabindex="0" aria-label="${t.label(p)}"
data-hp="device" data-id="${p.id}"
@click=${e=>t.click(e,u(e))}
@keydown=${e=>t.keydown(e,p)}
@contextmenu=${e=>t.contextmenu(e,u(e))}
@pointerdown=${e=>t.pointerdown(e,u(e))}
@pointermove=${e=>t.pointermove(e,u(e))}
@pointerup=${e=>t.pointerup(e,u(e))}
@pointercancel=${e=>t.pointercancel(e,p)}
@lostpointercapture=${e=>t.pointercancel(e,p)}
@pointerover=${e=>t.pointerover(e,u(e))}
@pointerleave=${()=>t.pointerleave()}
@focus=${e=>t.focus(e,p)}
@blur=${()=>t.blur(p)}></path>`:m}
</g>
</g>`})}
</g>`}function H(e,t,s,o){return(t?e.find(e=>e.id===t):void 0)??(s?e.find(e=>o(s,e)):void 0)}function N(e){const t=new Map(e.devices.map(e=>[e.id,e])),s=g/e.cellCm*e.gridPitch,o=new Map(i({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:s,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),r=[];for(const i of e.space.led_strips||[]){if(!1===i?.active||!i.marker||!w(i.points))continue;const a={...i,points:i.points.map(e=>[e[0]*n,e[1]*n])},l=t.get(a.marker);if(!l||l.space!==e.space.id)continue;if(l.hidden&&!e.showHidden)continue;const d=H(e.space.rooms,l.marker?.room_id,$(R(a.points)),e.inRoom);r.push(I({strip:a,device:l,candidate:o.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!d&&e.glowFor(d),defaultRadius:s,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const l=y/e.cellCm*e.gridPitch;return{views:r,faces:A(e.scene,l),d:e.iconPct/100*a(e.space),scene:e.scene,polygons:e.polygons}}const q=new WeakMap;function B(e){for(const t of q.get(e)?.frame.views??[])T.delete(t.strip);q.delete(e),L?.releaseLedField(e)}function S(e){return{shapes:q.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,visibilityPaths:0,pathChars:0,recomputes:0}}}function O(e,t,s){const o=t.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),i=o.length?e._lightBarriers(t,o.map(({room:e,poly:t})=>({r:e,poly:t}))):null,n=[t,e._renderDevices,e._renderPlanHass,s,i,e._mode,e._showAll],a=q.get(e);if(a&&a.key.every((e,t)=>e===n[t]))return a.frame;for(const e of a?.frame.views??[])T.delete(e.strip);const d=e._config?.icon_size??2.5,c=N({space:t,devices:e._renderDevices,hass:e._renderPlanHass,virtualLights:e._virtualLights,defaultColor:e._fillColors.glow_light.c,paletteAlpha:e._fillColors.glow_light.a,cellCm:e._cellCm,gridPitch:e._gridPitch,iconPct:d>8?2.5:d,scene:i,polygons:o,glowFor:e=>l(s,e),inRoom:(t,s)=>e._pointInRoom(t,s),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&q.set(e,{key:n,frame:c}),c}function W(e,t,s,o){const i=O(e,t,s),n=e._stageEl?.clientWidth,a="iso"===e._renderProjection&&"view"===e._mode,r=a?i.d*c:i.d,l="theme-dark"===p(e._renderPlanHass)?"dark":"light",m=!!e._isoLightFloors?.size,v=u(l,m),g=G({views:i.views,d:r,iso:a?{lift:f.lift*r,depth:f.depth*r,edge:h(E,l,m),shadow:{dx:v.dx*r,dy:v.dy*r,sigma:v.sigma*r,opacity:v.opacityWhite}}:null,faces:i.faces,perUnit:n&&o.w?n/o.w:1,handlers:"view"===e._mode?{click:(t,s)=>e._clickDevice(t,s),keydown:(t,s)=>e._keyDevice(t,s),contextmenu:(t,s)=>e._ctxDevice(t,s),pointerdown:(t,s)=>e._pointerDown(t,s),pointermove:(t,s)=>{const o=e._pointerMove(t,s);o&&e._showDeviceTip(t,o)},pointerup:(t,s)=>e._pointerUp(t,s),pointercancel:(t,s)=>e._pointerCancel(t,s),pointerover:(t,s)=>e._showDeviceTip(t,s),pointerleave:()=>e._clearPointerHover(),focus:(t,s)=>e._showDeviceFocusTip(t,s),blur:t=>e._hideDeviceFocusTip(t.id),label:e=>e.name}:null});return"plan"===e._mode||"decor"===e._mode?d`<g class="led-passive" opacity="0.45" pointer-events="none">${g}</g>`:g}function z(e,t,s){if(!1===e.isConnected)return d``;const o=O(e,t,s),i=o.scene&&(D(o.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return i?i.renderLedField({views:o.views,scene:o.scene,polygons:o.polygons,faces:o.faces,spaceId:t.id,owner:e,requestUpdate:()=>e.requestUpdate(),isConnected:()=>!1!==e.isConnected}):d``}function Y(e){const t=e.space.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),s=N({space:e.space,devices:e.devices,hass:e.hass,virtualLights:e.virtualLights,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,cellCm:e.cellCm,gridPitch:e.gridPitch,iconPct:e.iconPct,scene:e.scene,polygons:t,glowFor:e.glowFor,inRoom:e.inRoom,showHidden:!1}),o=e.live?s.views:s.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),i=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},y/e.cellCm*e.gridPitch),n=e.isConnected()&&e.scene&&e.live&&(D(o)||L?.hasLedField(e.owner))?x(e.space.id,()=>e.isConnected()&&e.ready()):null;return n||L?.releaseLedField(e.owner),d`${n?n.renderLedField({views:o,scene:e.scene,polygons:t,faces:i,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:e.isConnected}):m}${G({views:o,d:s.d,faces:i,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,N as ledFrame,O as ledFrameFor,S as ledStats,I as ledStripView,j as ledStripePath,B as releaseLed,z as renderLedFieldFor,W as renderLedLayerFor,G as renderLedStripes,Y as renderStaticLed,H as stripRoom};
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__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";const t=t=>[t.left,t.top,t.right,t.bottom,t.width,t.height].every(Number.isFinite)&&t.right>=t.left&&t.bottom>=t.top&&t.width>=0&&t.height>=0;function o(o){const{preferred:e,size:i,bounds:h,blockers:r,gap:f=8,edge:n=8}=o;if(![e.x,e.y,i.width,i.height,f,n].every(Number.isFinite)||i.width<=0||i.height<=0||f<0||n<0||!t(h)||r.some(o=>!t(o)))return null;const s=h.left+n,d=h.right-n-i.width,l=h.top+n,a=h.bottom-n-i.height;if(![s,d,l,a].every(Number.isFinite)||d<s||a<l)return null;const b=r.filter(t=>t.right>t.left&&t.bottom>t.top).map(t=>({left:t.left-f-i.width,right:t.right+f,top:t.top-f-i.height,bottom:t.bottom+f})),g=(t,o,e)=>Math.max(o,Math.min(e,t)),m=new Set([s,d,g(e.x,s,d)]),p=new Set([l,a,g(e.y,l,a)]);for(const t of b)m.add(g(t.left,s,d)),m.add(g(t.right,s,d)),p.add(g(t.top,l,a)),p.add(g(t.bottom,l,a));const c=[...m].sort((t,o)=>t-o),u=[...p].sort((t,o)=>t-o);let w=null,y=1/0;for(const t of u)for(const o of c){if(b.some(e=>o>e.left&&o<e.right&&t>e.top&&t<e.bottom))continue;const i=Math.hypot(o-e.x,t-e.y);(!w||i<y)&&(w={left:o,top:t},y=i)}return w}export{o as p};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{eJ as e,eK as a,eF as n}from"./houseplan-card-DGdbxgxC.js";function c(n,c){return new e([{code:"en",dictionary:n},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004",console.warn,a)}function d(e){return function(e,a){return{state:n=>{const c=e.state(n);return"pending"!==c&&a.some(e=>"pending"===e.state(n))?"pending":c},dictionary:a=>e.dictionary(a),ensure:n=>Promise.all([e,...a].map(e=>e.ensure(n))).then(()=>{})}}(n,e)}export{c as n,d as s};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{eR as a,eS as e,eG as n}from"./houseplan-card-BOhGeQlv.js";function o(n,o){return new a([{code:"en",dictionary:n},{code:"ru",loadDictionary:o.ru},{code:"de",loadDictionary:o.de},{code:"fr",loadDictionary:o.fr}],"2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83",console.warn,e)}function r(a){return function(a,e){return{state:n=>{const o=a.state(n);return"pending"!==o&&e.some(a=>"pending"===a.state(n))?"pending":o},dictionary:e=>a.dictionary(e),ensure:n=>Promise.all([a,...e].map(a=>a.ensure(n))).then(()=>{})}}(n,a)}export{o as n,r as s};
@@ -1,6 +1,6 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{aG as e,l as t,aJ as s,A as a,eE as o,eF as i,eG as n,E as c,eI as l,c as r}from"./houseplan-card-DGdbxgxC.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return c;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{aG as e,l as t,aJ as s,A as a,eF as o,eG as i,eH as n,E as l,eQ as c,c as r}from"./houseplan-card-BOhGeQlv.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return l;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
.hass=${this.hass}
.data=${{...this._config,fit:l(this._config.fit)}}
.data=${{...this._config,fit:c(this._config.fit)}}
.schema=${this._schema}
.computeLabel=${e=>_[e.name]||e.name}
@value-changed=${this._valueChanged}
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{c as a,A as e}from"./houseplan-card-DGdbxgxC.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-rO8jZiCI.js";import"./form-kit.styles-BBYWe4Py.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{c as a,A as s}from"./houseplan-card-BOhGeQlv.js";import{s as e,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-Gm80_eMo.js";import"./form-kit.styles-BXaoYN6g.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!e(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(s=>s.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
@@ -10,31 +10,31 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
dismiss-on-scrim aria-busy=${String(d.busy)}
@hp-close=${k}>
<div class="body summary-editor" @click=${()=>m.closeSource()}>
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:e}
${d.localOnly?a`<p class="summary-local-hint">${b(d.localOnlyHint)}</p>`:s}
<section class="summary-general" aria-labelledby="summary-general-title">
<h3 id="summary-general-title">${b("summary.general_settings")}</h3>
<div class="summary-general-grid">
${d.localOnly?e:a`<div class="summary-field">
${d.localOnly?s:a`<div class="summary-field">
<label for="summary-panel-title">${b("summary.panel_title")}</label>
<input id="summary-panel-title" type="text" .value=${d.draft.title}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&"title"===y?.path)}
aria-invalid=${"error"===v("title")?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.title=a.target.value})} />
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:e}
@input=${a=>m.mutate(s=>{s.title=a.target.value})} />
${v("title")?a`<div class="summary-problem error">${h(v("title"))}</div>`:s}
</div>`}
${g}
${d.localOnly?e:a`<label class="summary-switch" for="summary-mobile-show">
${d.localOnly?s:a`<label class="summary-switch" for="summary-mobile-show">
<span class="summary-switch-caption"><strong>${b("summary.show_mobile")}</strong>
<small id="summary-mobile-show-hint">${b("summary.show_mobile_hint")}</small></span>
<input id="summary-mobile-show" data-summary-mobile-show type="checkbox"
.checked=${d.draft.show_on_mobile} ?disabled=${!d.localShow||d.busy}
aria-label=${b("summary.show_mobile")} aria-describedby="summary-mobile-show-hint"
@change=${a=>m.mutate(e=>{e.show_on_mobile=a.target.checked})} /></label>`}
@change=${a=>m.mutate(s=>{s.show_on_mobile=a.target.checked})} /></label>`}
</div>
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:s}
</section>
${d.localOnly?e:a`
${d.localOnly?s:a`
<section class="summary-blocks-card" aria-labelledby="summary-blocks-title">
<header class="summary-blocks-heading">
<div><h3 id="summary-blocks-title">${b("summary.blocks")}</h3>
@@ -42,7 +42,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
<span class="summary-block-count">${b("summary.block_count").replace("{count}",String(d.draft.blocks.length)).replace("{limit}","10")}</span>
</header>
<div class="summary-editor-blocks">
${d.draft.blocks.map((s,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
${d.draft.blocks.map((e,t)=>{const u=`blocks.${t}`,n=v(`${u}.scope`);return a`<article class="summary-editor-block"
@dragover=${a=>a.preventDefault()}
@drop=${a=>m.drop(a,`block:${t}`)}>
<div class="summary-block-head">
@@ -57,40 +57,40 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
?disabled=${t===d.draft.blocks.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t+1)})}>${l("down")}</button>
</div>
<input class="summary-block-title" type="text" .value=${s.title}
<input class="summary-block-title" type="text" .value=${e.title}
placeholder=${b("summary.block_title")} aria-label=${b("summary.block_title")}
?disabled=${d.busy}
data-summary-error=${String(d.attempted&&y?.path===`${u}.title`)}
aria-invalid=${"error"===v(`${u}.title`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].title=a.target.value})} />
@input=${a=>m.mutate(s=>{s.blocks[t].title=a.target.value})} />
<button class="summary-icon-button summary-visibility" type="button"
?disabled=${d.busy} aria-pressed=${String(s.visible)}
title=${b(s.visible?"summary.hide_block":"summary.show_block")}
aria-label=${b(s.visible?"summary.hide_block":"summary.show_block")}
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(s.visible?"eye":"eyeOff")}</button>
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:e}
?disabled=${d.busy} aria-pressed=${String(e.visible)}
title=${b(e.visible?"summary.hide_block":"summary.show_block")}
aria-label=${b(e.visible?"summary.hide_block":"summary.show_block")}
@click=${()=>m.mutate(a=>{a.blocks[t].visible=!a.blocks[t].visible})}>${l(e.visible?"eye":"eyeOff")}</button>
${v(`${u}.title`)?a`<div class="summary-problem error summary-block-title-error">${h(v(`${u}.title`))}</div>`:s}
<div class="summary-scope">
<select .value=${s.scope.type} ?disabled=${d.busy}
<select .value=${e.scope.type} ?disabled=${d.busy}
aria-label=${b("summary.scope")}
@change=${a=>m.mutate(e=>{e.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
<option value="all" ?selected=${"all"===s.scope.type}>${b("summary.scope_all")}</option>
<option value="space" ?selected=${"space"===s.scope.type}>${b("summary.scope_space")}</option>
@change=${a=>m.mutate(s=>{s.blocks[t].scope="space"===a.target.value?{type:"space",space_id:c._space}:{type:"all"}})}>
<option value="all" ?selected=${"all"===e.scope.type}>${b("summary.scope_all")}</option>
<option value="space" ?selected=${"space"===e.scope.type}>${b("summary.scope_space")}</option>
</select>
${"space"===s.scope.type?a`<select .value=${s.scope.space_id}
${"space"===e.scope.type?a`<select .value=${e.scope.space_id}
?disabled=${d.busy} aria-label=${b("summary.scope_space")}
data-summary-error=${String(d.attempted&&y?.path===`${u}.scope`)}
aria-invalid=${"error"===n?.kind?"true":"false"}
@change=${a=>m.mutate(e=>{const s=e.blocks[t].scope;"space"===s.type&&(s.space_id=a.target.value)})}>
${c._model.some(a=>a.id===s.scope.space_id)?e:a`<option value=${s.scope.space_id} selected>${s.scope.space_id}</option>`}
${c._model.map(e=>a`<option value=${e.id}
?selected=${e.id===s.scope.space_id}>${e.title}</option>`)}
</select>`:e}
@change=${a=>m.mutate(s=>{const e=s.blocks[t].scope;"space"===e.type&&(e.space_id=a.target.value)})}>
${c._model.some(a=>a.id===e.scope.space_id)?s:a`<option value=${e.scope.space_id} selected>${e.scope.space_id}</option>`}
${c._model.map(s=>a`<option value=${s.id}
?selected=${s.id===e.scope.space_id}>${s.title}</option>`)}
</select>`:s}
</div>
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:s}
</div>
<div class="summary-block-body">
<div class="summary-editor-values">
${s.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===s.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${s.id}\n${i.id}`;return a`<div class="summary-editor-value"
${e.values.map((i,c)=>{const n=`${u}.values.${c}`,$=v(`${n}.source`),p=m.sourceToken(i.source),k=d.activeSource?.blockId===e.id&&d.activeSource.valueId===i.id,g="system"===i.source.type?b(`summary.system.${i.source.key}`):i.source.entity_id?m.entityIndex.labels.get(i.source.entity_id)||i.source.entity_id:b("summary.select_source"),_="system"===i.source.type?b("summary.system_source"):i.source.entity_id,f=k?o(m.entityIndex,d.entityFilter):null,w="entity"===i.source.type&&!!i.source.entity_id&&!m.entityIndex.labels.has(i.source.entity_id),S=`${e.id}\n${i.id}`;return a`<div class="summary-editor-value"
@dragover=${a=>{a.preventDefault(),a.stopPropagation()}}
@drop=${a=>m.drop(a,`value:${t}:${c}`)}>
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
@@ -101,7 +101,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c-1)})}>${l("up")}</button>
<button class="summary-icon-button" type="button" title=${b("summary.down")}
aria-label=${b("summary.down")}
?disabled=${c===s.values.length-1||d.busy}
?disabled=${c===e.values.length-1||d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values=r(a.blocks[t].values,c,c+1)})}>${l("down")}</button>
</div>
<div class="summary-value-label">
@@ -109,8 +109,8 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
placeholder=${b("summary.value_name")} aria-label=${b("summary.value_name")}
data-summary-error=${String(d.attempted&&y?.path===`${n}.label`)}
aria-invalid=${"error"===v(`${n}.label`)?.kind?"true":"false"}
@input=${a=>m.mutate(e=>{e.blocks[t].values[c].label=a.target.value})} />
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:e}
@input=${a=>m.mutate(s=>{s.blocks[t].values[c].label=a.target.value})} />
${v(`${n}.label`)?a`<div class="summary-problem error">${h(v(`${n}.label`))}</div>`:s}
</div>
<div class="summary-source-field">
<button class="summary-source" type="button"
@@ -119,9 +119,9 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
data-summary-error=${String(d.attempted&&y?.path===`${n}.source`)}
aria-invalid=${"error"===$?.kind?"true":"false"}
aria-haspopup="listbox" aria-expanded=${k?"true":"false"}
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(s.id,i.id)}}>
@click=${a=>{a.stopPropagation(),k?m.closeSource():m.openSource(e.id,i.id)}}>
<span class="summary-source-caption"><strong>${g}</strong>
${_?a`<small>${_}</small>`:e}</span>
${_?a`<small>${_}</small>`:s}</span>
${l("chevron")}
</button>
${k&&f?a`<div class="summary-source-picker"
@@ -135,39 +135,39 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
<div class="summary-source-results" role="listbox"
aria-label=${b("summary.select_source")}>
<div class="summary-source-group">${b("summary.system_group")}</div>
${["device_count","total_area","datetime"].map(e=>a`
${["device_count","total_area","datetime"].map(s=>a`
<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`system:${e}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`system:${e}`)}>
${b(`summary.system.${e}`)}
aria-selected=${p===`system:${s}`?"true":"false"}
@click=${()=>m.setSource(e.id,i.id,`system:${s}`)}>
${b(`summary.system.${s}`)}
</button>`)}
${w?a`<div class="summary-source-group">${b("summary.current_source")}</div>
<button type="button" role="option" class="broken" aria-selected="true"
?disabled=${d.busy}
@click=${()=>m.setSource(s.id,i.id,p)}>
@click=${()=>m.setSource(e.id,i.id,p)}>
${g}
</button>`:e}
</button>`:s}
<div class="summary-source-group">${b("summary.entities_group")}</div>
${f.entries.map(e=>a`<button type="button" role="option"
${f.entries.map(s=>a`<button type="button" role="option"
?disabled=${d.busy}
aria-selected=${p===`entity:${e.id}`?"true":"false"}
@click=${()=>m.setSource(s.id,i.id,`entity:${e.id}`)}>
<span>${e.label}</span><small>${e.id}</small>
aria-selected=${p===`entity:${s.id}`?"true":"false"}
@click=${()=>m.setSource(e.id,i.id,`entity:${s.id}`)}>
<span>${s.label}</span><small>${s.id}</small>
</button>`)}
${f.total?e:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:e}
${f.total?s:a`<div class="summary-empty">${b("summary.no_search_results")}</div>`}
${f.truncated?a`<div class="summary-refine">${b("summary.refine_search")}</div>`:s}
</div>
</div>`:e}
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
</div>`:s}
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:s}
</div>
<button class="summary-icon-button summary-value-remove" type="button"
title=${b("btn.delete")} aria-label=${b("btn.delete")} ?disabled=${d.busy}
@click=${()=>m.mutate(a=>{a.blocks[t].values.splice(c,1)})}>${l("close")}</button>
</div>`})}
<button class="summary-add summary-add-value" type="button"
?disabled=${s.values.length>=20||d.busy}
title=${s.values.length>=20?b("summary.limit_values"):""}
?disabled=${e.values.length>=20||d.busy}
title=${e.values.length>=20?b("summary.limit_values"):""}
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
${l("plus")}${b("summary.add_value")}
</button>
@@ -186,11 +186,11 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7d
${l("plus")}${b("summary.add_block")}
</button>
</section>
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:s}
${d.conflict?a`<button class="btn ghost summary-reload" type="button"
?disabled=${d.busy} @click=${()=>m.reload()}>
<ha-icon icon="mdi:reload"></ha-icon>${b("summary.reload_current")}
</button>`:e}
</button>`:s}
`}
</div>
<div class="row summary-editor-footer" slot="footer">
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{eM as e,O as t,b5 as n,M as i,af as o,ad as r,ce as s,eN as a,cd as c,b4 as l,b3 as d,b6 as u,eO as f,eP as m,cg as g,eQ as v}from"./houseplan-card-DGdbxgxC.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";import{e$ as e,O as t,b5 as n,M as i,af as o,ad as r,cf as s,f0 as a,ce as l,b4 as c,b3 as d,b6 as u,f1 as f,f2 as m,ch as g,f3 as v}from"./houseplan-card-BOhGeQlv.js";function y(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,l="entity"===r?e.registry.entities?.[s]:null,c="device"===r?s:l?.device_id,d="device"===r?a?.area_id:l?.area_id||c&&e.registry.devices?.[c]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;c&&e.registry.devices?.[c]&&e.spaceIds.has(f)&&(!n.devices.has(c)||"entity"===r&&n.liveEntities.has(s))&&t.add(c)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(c,d,u=h){try{let f=0;for(const m of d){const d=c.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const l=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(l,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=l(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:g(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";var e={"furn.title":"Möbelbibliothek","furn.symbol":"Symbol","furn.group_furniture":"Möbel","furn.group_appliance":"Geräte","furn.group_sanitary":"Sanitär","furn.group_other":"Sonstiges","furn.width":"Breite","furn.depth":"Tiefe","furn.back_to_categories":"Alle Kategorien","furn.pick_hint":"Wählen Sie eine Kategorie und dann ein Symbol.","furn.place_hint":"Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.","furn.cat_air_conditioner":"Klimaanlagen","furn.cat_armchair":"Sessel","furn.cat_bathtub":"Badewannen","furn.cat_bed":"Betten","furn.cat_bidet":"Bidets","furn.cat_boiler":"Warmwasserbereiter","furn.cat_chair":"Stühle","furn.cat_coffee_table":"Couchtische","furn.cat_computer":"Computer","furn.cat_cooktop":"Kochfelder","furn.cat_dining_table":"Esstische","furn.cat_dishwasher":"Geschirrspüler","furn.cat_dryer":"Wäschetrockner","furn.cat_exercise":"Fitnessgerät","furn.cat_fireplace":"Kamine","furn.cat_fridge":"Kühlschränke","furn.cat_hood":"Dunstabzugshaube","furn.cat_kitchen_cabinet":"Küchenschränke","furn.cat_kitchen_sink":"Küchenspülen","furn.cat_nightstand":"Schränke","furn.cat_oven":"Backofen","furn.cat_plant":"Pflanzen","furn.cat_rug":"Teppiche","furn.cat_shelving":"Regale","furn.cat_shower":"Duschen","furn.cat_sink":"Waschbecken","furn.cat_sofa":"Sofas","furn.cat_stairs":"Treppen","furn.cat_toilet":"Toiletten","furn.cat_tv":"Fernseher","furn.cat_wardrobe":"Kleiderschränke","furn.cat_washer":"Waschmaschinen","furn.cat_work_table":"Schreibtische","furn.sym_exercise":"Fitnessgerät","furn.sym_computer":"Computer","furn.sym_hood":"Dunstabzugshaube","furn.sym_oven":"Backofen","furn.sym_sofa":"Zweisitzer-Sofa","furn.sym_sofa_three_seat":"Dreisitzer-Sofa","furn.sym_sofa_corner_right":"Ecksofa rechts","furn.sym_armchair":"Polstersessel","furn.sym_armchair_office":"Bürostuhl","furn.sym_coffee_table":"Rechteckiger Couchtisch","furn.sym_coffee_table_round":"Runder Couchtisch","furn.sym_coffee_table_oval":"Ovaler Couchtisch","furn.sym_coffee_table_rounded":"Abgerundeter Couchtisch","furn.sym_table_dining":"Rechteckiger Esstisch","furn.sym_table_round":"Rundtisch","furn.sym_table_dining_oval":"Ovaler Esstisch","furn.sym_table_dining_rounded":"Abgerundeter Esstisch","furn.sym_chair":"Stuhl","furn.sym_chair_bar":"Barhocker","furn.sym_desk":"Rechteckiger Schreibtisch","furn.sym_desk_corner":"Eckschreibtisch","furn.sym_bed_double":"Doppelbett","furn.sym_bed_single":"Einzelbett","furn.sym_nightstand":"Nachttisch","furn.sym_cabinet_tv":"TV-Schrank","furn.sym_cabinet_shoe":"Schuhschrank","furn.sym_cabinet_sink":"Waschtischunterschrank","furn.sym_wardrobe":"Kleiderschrank","furn.sym_bookshelf":"Bücherschrank","furn.sym_wall_unit":"Schrankwand","furn.sym_kitchen_floor":"Küchen-Unterschrank","furn.sym_kitchen_floor_corner":"Küchen-Eckunterschrank","furn.sym_kitchen_wall":"Küchen-Hängeschrank","furn.sym_kitchen_wall_corner":"Küchen-Eckhängeschrank","furn.sym_shelf_floor":"Standregal","furn.sym_shelf_wall":"Wandregal","furn.sym_fridge":"Kühlschrank","furn.sym_stove":"Vierflammiges Kochfeld","furn.sym_cooktop_two":"Zweiflammiges Kochfeld","furn.sym_dishwasher":"Geschirrspüler","furn.sym_washer":"Waschmaschine","furn.sym_dryer":"Wäschetrockner","furn.sym_tv":"Fernseher mit Standfuß","furn.sym_tv_wall":"Wandfernseher","furn.sym_ac":"Klimaanlage","furn.sym_water_heater":"Wassererhitzer","furn.sym_toilet":"Stand-WC","furn.sym_toilet_built_in":"Wand-WC","furn.sym_bathtub":"Rechteckige Badewanne","furn.sym_bathtub_corner":"Eckbadewanne","furn.sym_shower":"Dusche","furn.sym_sink":"Waschbecken","furn.sym_kitchen_sink":"Einzel-Küchenspüle","furn.sym_kitchen_sink_double":"Doppel-Küchenspüle","furn.sym_bidet":"Stand-Bidet","furn.sym_bidet_built_in":"Wand-Bidet","furn.sym_stairs":"Treppe","furn.sym_fireplace":"Kamin","furn.sym_plant":"Pflanze","furn.sym_rug":"Teppich"};const r="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,r as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";var e={"furn.title":"Bibliothèque de mobilier","furn.symbol":"Symbole","furn.group_furniture":"Mobilier","furn.group_appliance":"Électroménager","furn.group_sanitary":"Sanitaires","furn.group_other":"Autre","furn.width":"Largeur","furn.depth":"Profondeur","furn.back_to_categories":"Toutes les catégories","furn.pick_hint":"Choisissez un symbole, puis cliquez sur le plan.","furn.place_hint":"Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.","furn.cat_air_conditioner":"Climatiseurs","furn.cat_armchair":"Fauteuils","furn.cat_bathtub":"Baignoires","furn.cat_bed":"Lits","furn.cat_bidet":"Bidets","furn.cat_boiler":"Chauffe-eau","furn.cat_chair":"Chaises","furn.cat_coffee_table":"Tables basses","furn.cat_computer":"Ordinateur","furn.cat_cooktop":"Plaques de cuisson","furn.cat_dining_table":"Tables à manger","furn.cat_dishwasher":"Lave-vaisselle","furn.cat_dryer":"Sèche-linge","furn.cat_exercise":"Équipement de sport","furn.cat_fireplace":"Cheminées","furn.cat_fridge":"Réfrigérateurs","furn.cat_hood":"Hotte","furn.cat_kitchen_cabinet":"Meubles de cuisine","furn.cat_kitchen_sink":"Éviers de cuisine","furn.cat_nightstand":"Meubles","furn.cat_oven":"Four","furn.cat_plant":"Plantes","furn.cat_rug":"Tapis","furn.cat_shelving":"Étagères","furn.cat_shower":"Douches","furn.cat_sink":"Lavabos","furn.cat_sofa":"Canapés","furn.cat_stairs":"Escaliers","furn.cat_toilet":"Toilettes","furn.cat_tv":"Téléviseurs","furn.cat_wardrobe":"Armoires","furn.cat_washer":"Lave-linge","furn.cat_work_table":"Bureaux","furn.sym_exercise":"Équipement de sport","furn.sym_computer":"Ordinateur","furn.sym_hood":"Hotte","furn.sym_oven":"Four","furn.sym_sofa":"Canapé","furn.sym_sofa_three_seat":"Canapé trois places","furn.sym_sofa_corner_right":"Canapé d'angle droit","furn.sym_armchair":"Fauteuil","furn.sym_armchair_office":"Chaise de bureau","furn.sym_coffee_table":"Table basse","furn.sym_coffee_table_round":"Table basse ronde","furn.sym_coffee_table_oval":"Table basse ovale","furn.sym_coffee_table_rounded":"Table basse arrondie","furn.sym_table_dining":"Table à manger","furn.sym_table_round":"Table ronde","furn.sym_table_dining_oval":"Table à manger ovale","furn.sym_table_dining_rounded":"Table à manger arrondie","furn.sym_chair":"Chaise","furn.sym_chair_bar":"Tabouret de bar","furn.sym_desk":"Bureau","furn.sym_desk_corner":"Bureau d'angle","furn.sym_bed_double":"Lit double","furn.sym_bed_single":"Lit simple","furn.sym_nightstand":"Table de chevet","furn.sym_cabinet_tv":"Meuble TV","furn.sym_cabinet_shoe":"Meuble à chaussures","furn.sym_cabinet_sink":"Meuble sous lavabo","furn.sym_wardrobe":"Armoire","furn.sym_bookshelf":"Bibliothèque","furn.sym_wall_unit":"Élément mural","furn.sym_kitchen_floor":"Module de cuisine bas","furn.sym_kitchen_floor_corner":"Module de cuisine bas d'angle","furn.sym_kitchen_wall":"Module de cuisine haut","furn.sym_kitchen_wall_corner":"Module de cuisine haut d'angle","furn.sym_shelf_floor":"Étagère au sol","furn.sym_shelf_wall":"Étagère murale","furn.sym_fridge":"Réfrigérateur","furn.sym_stove":"Cuisinière","furn.sym_cooktop_two":"Plaque à deux feux","furn.sym_dishwasher":"Lave-vaisselle","furn.sym_washer":"Lave-linge","furn.sym_dryer":"Sèche-linge","furn.sym_tv":"Téléviseur","furn.sym_tv_wall":"Téléviseur mural","furn.sym_ac":"Climatiseur","furn.sym_water_heater":"Chauffe-eau","furn.sym_toilet":"Toilettes","furn.sym_toilet_built_in":"Toilettes suspendues","furn.sym_bathtub":"Baignoire","furn.sym_bathtub_corner":"Baignoire d'angle","furn.sym_shower":"Douche","furn.sym_sink":"Lavabo","furn.sym_kitchen_sink":"Évier de cuisine","furn.sym_kitchen_sink_double":"Évier de cuisine double","furn.sym_bidet":"Bidet","furn.sym_bidet_built_in":"Bidet suspendu","furn.sym_stairs":"Escalier","furn.sym_fireplace":"Cheminée","furn.sym_plant":"Plante","furn.sym_rug":"Tapis"};const r="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,r as fingerprint};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Zeigt Eltern und nächste Hops laut Integration. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt den Elternknoten eines Endgeräts oder den aktiven nächsten Hop eines Routers zum Koordinator laut Integration. Es gibt höchstens einen ausgehenden Pfeil; eingehende Pfeile zeigen Geräte, die über diesen Knoten routen. Gewöhnliche Nachbarn werden ausgeblendet. Alle Linien sind durchgezogen. Linie und Pfeil verwenden LQI 0–255: rot bei 0, gelb bei 128, grün bei 255. Ohne LQI sind sie grau mit schwarzer Kontur. Eine Beschriftung am Pfeilende nennt ein Ziel in einem anderen Bereich oder außerhalb des Plans. Keine Routendaten bedeutet fehlende eindeutige Angaben, nicht dass das Gerät offline ist. Dies sind zuletzt empfangene Integrationsdaten, keine Live-Paketverfolgung oder Garantie des gesamten Pfads. Der ZHA-Cache kann beim Lesen bereits alt sein; veraltete und unvollständige Daten werden markiert. Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan mit Routen kann länger als 10 Minuten dauern. House Plan wartet im Hintergrund weiter. Der Scan kann Zigbee vorübergehend verlangsamen.",scan_connecting:"Verbindung zu Zigbee2MQTT wird hergestellt",scan_waiting:"Warten auf die Karte von Zigbee2MQTT",scan_elapsed:"Vergangen: {time}",scan_background_hint:"Du kannst die Einstellungen schließen — das Warten wird im Hintergrund fortgesetzt.",scan_long_wait:"Zigbee2MQTT hat noch nicht geantwortet. Das Warten wird fortgesetzt.",scan_cancel:"Anfrage abbrechen",scan_cancel_hint:"Nur das Warten von House Plan wird beendet. Zigbee2MQTT kann den Scan fortsetzen.",scan_cancelled:"Warten abgebrochen. Der Scan von Zigbee2MQTT ist möglicherweise noch nicht beendet.",scan_backend_required:"Aktualisieren Sie die House-Plan-Integration und starten Sie Home Assistant neu, um Kartenscans im Hintergrund zu nutzen.",scan_connection_lost:"Die MQTT-Verbindung wurde unterbrochen; das Ergebnis ist möglicherweise nicht angekommen. Das Warten wurde beendet, Zigbee2MQTT scannt möglicherweise weiter.",scan_frontend_disconnected:"Die Verbindung zu Home Assistant wurde unterbrochen. Das Warten wird im Hintergrund fortgesetzt. Stelle die Verbindung wieder her, um den Status zu sehen.",scan_unavailable:"MQTT ist in Home Assistant nicht verfügbar oder wird nicht unterstützt.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · unvollständige Daten",status_no_links:"Empfangen: {time} · keine bestätigten Routen",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich",route_other_space_named:"{space} ({name})",route_device_not_on_plan_named:"Gerät ist nicht im Plan ({name})",route_unknown:"Keine Routendaten",route_stale:"Veraltete Daten",route_partial:"Unvollständige Daten",status_cache:"Integrationscache; Zeitpunkt des Funkscans unbekannt"};const n="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,n as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";export{e as dictionary,n as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";export{e as dictionary,s as fingerprint};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Affiche les parents et prochains sauts signalés par l’intégration. Le survol ne lance aucune requête.",help:"Affiche le parent d’un appareil terminal ou le prochain saut actif d’un routeur vers le coordinateur selon l’intégration. Il y a au plus une flèche sortante ; les flèches entrantes indiquent les appareils passant par ce nœud. Les simples voisins sont masqués. Toutes les lignes sont continues. Ligne et flèche utilisent le LQI 0–255 : rouge à 0, jaune à 128, vert à 255. Sans LQI, elles sont grises avec un contour noir. Une étiquette au bout de la flèche nomme une cible dans un autre espace ou absente du plan. Aucune donnée de route indique l’absence d’information univoque, pas un appareil hors ligne. Il s’agit des derniers relevés de l’intégration, pas du trajet des paquets en direct ni d’une garantie du chemin entier. Le cache ZHA peut déjà être ancien à la lecture ; les données anciennes et incomplètes sont signalées. Les liens ne sont visibles qu’à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau avec les routes peut prendre plus de 10 minutes. House Plan continue d’attendre en arrière-plan. L’analyse peut réduire temporairement la réactivité de Zigbee.",scan_connecting:"Connexion à Zigbee2MQTT",scan_waiting:"En attente de la carte de Zigbee2MQTT",scan_elapsed:"Temps écoulé : {time}",scan_background_hint:"Vous pouvez fermer les réglages — l’attente continuera en arrière-plan.",scan_long_wait:"Zigbee2MQTT n’a pas encore répondu. L’attente continue.",scan_cancel:"Annuler la demande",scan_cancel_hint:"Seule l’attente de House Plan est annulée. L’analyse de Zigbee2MQTT peut continuer.",scan_cancelled:"Attente annulée. L’analyse de Zigbee2MQTT n’est peut-être pas encore terminée.",scan_backend_required:"Mettez à jour l’intégration House Plan et redémarrez Home Assistant pour utiliser les analyses de carte en arrière-plan.",scan_connection_lost:"La connexion MQTT a été perdue ; le résultat n’est peut-être pas arrivé. L’attente est terminée, mais Zigbee2MQTT peut encore analyser le réseau.",scan_frontend_disconnected:"La connexion à Home Assistant a été interrompue. L’attente continue en arrière-plan. Reconnectez-vous pour retrouver l’état de la demande.",scan_unavailable:"MQTT n’est pas disponible ou n’est pas pris en charge dans Home Assistant.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · données incomplètes",status_no_links:"Reçu : {time} · aucune route confirmée",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace",route_other_space_named:"{space} ({name})",route_device_not_on_plan_named:"Appareil absent du plan ({name})",route_unknown:"Aucune donnée de route",route_stale:"Données anciennes",route_partial:"Données incomplètes",status_cache:"Cache de l’intégration ; date de l’analyse radio inconnue"};const n="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";export{e as dictionary,n as fingerprint};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const a="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";export{e as dictionary,a as fingerprint};
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 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";import{n as t,c as n}from"./zigbee-topology-DZ_-WbFy.js";import{aP as e}from"./houseplan-card-DGdbxgxC.js";import"./namespace-language-CPNZBROg.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function m(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function h(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,h=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,h=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&m(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=m(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&h?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,h as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2c4b7fa5cbc37ca9d9ac95d53f27dcefde7da87e20d87774a9e7ec19a9399004";try{await import("./houseplan-assets/houseplan-card-DGdbxgxC.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2b8ff615f3468a97d8f68ae4207e75d7cbb972f222fa241ba26a675f2d433a83";try{await import("./houseplan-assets/houseplan-card-BOhGeQlv.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
File diff suppressed because one or more lines are too long
@@ -35,6 +35,7 @@ from .const import (
PLANS_URL,
VERSION,
)
from .led_strips import led_strip_link_report, plan_only_strips, unbind_strips
from .radar_validation import validate_marker_radars
from .store import HouseplanData
from .validation import (
@@ -294,6 +295,10 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
projected["decor"] = [
_project_plan_only_decor(shape) for shape in space.get("decor") or []
]
led_strips = plan_only_strips(space)
if led_strips is not None:
# #780: plan-only keeps the strip geometry and never a device link.
projected["led_strips"] = led_strips
if "stairs" in space:
projected["stairs"] = [
_pick_fields(stair, (
@@ -694,6 +699,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
# before an import token can be issued.
if model > 0:
config_candidate["model_version"] = model
# #780: the schema step unbinds strips whose marker is not in the
# document; count them before it does, for the import summary.
unbound_led_strips = (
led_strip_link_report(config_candidate)["unbound"]
if isinstance(config_candidate, dict) else 0
)
config = CONFIG_SCHEMA(config_candidate)
config.pop("model_version", None)
except (vol.Invalid, TypeError, ValueError) as err:
@@ -730,6 +741,10 @@ def parse_document(raw: bytes) -> dict[str, Any]:
**(document.get("transfer") or {}),
"dropped_marker_links": dropped_marker_links,
}
# Computed here, never trusted from the file.
document["transfer"].pop("unbound_led_strips", None)
if unbound_led_strips:
document["transfer"]["unbound_led_strips"] = unbound_led_strips
if plan_only:
_validate_plan_only_document(document, config, layout, placement)
if len(json.dumps(
@@ -1608,6 +1623,14 @@ def build_space_merge(
marker.pop("value_source", None)
dropped_marker_links += 1
# #780: an LED strip follows its marker through the same id remap; a
# marker that was skipped or virtualised by the duplicate policy is not
# transferred, so the strip arrives unbound (geometry kept, no device).
unbound_led_strips = unbind_strips(space, remap={
old_id: new_id for old_id, new_id in marker_map.items()
if old_id not in virtualized_targets
})
output_markers_by_id = {
str(marker.get("id")): marker for marker in output_markers
if marker.get("id") is not None
@@ -1709,6 +1732,7 @@ def build_space_merge(
"virtualized": virtualized,
"orphan_markers": len(output_markers) - len(marker_map),
"dropped_marker_links": dropped_marker_links,
"unbound_led_strips": unbound_led_strips,
"repaired_target_refs": repaired_target_refs,
"preserved_unresolved_refs": sum(
int(value) for value in reference_report["preservedUnresolved"].values()
@@ -1914,6 +1938,14 @@ def _materialize_import_candidate(
details = {
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
}
# #780: strips unbound by the document itself (counted while parsing)
# plus those the transfer unbound (space merge) or the schema step will.
transfer = prepared.get("transfer") if isinstance(prepared.get("transfer"), dict) else {}
details["unbound_led_strips"] = (
int(details.get("unbound_led_strips", 0))
+ int(transfer.get("unbound_led_strips", 0) or 0)
+ led_strip_link_report(config)["unbound"]
)
try:
config = CONFIG_SCHEMA(config)
except vol.Invalid as err:
+315
View File
@@ -0,0 +1,315 @@
"""LED strips: stored shape, link normalisation and transfer rules (#780).
A strip is optional geometry of a space::
space.led_strips = [{"id": str, "points": [[x, y], ...],
"marker": str | None, "active": bool (optional)}]
``active`` is the representation of the bound marker: absent/``True`` draws
the strip, ``False`` keeps the shape hidden while the same marker is shown as
an ordinary icon. It is neither ``marker.hidden`` nor the power state.
The module is pure (voluptuous only), so the schema, the write-path
normalisation and the import/plan-only projections are exercised by the pure
pytest suite without Home Assistant.
Write-path order (ТЗ #780 §9):
1. structure, types and limits (``LED_STRIPS_SCHEMA`` inside ``SPACE_SCHEMA``);
2. per-space invariants and duplicate links — **before** any normalisation, so
two strips pointing at the same missing id are still a conflict;
3. normalisation: a link to a marker that is not live in the resulting config
becomes an unbound strip (``marker: None, active: True``) with its id and
points intact — a client that does not know about strips may delete a bound
marker and its save must not fail; a live marker with an empty ``space``
adopts the strip's space;
4. referential check: a live marker with a non-empty foreign ``space``
rejects the whole write.
"""
from __future__ import annotations
import copy
import math
from collections.abc import Iterator
from typing import Any
import voluptuous as vol
MAX_LED_STRIPS = 50
MAX_LED_POINTS = 50
LED_ID_MAX = 64
LED_MARKER_MAX = 500
CANVAS_LIMIT = 5000.0
LED_KEY = "led_strips"
def _coordinate(value: Any) -> float:
"""A stored coordinate: a real finite number, never a string or a bool."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise vol.Invalid("LED strip coordinate must be a number")
number = float(value)
if not math.isfinite(number):
raise vol.Invalid("LED strip coordinate must be finite")
if abs(number) > CANVAS_LIMIT:
raise vol.Invalid("LED strip coordinate is outside the canvas")
return number
def _strict_bool(value: Any) -> bool:
if not isinstance(value, bool):
raise vol.Invalid("LED strip active must be a boolean")
return value
def _marker_ref(value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str) or not value or len(value) > LED_MARKER_MAX:
raise vol.Invalid("LED strip marker must be a non-empty string or null")
return value
def _strip_id(value: Any) -> str:
if not isinstance(value, str) or not value or len(value) > LED_ID_MAX:
raise vol.Invalid("LED strip id must be a non-empty string")
return value
_POINT = vol.All([_coordinate], vol.Length(min=2, max=2))
LED_STRIP_SCHEMA = vol.Schema(
{
vol.Required("id"): _strip_id,
vol.Required("points"): vol.All(
list, vol.Length(min=2, max=MAX_LED_POINTS), [_POINT],
),
vol.Optional("marker", default=None): _marker_ref,
vol.Optional("active"): _strict_bool,
},
extra=vol.ALLOW_EXTRA,
)
LED_STRIPS_SCHEMA = vol.All(list, vol.Length(max=MAX_LED_STRIPS), [LED_STRIP_SCHEMA])
def _same(a: list[Any], b: list[Any]) -> bool:
return float(a[0]) == float(b[0]) and float(a[1]) == float(b[1])
def strip_is_closed(points: list[Any]) -> bool:
return len(points) >= 4 and _same(points[0], points[-1])
def polyline_length(points: list[Any]) -> float:
total = 0.0
for index in range(1, len(points)):
ax, ay = points[index - 1]
bx, by = points[index]
total += math.hypot(float(bx) - float(ax), float(by) - float(ay))
return total
def _distinct_vertices(points: list[Any]) -> int:
seen: set[tuple[float, float]] = set()
for point in points:
seen.add((float(point[0]), float(point[1])))
return len(seen)
def validate_strip_shape(strip: dict[str, Any]) -> None:
"""Geometry invariants that the type schema cannot express."""
points = strip["points"]
if _distinct_vertices(points) < 2 or not polyline_length(points) > 0:
raise vol.Invalid("LED strip needs at least two distinct points")
if _same(points[0], points[-1]) and len(points) > 2:
# A repeated first point closes the strip: three distinct vertices.
if _distinct_vertices(points[:-1]) < 3:
raise vol.Invalid("a closed LED strip needs three distinct vertices")
if strip.get("active") is False and not isinstance(strip.get("marker"), str):
raise vol.Invalid("a hidden LED strip shape must belong to a marker")
class LedStripLinkError(ValueError):
"""A write that cannot be normalised: duplicate or foreign link."""
code = "invalid_led_strip"
def __init__(self, reason: str) -> None:
# Marker ids may carry user-controlled HA identifiers: a stable
# message without the value, like DuplicateMarkerIdError.
super().__init__(reason)
self.reason = reason
def _live_markers(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
live: dict[str, dict[str, Any]] = {}
for marker in config.get("markers") or []:
if not isinstance(marker, dict) or marker.get("removed") is True:
continue
marker_id = marker.get("id")
if isinstance(marker_id, str) and marker_id and marker_id not in live:
live[marker_id] = marker
return live
def _strips(config: dict[str, Any]) -> Iterator[tuple[dict[str, Any], dict[str, Any]]]:
for space in config.get("spaces") or []:
if not isinstance(space, dict):
continue
for strip in space.get(LED_KEY) or []:
if isinstance(strip, dict):
yield space, strip
def preserve_led_strips(config: dict[str, Any], previous: dict[str, Any] | None) -> int:
"""An ordinary writer that omits ``led_strips`` keeps the stored shapes (#780 r1 H1).
A previous frontend does not know the field and sends every space without
it; the omission is not a deletion. A new client deletes shapes by sending
an explicit list (``[]`` removes all of them). Applies to spaces that keep
their id; a space the writer removed takes its strips with it. Runs on the
ordinary write path before normalisation, so a preserved link to a marker
the same write deleted becomes an unbound strip by the usual rule.
Returns how many spaces received their stored shapes back.
"""
stored = {
space.get("id"): space[LED_KEY]
for space in (previous or {}).get("spaces") or []
if isinstance(space, dict) and isinstance(space.get(LED_KEY), list) and space[LED_KEY]
}
restored = 0
for space in config.get("spaces") or []:
if not isinstance(space, dict) or LED_KEY in space:
continue
shapes = stored.get(space.get("id"))
if shapes:
space[LED_KEY] = copy.deepcopy(shapes)
restored += 1
return restored
def led_strip_link_report(config: dict[str, Any]) -> dict[str, int]:
"""What the normaliser would change, without changing anything.
The write path answers the client with these counters so a new client
knows to re-read; an old client ignores the extra keys.
"""
live = _live_markers(config)
unbound = adopted = 0
for _space, strip in _strips(config):
ref = strip.get("marker")
if not isinstance(ref, str) or not ref:
continue
marker = live.get(ref)
if marker is None:
unbound += 1
elif marker.get("space") in (None, ""):
adopted += 1
return {"unbound": unbound, "space_adopted": adopted}
def normalize_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
"""Validate and normalise every strip of a whole configuration in place.
Runs after the type schema and the coordinate canonicalisation, so the
shape invariants judge the stored numbers.
"""
taken: set[str] = set()
for space in config.get("spaces") or []:
if not isinstance(space, dict):
continue
ids: set[str] = set()
for strip in space.get(LED_KEY) or []:
if strip["id"] in ids:
raise vol.Invalid("LED strip ids must be unique within a space")
ids.add(strip["id"])
validate_strip_shape(strip)
ref = strip.get("marker")
if isinstance(ref, str):
if ref in taken:
raise LedStripLinkError("a marker is bound to more than one LED strip")
taken.add(ref)
live = _live_markers(config)
for space, strip in _strips(config):
ref = strip.get("marker")
if not isinstance(ref, str):
strip["marker"] = None
continue
marker = live.get(ref)
if marker is None:
strip["marker"] = None
strip["active"] = True
continue
owner = marker.get("space")
if owner in (None, ""):
marker["space"] = space["id"]
elif owner != space["id"]:
raise LedStripLinkError("an LED strip is bound to a marker of another space")
return config
def config_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
"""voluptuous step for CONFIG_SCHEMA: ``LedStripLinkError`` → ``vol.Invalid``."""
try:
return normalize_led_strip_links(config)
except LedStripLinkError as err:
raise vol.Invalid(f"{LedStripLinkError.code}: {err.reason}") from err
def unbind_strips(space: dict[str, Any], keep: set[str] | None = None,
remap: dict[str, str] | None = None) -> int:
"""Transfer rule for imports/copies: remap a link or make the strip unbound.
``remap`` maps the source marker id to the id it received in the target;
a marker that was not transferred (skipped duplicate, plan-only, dropped)
leaves an unbound strip with ``active: True``. Returns how many strips
became unbound, for the import summary.
"""
unbound = 0
for strip in space.get(LED_KEY) or []:
if not isinstance(strip, dict):
continue
ref = strip.get("marker")
if not isinstance(ref, str) or not ref:
strip["marker"] = None
continue
target = (remap or {}).get(ref)
if target is None and keep is not None and ref in keep:
target = ref
if target is None:
strip["marker"] = None
strip["active"] = True
unbound += 1
else:
strip["marker"] = target
return unbound
def plan_only_strips(space: dict[str, Any]) -> list[dict[str, Any]] | None:
"""Plan-only export: geometry stays, device links never leak."""
if LED_KEY not in space:
return None
projected = []
for strip in space.get(LED_KEY) or []:
if not isinstance(strip, dict):
continue
projected.append({
"id": strip.get("id"),
"points": [list(point) for point in strip.get("points") or []],
"marker": None,
"active": True,
})
return projected
def led_strip_counts(config: dict[str, Any]) -> dict[str, int]:
"""Support-package counters: no coordinates, names or HA identifiers."""
total = unbound = hidden = 0
for _space, strip in _strips(config):
total += 1
if not isinstance(strip.get("marker"), str):
unbound += 1
elif strip.get("active") is False:
hidden += 1
return {"led_strips": total, "led_strips_unbound": unbound, "led_strips_hidden": hidden}
+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-beta.2"
"version": "1.80.0-beta.4"
}
+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]
@@ -22,6 +22,7 @@ from .const import (
MAX_SUPPORT_ATTACHMENT_BYTES,
PLAN_MODEL_VERSION,
)
from .led_strips import led_strip_counts
PACKAGE_FORMAT = "houseplan-support-package"
PACKAGE_VERSION = 1
@@ -457,10 +458,21 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
"lifecycle": dict(sorted(lifecycles.items())),
"binding": dict(sorted(bindings.items())),
},
# #780: counters only — no coordinates, room names or HA identifiers.
"led_strips": _led_strip_summary(spaces),
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
}
def _led_strip_summary(spaces: list[Any]) -> dict[str, int]:
counts = led_strip_counts({"spaces": [space for space in spaces if isinstance(space, dict)]})
return {
"total": counts["led_strips"],
"unbound": counts["led_strips_unbound"],
"hidden": counts["led_strips_hidden"],
}
def _binding_kind(value: object) -> str:
text = str(value or "")
if text == "virtual":
+14 -1
View File
@@ -17,6 +17,11 @@ from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_layout_geometry,
canonicalize_position,
)
from custom_components.houseplan.led_strips import (
LED_STRIPS_SCHEMA,
config_led_strip_links,
preserve_led_strips,
)
from custom_components.houseplan.vacuum_routes import validate_marker_routes
# ---------- limits and extension sets ----------
@@ -1877,6 +1882,8 @@ SPACE_SCHEMA = vol.All(vol.Schema(
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
# list is not a limit, it is a hope. `Remove` returns the key stripped.
vol.Remove("segments"): object,
# #780: optional LED strip shapes of the space (led_strips.py).
vol.Optional("led_strips"): LED_STRIPS_SCHEMA,
},
extra=vol.ALLOW_EXTRA,
), _space_geometry_invariants)
@@ -2268,13 +2275,16 @@ def prepare_ordinary_summary_candidate(
readable_entity_ids: set[str],
normalize,
) -> dict:
"""Apply the one summary-panel contract shared by ordinary config writers.
"""Apply the one preservation contract shared by ordinary config writers.
`normalize` owns the writer-specific schema/migration sequence. Preservation
must happen before it; change-aware reference validation must happen after
it. Authoritative full import/restore intentionally does not use this helper.
Preserved: the summary panel namespace (#437) and the LED strip shapes of a
space an older writer sent without the field (#780).
"""
preserve_summary_panel_namespace(candidate, previous)
preserve_led_strips(candidate, previous)
checked = normalize(candidate)
validate_summary_panel_references(checked, previous, readable_entity_ids)
return checked
@@ -2353,4 +2363,7 @@ CONFIG_SCHEMA = vol.All(
),
canonicalize_config_geometry,
_config_wall_segment_invariants,
# #780: after canonicalisation — shape invariants judge stored numbers;
# orphan links become unbound strips, foreign/duplicate links reject.
config_led_strip_links,
)
+17 -1
View File
@@ -70,6 +70,7 @@ from .import_export import (
revalidate_candidate,
)
from .junction_limits import JunctionLimitError, validate_junction_limits
from .led_strips import led_strip_link_report, preserve_led_strips
from .plans import (
QuotaError,
collect_attachments,
@@ -257,6 +258,7 @@ async def _resolved_write_pair(
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
from .radar_websocket import async_register as async_register_radar
from .zigbee_websocket import async_register as async_register_zigbee
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
@@ -287,6 +289,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_support_preview_discard)
websocket_api.async_register_command(hass, ws_support_submit)
async_register_radar(hass)
async_register_zigbee(hass)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -1480,6 +1483,8 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
"decor_assets_api": DECOR_ASSETS_API_VERSION,
"summary_panel_api": SUMMARY_PANEL_API_VERSION,
**({"radar_stage1_api": 1} if rt.radar_coordinator is not None else {}),
**({"zigbee_scan_api": 1} if rt.zigbee_coordinator is not None
and not rt.zigbee_coordinator.closed else {}),
},
)
@@ -1681,6 +1686,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
radar_registry = radar_registry_evidence(hass)
# #780: what the write path will normalise in LED strip links (an
# orphan becomes unbound, an empty marker.space adopts the strip's
# space). Reported back so a new client re-reads; old ones ignore it.
# An older writer omits the field: its stored shapes are kept first
# (r1 H1), so the report also counts their links.
preserve_led_strips(msg["config"], data.get("config"))
led_report = led_strip_link_report(msg["config"])
def _validate_config_cpu():
def _normalize(candidate):
validate_wall_model_transition(candidate, data.get("config"))
@@ -1789,7 +1802,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
from .repairs import async_check_plan_files
hass.async_create_task(async_check_plan_files(hass, entry))
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
result: dict[str, Any] = {"ok": True, "rev": new_rev}
if any(led_report.values()):
result["led_strips"] = led_report
connection.send_result(msg["id"], result)
# ---------------- whole-plan maintenance ----------------
@@ -0,0 +1,441 @@
"""Bounded, entry-owned Zigbee2MQTT requests; never a polling radio scanner."""
from __future__ import annotations
import asyncio
import json
import logging
import math
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from time import monotonic, time
from typing import Any
from uuid import uuid4
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
MAX_TOPICS = 8
MAX_LISTENERS = 32
MAX_PAYLOAD_BYTES = 2 * 1024 * 1024
MAX_NODES = 1000
MAX_LINKS = 6000
CANCEL_AFTER_MS = 600_000
TRANSPORT_TIMEOUT = 10.0
INFO_TIMEOUT = 4.0
_LOGGER = logging.getLogger(__name__)
class ZigbeeScanError(Exception):
"""A stable, already localized WebSocket command refusal."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def normalize_base_topic(value: object) -> str:
"""Match the frontend's exact-topic normalization, without a saved-topic gate."""
if not isinstance(value, str):
raise ZigbeeScanError("invalid_data")
topic = re.sub(r"/{2,}", "/", value.strip().strip("/"))
# JS counts UTF-16 code units; reject lone surrogates as invalid MQTT UTF-8.
try:
length = len(topic.encode("utf-16-le")) // 2
topic.encode("utf-8")
except UnicodeError as err:
raise ZigbeeScanError("invalid_data") from err
if not topic or length > 180 or re.search(r"[#+\x00-\x1f\x7f]", topic):
raise ZigbeeScanError("invalid_data")
return topic
def _parse_payload(payload: object) -> dict[str, Any] | None:
"""Bound bytes before decoding; uncorrelatable garbage must not end a job."""
try:
if isinstance(payload, str):
payload = payload.encode("utf-8")
if not isinstance(payload, bytes) or len(payload) > MAX_PAYLOAD_BYTES:
return None
value = json.loads(payload)
if not isinstance(value, dict):
return None
pending = [(value, 0)]
inspected = 0
while pending:
item, depth = pending.pop()
inspected += 1
if depth > 32 or inspected > 100_000:
return None
if isinstance(item, dict):
pending.extend((child, depth + 1) for child in item.values())
elif isinstance(item, list):
pending.extend((child, depth + 1) for child in item)
elif isinstance(item, float) and not math.isfinite(item):
return None
return value
except (ValueError, UnicodeError, RecursionError):
return None
def _valid_map(message: dict[str, Any]) -> bool:
"""Check the raw-map shape, not routing evidence (that remains TypeScript)."""
data = message.get("data")
value = data.get("value", data) if isinstance(data, dict) else data
if value is None:
value = message.get("value", message)
if isinstance(value, str):
value = _parse_payload(value)
if not isinstance(value, dict):
return False
nodes, links = value.get("nodes"), value.get("links")
if not isinstance(nodes, list) or len(nodes) > MAX_NODES:
return False
if not isinstance(links, list) or len(links) > MAX_LINKS:
return False
for node in nodes:
if not isinstance(node, dict):
return False
ieee = node.get("ieeeAddr", node.get("ieee_address", node.get("ieee")))
if not isinstance(ieee, str) or not re.fullmatch(
r"(?:0x)?[0-9a-fA-F]{16}|(?:[0-9a-fA-F]{2}[:-]){7}[0-9a-fA-F]{2}", ieee,
):
return False
routes_count = 0
for link in links:
if not isinstance(link, dict):
return False
for side in ("source", "target"):
endpoint = link.get(f"{side}IeeeAddr", link.get(side))
if isinstance(endpoint, bool) or not isinstance(endpoint, (dict, str, int)):
return False
if not endpoint and endpoint != 0:
return False
routes = link.get("routes", [])
if not isinstance(routes, list) or any(not isinstance(route, dict) for route in routes):
return False
routes_count += len(routes)
if routes_count > MAX_LINKS:
return False
return True
@dataclass
class _Job:
topic: str
job_id: str
started_at: int
started: float
info: asyncio.Future[None]
phase: str = "loading"
stage: str = "connecting"
finished: float | None = None
error: str | None = None
stale: bool = False
result: dict[str, Any] | None = None
obtained_at: int | None = None
response_active: bool = False
task: asyncio.Task[None] | None = None
cleanups: list[Callable[[], None]] = field(default_factory=list)
class ZigbeeScanCoordinator:
"""One in-memory slot per topic, shared by all browsers of the HA entry."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self.entry = entry
self.session_id = str(uuid4())
self.revision = 0
self.closed = False
self._jobs: dict[str, _Job] = {}
self._listeners: set[Callable[[dict[str, Any]], None]] = set()
self._operations: set[asyncio.Task] = set()
def _active(self, job: _Job) -> bool:
return not self.closed and self._jobs.get(job.topic) is job and job.phase == "loading"
def _state(self, job: _Job) -> dict[str, Any]:
provider: dict[str, Any] = {
"topic": job.topic, "job_id": job.job_id, "phase": job.phase,
"stage": job.stage, "started_at": job.started_at,
"elapsed_ms": max(0, int(((job.finished if job.finished is not None else monotonic()) - job.started) * 1000)),
"cancel_after_ms": CANCEL_AFTER_MS,
}
if job.error is not None:
provider["error"] = job.error
if job.result is not None:
provider["result"] = job.result
provider["obtained_at"] = job.obtained_at
provider["stale"] = job.stale
return {"kind": "state", "session_id": self.session_id,
"revision": self.revision, "provider": provider}
def snapshot(self, topic: str) -> dict[str, Any]:
"""Current public state; no MQTT work, no timestamp reset."""
if self.closed:
raise ZigbeeScanError("not_ready")
job = self._jobs.get(normalize_base_topic(topic))
if job is None:
raise ZigbeeScanError("invalid_data")
return self._state(job)
def initial_events(self) -> list[dict[str, Any]]:
"""Bound each event to one map; never aggregate eight maps into one frame."""
return [{"kind": "reset", "session_id": self.session_id,
"revision": self.revision, "topics": list(self._jobs)},
*(self._state(job) for job in self._jobs.values())]
def add_listener(self, listener: Callable[[dict[str, Any]], None]) -> Callable[[], None]:
if self.closed:
raise ZigbeeScanError("not_ready")
if len(self._listeners) >= MAX_LISTENERS:
raise ZigbeeScanError("capacity_exceeded")
self._listeners.add(listener)
return lambda: self._listeners.discard(listener)
def _emit(self, event: dict[str, Any]) -> None:
for listener in tuple(self._listeners):
try:
listener(event)
except Exception: # noqa: BLE001 - one disconnected UI must not interrupt a shared job
self._listeners.discard(listener)
def _changed(self, job: _Job) -> None:
self.revision += 1
self._emit(self._state(job))
def start(self, base_topic: str) -> dict[str, Any]:
"""Reserve before creating any task: concurrent WS clients join one job."""
if self.closed:
raise ZigbeeScanError("not_ready")
topic = normalize_base_topic(base_topic)
previous = self._jobs.get(topic)
if previous is not None and previous.phase == "loading":
return self._state(previous)
# A transport which acknowledges cancellation late still owns a bounded
# slot until its cleanup returns; repeated failed starts cannot grow it.
if len(self._operations) >= MAX_TOPICS:
raise ZigbeeScanError("capacity_exceeded")
if previous is None and len(self._jobs) >= MAX_TOPICS:
candidate = next((item for item in self._jobs.values() if item.phase != "loading"), None)
if candidate is None:
raise ZigbeeScanError("capacity_exceeded")
del self._jobs[candidate.topic]
self.revision += 1
self._emit({"kind": "removed", "session_id": self.session_id,
"revision": self.revision, "topic": candidate.topic})
job = _Job(topic, f"houseplan-{uuid4()}", int(time() * 1000), monotonic(),
self.hass.loop.create_future(),
stale=previous.stale if previous else False,
result=previous.result if previous else None,
obtained_at=previous.obtained_at if previous else None)
self._jobs.pop(topic, None)
self._jobs[topic] = job
job.task = self.entry.async_create_background_task(
self.hass, self._run(job), "House Plan Zigbee map", eager_start=False,
)
self._changed(job)
return self._state(job)
def cancel(self, base_topic: str, job_id: str) -> dict[str, Any]:
if self.closed:
raise ZigbeeScanError("not_ready")
topic = normalize_base_topic(base_topic)
job = self._jobs.get(topic)
if job is None or job.job_id != job_id:
raise ZigbeeScanError("conflict")
# A repeated cancellation or a success/cancel race never rolls back a final state.
if job.phase != "loading":
return self._state(job)
if (monotonic() - job.started) * 1000 < CANCEL_AFTER_MS:
raise ZigbeeScanError("conflict")
self._finish(job, "cancelled")
return self._state(job)
@staticmethod
def _cleanup(cleanup: Callable[[], None]) -> None:
try:
cleanup()
except Exception: # noqa: BLE001 - cleanup remains idempotent across unload and transport failure
_LOGGER.debug("House Plan Zigbee transport cleanup failed")
def _add_cleanup(self, job: _Job, cleanup: Callable[[], None]) -> None:
if self._active(job):
job.cleanups.append(cleanup)
else:
self._cleanup(cleanup)
def _release(self, job: _Job) -> None:
job.response_active = False
cleanups, job.cleanups = job.cleanups, []
for cleanup in cleanups:
self._cleanup(cleanup)
if not job.info.done():
job.info.cancel()
def _finish(self, job: _Job, phase: str, error: str | None = None,
result: dict[str, Any] | None = None) -> None:
if not self._active(job):
return
job.phase, job.error, job.finished = phase, error, monotonic()
if result is not None:
job.result, job.obtained_at = result, int(time() * 1000)
job.stale = False
elif job.result is not None:
job.stale = True
self._release(job)
# Including when called synchronously inside publish: a terminal provider
# reply must not wait another ten seconds for the publish acknowledgement.
if job.task is not None and not job.task.done():
job.task.cancel()
self._changed(job)
async def _operation(self, job: _Job, action: Callable[[], Awaitable],
seconds: float, *, subscription: bool = False):
"""Bound transport even if a cancelled subscribe later returns an unsubscribe."""
async def perform():
result = await action()
if subscription and callable(result):
self._add_cleanup(job, result)
return result
task = self.entry.async_create_background_task(
self.hass, perform(), "House Plan Zigbee transport", eager_start=False,
)
self._operations.add(task)
@callback
def settled(done: asyncio.Task) -> None:
self._operations.discard(done)
if not done.cancelled():
done.exception() # consume a late transport exception after the job ended
task.add_done_callback(settled)
try:
done, _pending = await asyncio.wait({task}, timeout=max(0, seconds))
if not done:
raise TimeoutError
return task.result()
finally:
if not task.done():
task.cancel()
async def _subscribe(self, job: _Job, topic: str, handler: Callable) -> None:
deadline = self.hass.loop.time() + TRANSPORT_TIMEOUT
await self._operation(job, lambda: mqtt.async_subscribe(
self.hass, topic, handler, qos=0, encoding=None,
), TRANSPORT_TIMEOUT, subscription=True)
# Optional on the declared HA floor (2024.6). This acknowledges HA's
# processing of subscriptions, not provider acceptance or radio progress.
processed = getattr(mqtt, "async_on_subscribe_done", None)
if callable(processed) and self._active(job):
ready = self.hass.loop.create_future()
@callback
def on_ready() -> None:
if not ready.done():
ready.set_result(None)
remove = processed(self.hass, topic, 0, on_ready)
self._add_cleanup(job, remove)
async with asyncio.timeout(max(0, deadline - self.hass.loop.time())):
await ready
async def _run(self, job: _Job) -> None:
try:
if not mqtt.mqtt_config_entry_enabled(self.hass):
self._finish(job, "error", "unsupported")
return
if not mqtt.is_connected(self.hass):
self._finish(job, "error", "connection")
return
@callback
def connection_changed(connected: bool) -> None:
if not connected:
self._finish(job, "error", "connection")
self._add_cleanup(job, mqtt.async_subscribe_connection_status(
self.hass, connection_changed,
))
response_topic = f"{job.topic}/bridge/response/networkmap"
info_topic = f"{job.topic}/bridge/info"
@callback
def receive_response(message) -> None:
if (not self._active(job) or not job.response_active
or message.topic != response_topic or message.retain):
return
value = _parse_payload(message.payload)
if value is None:
return
data = value.get("data")
transaction = value.get("transaction", data.get("transaction") if isinstance(data, dict) else None)
if transaction != job.job_id:
return
if value.get("status") not in (None, "ok"):
self._finish(job, "error", "provider")
elif not _valid_map(value):
self._finish(job, "error", "invalid_payload")
else:
self._finish(job, "ready", result=value)
@callback
def receive_info(message) -> None:
if (self._active(job) and message.topic == info_topic and message.retain
and not job.info.done() and _parse_payload(message.payload) is not None):
job.info.set_result(None)
# Response first also improves the old-HA retained-info preflight;
# retained info alone is not claimed to be a strict broker SUBACK.
await self._subscribe(job, response_topic, receive_response)
await self._subscribe(job, info_topic, receive_info)
async with asyncio.timeout(INFO_TIMEOUT):
await job.info
if not self._active(job):
return
job.response_active = True
await self._operation(job, lambda: mqtt.async_publish(
self.hass, f"{job.topic}/bridge/request/networkmap",
json.dumps({"type": "raw", "routes": True, "transaction": job.job_id}),
qos=0, retain=False,
), TRANSPORT_TIMEOUT)
if self._active(job):
job.stage = "waiting"
self._changed(job)
# Only MQTT, explicit cancel, transport loss or entry teardown
# ends this wait. 600 seconds is a cancel threshold, not a deadline.
await self.hass.loop.create_future()
except asyncio.CancelledError:
if self._active(job):
self._finish(job, "error", "connection")
raise
except TimeoutError:
self._finish(job, "error", "timeout")
except Exception: # noqa: BLE001 - contain optional MQTT errors without logging network payloads
self._finish(job, "error", "connection")
finally:
self._release(job)
async def async_close(self) -> None:
"""Stop entry-owned work, clear volatile maps, never restart a radio scan."""
if self.closed:
return
self.closed = True
# Reload does not disconnect HA WebSockets. Tell their observers that
# this session and its volatile cache are gone before dropping them.
self.revision += 1
self._emit({"kind": "closed", "session_id": self.session_id,
"revision": self.revision})
tasks = [job.task for job in self._jobs.values() if job.task is not None]
for job in self._jobs.values():
self._release(job)
for task in (*tasks, *self._operations):
if not task.done():
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._jobs.clear()
self._listeners.clear()
@@ -0,0 +1,91 @@
"""Admin-only WebSocket observers and explicit actions for background Z2M jobs."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .store import get_data
from .zigbee_topology import ZigbeeScanCoordinator, ZigbeeScanError
def _coordinator(hass: HomeAssistant, connection, msg_id: int) -> ZigbeeScanCoordinator | None:
runtime = get_data(hass)
coordinator = getattr(runtime, "zigbee_coordinator", None)
if not isinstance(coordinator, ZigbeeScanCoordinator) or coordinator.closed:
connection.send_error(msg_id, "not_ready", "not_ready")
return None
return coordinator
@callback
def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_zigbee_subscribe)
websocket_api.async_register_command(hass, ws_zigbee_start)
websocket_api.async_register_command(hass, ws_zigbee_cancel)
@websocket_api.websocket_command({vol.Required("type"): "houseplan/zigbee/subscribe"})
@websocket_api.require_admin
def ws_zigbee_subscribe(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
coordinator = _coordinator(hass, connection, msg["id"])
if coordinator is None:
return
@callback
def publish(event: dict[str, Any]) -> None:
# Permission can be revoked during a long-lived browser session.
if connection.user is None or not connection.user.is_admin:
remove()
connection.subscriptions.pop(msg["id"], None)
return
connection.send_event(msg["id"], event)
try:
remove = coordinator.add_listener(publish)
except ZigbeeScanError as err:
connection.send_error(msg["id"], err.code, err.code)
return
# HA calls this on unsubscribe/disconnect. It never cancels the shared job.
connection.subscriptions[msg["id"]] = remove
connection.send_result(msg["id"], {"session_id": coordinator.session_id,
"revision": coordinator.revision})
for event in coordinator.initial_events():
publish(event)
@websocket_api.websocket_command({
vol.Required("type"): "houseplan/zigbee/start",
vol.Required("base_topic"): vol.All(str, vol.Length(min=1, max=1024)),
})
@websocket_api.require_admin
def ws_zigbee_start(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
coordinator = _coordinator(hass, connection, msg["id"])
if coordinator is None:
return
try:
result = coordinator.start(msg["base_topic"])
except ZigbeeScanError as err:
connection.send_error(msg["id"], err.code, err.code)
return
connection.send_result(msg["id"], result)
@websocket_api.websocket_command({
vol.Required("type"): "houseplan/zigbee/cancel",
vol.Required("base_topic"): vol.All(str, vol.Length(min=1, max=1024)),
vol.Required("job_id"): vol.All(str, vol.Length(min=1, max=80)),
})
@websocket_api.require_admin
def ws_zigbee_cancel(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
coordinator = _coordinator(hass, connection, msg["id"])
if coordinator is None:
return
try:
result = coordinator.cancel(msg["base_topic"], msg["job_id"])
except ZigbeeScanError as err:
connection.send_error(msg["id"], err.code, err.code)
return
connection.send_result(msg["id"], result)
+493
View File
@@ -0,0 +1,493 @@
#!/usr/bin/env node
/**
* #780 ТЗ §13.2: the `led-strips-v1` performance profile.
*
* The `large-house-v1` fixture (200 devices, 3 floors, 60 rooms, 100
* openings) with 10 or 50 of each floor's existing devices shown as LED
* strips that are on: `--size=10x5` (10 strips × 5 points) or `--size=50x50`
* (50 strips × 50 points); `--size=none` is the same build and plan without
* strips. No icon is added: the converted devices keep their ids, only their
* entity becomes a light. Light radius 30 cm (the strip default), Glow on,
* viewport 1440×1000, DPR 1, reduced motion. `--warmups` (≥1) samples are
* discarded, then `--samples` (≥7) are judged. Every sample is cold: a new
* browser with an empty cache mounts a new card; the warm metrics run in the
* same page after the module has loaded.
*
* Metrics (judged on median AND p95 against budgets-led-strips.json):
* firstStableRenderMs (mount → stripes and fields of every visible strip,
* cold import included), warmSpaceReadyMs (space switch with the module
* loaded → stable LED frame), stateUpdateMs (the interaction profile's method:
* hass update → every stripe in its new state), panZoomMs (the interaction
* profile's camera scenario, benchmark_large_house.mjs) and the longest Long
* Task of the camera — the scenario and the 100-step series, retained heap
* after 20 A→B→C→A cycles over a warm cycle (same GC protocol).
* #789 additionally observes cameraFullCycleLongTaskMaxMs continuously through
* full-quality restoration, immediate restart and a 500 ms tail, with the same
* camera ceiling. Historical camera windows remain unchanged; each row retains
* raw full-cycle Long Tasks and phase boundaries for audit.
*
* Counters (exact, every sample): geometry/visibility recomputes over 100
* unrelated HA ticks, 100 pan/zoom steps and a colour-only change (0); the
* three LED caches of the shown space — shapes ≤ 50, visibility ≤ 50,
* compact visibility paths ≤ 2500 and retained path text ≤ 4 Mi characters
* (≤ 8 MiB UTF-16) — identical after every cycle (growth 0). The true fan
* count remains diagnostic: #788 removed lossy endpoint/vertex thinning;
* after disconnect 0 retained entries and 0 live LED timers/observers; a
* late import after disconnect restores nothing (one extra cold run with the
* runtime response delayed); LED chunk requests (none without strips, never
* the editor chunk in the View).
*
* `--merge=a.json,b.json` judges the rows of several partial reports of the
* same size as one run — the samples are the same cold samples, only the
* process that collected them differs (a runner with a short step limit);
* `--warmup-only` collects the discarded warm-up of such a run.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs';
import { ledChunkRequestName } from './performance/led-chunk-request.mjs';
import { cameraCycleFailures, cameraCycleSampleFailures, finishLedCameraCycle } from './performance/led-camera-cycle.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const warmupOnly = process.argv.includes('--warmup-only');
const samples = warmupOnly ? 0 : Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = warmupOnly ? 1 : Math.max(0, Math.min(5, Number(valueArg('warmups') ?? 1)));
const size = valueArg('size') ?? '10x5';
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const merge = valueArg('merge')?.split(',').filter(Boolean).map((file) => resolve(file)) ?? null;
const skipLateImport = process.argv.includes('--no-late-import');
const SIZES = { '10x5': [10, 5], '50x50': [50, 50], none: [0, 0] };
if (!SIZES[size]) throw new Error(`unknown size: ${size}`);
const [STRIPS, POINTS] = SIZES[size];
const PROFILE = 'led-strips-v1';
const CYCLES = Math.max(1, Math.min(20, Number(valueArg('cycles')) || 20));
const MIN_SAMPLES = 7;
const fixture = makeLedStripsFixture(STRIPS, POINTS);
const sourceSha = (() => {
try { return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { return null; }
})();
const budgets = JSON.parse(readFileSync(new URL('./performance/budgets-led-strips.json', import.meta.url), 'utf8'));
/**
* Installed in the page before the card mounts anything: timers, frames and
* observers created from an LED chunk (its file name is in the creation
* stack) are tracked until they fire, are cleared or disconnected.
*/
function installLedLifecycleTracker() {
if (window.__ledLive) return;
const live = { timers: new Set(), frames: new Set(), observers: new Set() };
window.__ledLive = live;
const fromLed = () => /led-strip-/.test(new Error().stack || '');
const nativeTimeout = window.setTimeout, nativeClearTimeout = window.clearTimeout;
window.setTimeout = function setTimeout(fn, ms, ...rest) {
const mine = fromLed();
let id = 0;
id = nativeTimeout.call(window, typeof fn === 'function'
? (...args) => { live.timers.delete(id); return fn(...args); } : fn, ms, ...rest);
if (mine) live.timers.add(id);
return id;
};
window.clearTimeout = function clearTimeout(id) { live.timers.delete(id); return nativeClearTimeout.call(window, id); };
const nativeInterval = window.setInterval, nativeClearInterval = window.clearInterval;
window.setInterval = function setInterval(fn, ms, ...rest) {
const id = nativeInterval.call(window, fn, ms, ...rest);
if (fromLed()) live.timers.add(id);
return id;
};
window.clearInterval = function clearInterval(id) { live.timers.delete(id); return nativeClearInterval.call(window, id); };
const nativeFrame = window.requestAnimationFrame, nativeCancelFrame = window.cancelAnimationFrame;
window.requestAnimationFrame = function requestAnimationFrame(fn) {
const mine = fromLed();
let id = 0;
id = nativeFrame.call(window, (time) => { live.frames.delete(id); return fn(time); });
if (mine) live.frames.add(id);
return id;
};
window.cancelAnimationFrame = function cancelAnimationFrame(id) { live.frames.delete(id); return nativeCancelFrame.call(window, id); };
for (const name of ['ResizeObserver', 'MutationObserver', 'IntersectionObserver']) {
const Native = window[name];
if (!Native) continue;
window[name] = class extends Native {
constructor(callback) {
super(callback);
if (fromLed()) live.observers.add(this);
}
disconnect() { live.observers.delete(this); return super.disconnect(); }
};
}
}
/** The page side of one mount: the same fixture host as before, shared by the sample and the late-import run. */
function pageHost() {
return `
window.__finishLedCameraCycle = ${finishLedCameraCycle.toString()};
window.__ledHost = (fixture) => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 });
let states = fixture.states;
const hassBase = {
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get') return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
return { ok: true };
},
callService: async () => undefined,
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
};
return {
card,
get states() { return states; },
setStates(next) { states = next; card.hass = { ...hassBase, states }; },
};
};
window.__ledStats = async (card) => {
const url = performance.getEntriesByType('resource').map((entry) => entry.name)
.find((name) => /led-strip-runtime-[^/]+\\.js/.test(name));
if (!url) return { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0, loaded: false };
const runtime = await import(url);
return { ...runtime.ledStats(card), loaded: true };
};
window.__ledLiveCounts = () => ({
timers: window.__ledLive.timers.size, frames: window.__ledLive.frames.size,
observers: window.__ledLive.observers.size,
});
`;
}
async function sample() {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc']);
const chromium = await browser.version();
await page.emulateMedia({ reducedMotion: 'reduce' });
const requests = [];
page.on('request', (request) => {
const name = ledChunkRequestName(request.url());
if (name) requests.push(name);
});
try {
await page.evaluate(installLedLifecycleTracker);
await page.evaluate(pageHost());
const row = await page.evaluate(async ({ fixture, strips, cycles }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const until = async (predicate, timeout = 30000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out');
await sleep(5);
}
};
const longTasks = (onEntries) => {
const entries = [];
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return async () => {
await sleep(0);
entries.push(...observer.takeRecords());
observer.disconnect();
onEntries?.(entries.map(({ startTime, duration }) => ({ startTime, duration })));
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
};
};
const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } };
window.__card?.remove?.();
localStorage.clear();
const host = window.__ledHost(fixture);
const { card } = host;
const root = () => card.renderRoot;
const count = (selector) => root()?.querySelectorAll(selector).length ?? 0;
const ledStable = (state = 'on') => count(`[data-led-strip][data-state="${state}"]`) === strips
&& (state !== 'on' || count('.led-fields [data-led-field]') === strips);
const stats = () => window.__ledStats(card);
const started = performance.now();
document.getElementById('host').replaceChildren(card);
host.setStates(host.states);
await until(() => card._loadOk && card._model?.length === 3);
if (strips) await until(() => ledStable());
else { await until(() => card._booting === false); await frame(); }
await frame();
const firstStableRenderMs = Number((performance.now() - started).toFixed(2));
const warm = performance.now();
card._pickSpace('perf-floor-2');
await card.updateComplete;
if (strips) await until(() => ledStable());
await frame();
const warmSpaceReadyMs = Number((performance.now() - warm).toFixed(2));
const states = host.states;
const floorLights = Object.keys(states).filter((id) => id.startsWith('light.perf_led_1_'));
const stateStarted = performance.now();
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable('off'));
await frame();
const stateUpdateMs = Number((performance.now() - stateStarted).toFixed(2));
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable());
await frame();
const before = await stats();
// 100 unrelated HA ticks.
const sensor = Object.keys(host.states).find((id) => id.startsWith('sensor.'));
for (let tick = 0; tick < 100; tick++) {
host.setStates({ ...host.states, [sensor]: { ...host.states[sensor], state: String(20 + (tick % 10) / 10) } });
await card.updateComplete;
}
await frame();
const afterTicks = await stats();
// The camera: the interaction profile's scenario (one wheel step, its
// render and frame — benchmark_large_house.mjs), then 100 steps whose
// Long Tasks are judged too and whose recomputes are counted.
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', {
deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true,
}));
// Keep the old pan/series windows intact. This separate observer also
// covers the first quality switch, both restores and the quick restart.
const cameraFullCycle = { startTime: performance.now(), entries: [], phases: {} };
const stopFullCycle = longTasks(entries => { cameraFullCycle.entries = entries; });
const stopPan = longTasks();
const panStarted = performance.now();
wheel(-120);
await card.updateComplete;
await frame();
const panZoomMs = Number((performance.now() - panStarted).toFixed(2));
const panZoomLongTaskMaxMs = await stopPan();
const stopSeries = longTasks();
// One wheel step per frame, as a real wheel or trackpad delivers them.
for (let step = 0; step < 100; step++) {
wheel(step % 2 ? 60 : -60);
await card.updateComplete;
await frame();
}
await frame();
const cameraSeriesLongTaskMaxMs = await stopSeries();
cameraFullCycle.phases.seriesEnd = performance.now();
Object.assign(cameraFullCycle.phases, await window.__finishLedCameraCycle({
fullQuality: () => !card.hasAttribute('data-led-zoom-quality'),
input: async () => { wheel(-60); await card.updateComplete; },
frame, now: () => performance.now(), sleep,
}));
const cameraFullCycleLongTaskMaxMs = await stopFullCycle();
cameraFullCycle.endTime = performance.now();
const afterCamera = await stats();
// A colour-only change rebuilds no geometry or visibility.
host.setStates({ ...host.states, ...Object.fromEntries(floorLights.map((id) => [id,
{ ...host.states[id], attributes: { ...host.states[id].attributes, rgb_color: [255, 120, 80] } }])) });
await card.updateComplete;
await frame();
const afterColour = await stats();
// 20 cycles A→B→C→A after one warm cycle; the three caches identical after each.
const cycle = async () => {
for (const id of ['perf-floor-1', 'perf-floor-2', 'perf-floor-3', 'perf-floor-1']) {
card._pickSpace(id);
await card.updateComplete;
if (strips) await until(() => ledStable());
}
await frame();
const { shapes, visibility, sources, visibilityPaths, pathChars } = await stats();
return { shapes, visibility, sources, visibilityPaths, pathChars };
};
await cycle();
await gc();
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
const cycleStats = [];
for (let index = 0; index < cycles; index++) cycleStats.push(await cycle());
await gc();
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const liveBeforeDisconnect = window.__ledLiveCounts();
card.remove();
await frame();
await sleep(600);
const afterDisconnect = await stats();
const liveAfterDisconnect = window.__ledLiveCounts();
const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}/${entry.visibilityPaths}/${entry.pathChars}`;
return {
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, cameraSeriesLongTaskMaxMs,
cameraFullCycleLongTaskMaxMs, cameraFullCycle,
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
counters: strips ? {
recomputesOnHaTicks: afterTicks.recomputes - before.recomputes,
recomputesOnCamera: afterCamera.recomputes - afterTicks.recomputes,
recomputesOnColour: afterColour.recomputes - afterCamera.recomputes,
shapes: Math.max(...cycleStats.map((entry) => entry.shapes)),
visibility: Math.max(...cycleStats.map((entry) => entry.visibility)),
sources: Math.max(...cycleStats.map((entry) => entry.sources)),
visibilityPaths: Math.max(...cycleStats.map((entry) => entry.visibilityPaths)),
pathChars: Math.max(...cycleStats.map((entry) => entry.pathChars)),
cacheGrowthOverCycles: new Set(cycleStats.map(key)).size - 1,
} : null,
disconnect: {
retained: afterDisconnect.shapes + afterDisconnect.visibility + afterDisconnect.sources
+ afterDisconnect.visibilityPaths + afterDisconnect.pathChars,
liveBefore: liveBeforeDisconnect,
live: liveAfterDisconnect.timers + liveAfterDisconnect.frames + liveAfterDisconnect.observers,
},
};
}, { fixture, strips: STRIPS, cycles: CYCLES });
row.ledRequests = [...new Set(requests)].sort();
return { row, chromium };
} finally {
await browser.close();
}
}
/**
* The late import: the card is removed while the runtime chunk is still on
* the wire (its response is held back); when it lands nothing is rendered,
* cached or scheduled for the gone card.
*/
async function lateImport() {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1, []);
try {
let held = false;
let runtimeUrl = null;
await page.route(/led-strip-runtime-[^/]+\.js/, async (route) => {
held = true;
runtimeUrl = route.request().url();
await new Promise((done) => setTimeout(done, 1500));
await route.fallback();
});
await page.evaluate(installLedLifecycleTracker);
await page.evaluate(pageHost());
const result = await page.evaluate(async ({ fixture }) => {
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
window.__card?.remove?.();
const host = window.__ledHost(fixture);
const { card } = host;
document.getElementById('host').replaceChildren(card);
host.setStates(host.states);
const started = performance.now();
while (!(card._loadOk && card._model?.length === 3) && performance.now() - started < 20000) await sleep(10);
await sleep(300);
const before = card.renderRoot.querySelectorAll('[data-led-strip]').length;
card.remove();
window.__lateCard = card;
await sleep(3000);
return { stripesBeforeLoad: before, stripesAfterLoad: card.renderRoot.querySelectorAll('[data-led-strip]').length };
}, { fixture });
// The chunk the card asked for, by its URL: the same module instance.
const late = await page.evaluate(async (url) => {
const runtime = url ? await import(url) : null;
return { stats: runtime ? { ...runtime.ledStats(window.__lateCard), loaded: true } : { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, loaded: false },
live: window.__ledLiveCounts() };
}, runtimeUrl);
return { ...result, ...late, held };
} finally {
await browser.close();
}
}
let rows = [];
let chromium = null;
let late = null;
let discarded = 0;
if (merge) {
for (const file of merge) {
const part = JSON.parse(readFileSync(file, 'utf8'));
if (part.profile !== PROFILE || part.size !== size) throw new Error(`${file} is not ${PROFILE} ${size}`);
rows.push(...part.rows);
discarded += part.warmups;
chromium ??= part.chromium;
late ??= part.lateImport ?? null;
}
} else {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const result = await sample();
chromium ??= result.chromium;
if (iteration >= warmups) rows.push(result.row);
}
discarded = warmups;
if (STRIPS && !skipLateImport && !warmupOnly) late = await lateImport();
}
if (warmupOnly) {
// A discarded warm-up sample for a later --merge: nothing is judged here.
const text = `${JSON.stringify({ profile: PROFILE, size, sourceSha, chromium, samples: 0, warmups: 1, rows: [], partial: true }, null, 2)}\n`;
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
console.log(text);
process.exit(0);
}
const metric = (name) => {
const values = rows.map((row) => row[name]).filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
const at = (q) => values[Math.min(values.length - 1, Math.ceil(q * values.length) - 1)];
return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values };
};
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs',
'cameraSeriesLongTaskMaxMs', 'cameraFullCycleLongTaskMaxMs', 'retainedHeapBytes'];
const report = {
profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium,
samples: rows.length, warmups: discarded, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
merged: merge ? merge.length : undefined,
metrics: Object.fromEntries(METRICS.map((name) => [name, metric(name)])),
counters: rows[0]?.counters ?? null,
counterSamples: rows.map((row) => row.counters),
disconnectSamples: rows.map((row) => row.disconnect),
lateImport: late,
ledRequests: [...new Set(rows.flatMap((row) => row.ledRequests))].sort(),
rows,
};
const failures = [];
const partial = !merge && (samples < MIN_SAMPLES || warmups < 1);
if (!partial && (report.samples < MIN_SAMPLES || report.warmups < 1)) {
failures.push(`${report.samples} samples after ${report.warmups} warmups: the profile needs ≥ ${MIN_SAMPLES} after ≥ 1`);
}
const limits = budgets.sizes[size] || {};
failures.push(...cameraCycleSampleFailures(rows));
failures.push(...cameraCycleFailures(report.metrics.cameraFullCycleLongTaskMaxMs, limits.cameraSeriesLongTaskMaxMs));
for (const [name, limit] of Object.entries(limits)) {
for (const stat of ['median', 'p95']) {
const value = report.metrics[name]?.[stat];
if (value == null) { if (name !== 'retainedHeapBytes') failures.push(`${name} ${stat} missing`); continue; }
if (value > limit) failures.push(`${name} ${stat} ${value} > ${limit}`);
}
}
for (const [index, disconnect] of report.disconnectSamples.entries()) {
if (disconnect.retained !== 0) failures.push(`sample ${index}: ${disconnect.retained} LED cache entries retained after disconnect`);
if (disconnect.live !== 0) failures.push(`sample ${index}: ${disconnect.live} LED timers/frames/observers alive after disconnect`);
}
if (STRIPS) {
const caches = budgets.caches;
for (const counters of report.counterSamples) {
for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) {
if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`);
}
for (const key of ['shapes', 'visibility', 'visibilityPaths', 'pathChars']) {
if (!Number.isFinite(counters[key])) failures.push(`${key} cache metric missing`);
else if (counters[key] > caches[key]) failures.push(`${key} cache ${counters[key]} > ${caches[key]}`);
}
}
if (late) {
if (!late.held || !late.stats.loaded) failures.push('late import: the runtime response was never held or never landed');
if (late.stripesAfterLoad !== late.stripesBeforeLoad) failures.push('late import rendered the gone card');
const retained = late.stats.shapes + late.stats.visibility + late.stats.sources
+ late.stats.visibilityPaths + late.stats.pathChars;
if (retained) failures.push(`late import restored ${retained} cache entries`);
if (late.live.timers + late.live.frames + late.live.observers) failures.push('late import left LED timers/observers');
} else if (!skipLateImport) failures.push('late import not measured');
if (!report.ledRequests.includes('led-strip-runtime') || !report.ledRequests.includes('led-strip-field'))
failures.push(`LED chunks not loaded: ${report.ledRequests.join(', ')}`);
} else if (report.ledRequests.length) failures.push(`no strips but LED chunks requested: ${report.ledRequests.join(', ')}`);
if (report.ledRequests.includes('led-strip-editor')) failures.push('the View loaded the LED editor chunk');
report.failures = failures;
report.partial = partial || undefined;
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
console.log(JSON.stringify({ ...report, rows: undefined }, null, 2));
if (failures.length) { console.error(`led-strips-v1 ${size}: ${failures.length} failure(s)`); process.exit(1); }
+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)) {
+66 -4
View File
@@ -136,7 +136,53 @@ const applianceSpace = {
decor: [],
};
const runtime = (includeAppliance = false) => {
// #780: the LED strip reference scene — the designer's four strips on a
// synthetic plan with the reference floor colour #868D94: a free straight
// strip, a strip on the inner face of the bottom wall, a polyline along the
// wall faces around a corner and a closed rectangle. The colours are scene
// values of the four light sources, not a product palette.
const ledRooms = [
{ id: 'led-living', name: 'LED living', area: 'golden_led_living',
poly: [[0.06, 0.08], [0.56, 0.08], [0.56, 0.62], [0.06, 0.62]] },
{ id: 'led-studio', name: 'LED studio', area: 'golden_led_studio',
poly: [[0.56, 0.08], [0.94, 0.08], [0.94, 0.62], [0.56, 0.62]] },
];
// Inner faces of the 20 cm walls (half thickness = 10 cm = 2 cells of 1/240).
const LED_FACE = 2 / 240;
export const LED_REFERENCE_STRIPS = Object.freeze([
{ id: 'led-free', marker: 'demo-led-strip', color: [128, 213, 255],
points: [[0.14, 0.26], [0.44, 0.26]] },
{ id: 'led-wall', marker: 'demo-led-strip-wall', color: [230, 128, 255],
points: [[0.12, round(0.62 - LED_FACE)], [0.50, round(0.62 - LED_FACE)]] },
{ id: 'led-corner', marker: 'demo-led-strip-corner', color: [255, 234, 128],
points: [[0.64, round(0.08 + LED_FACE)], [round(0.94 - LED_FACE), round(0.08 + LED_FACE)],
[round(0.94 - LED_FACE), 0.42]] },
{ id: 'led-loop', marker: 'demo-led-strip-loop', color: [88, 255, 88],
points: [[0.66, 0.30], [0.84, 0.30], [0.84, 0.52], [0.66, 0.52], [0.66, 0.30]] },
]);
const ledSpace = {
id: 'golden-led',
title: 'LED strips',
plan_url: null,
view_box: [0, 0, 1, 0.7],
cell_cm: 5,
settings: {
fill_mode: 'custom', custom_fill: { c: '#868D94', a: 1 }, glow_enabled: true,
show_borders: true, show_names: false, sun_rays: false, bg_mode: 'static',
},
rooms: ledRooms,
walls: wallsFor('led', ledRooms, 20),
openings: [
{ id: 'led-door', type: 'door', x: 0.56, y: 0.53, angle: 90, length: 0.10 },
],
partitions: [],
wall_columns: [],
decor: [],
led_strips: LED_REFERENCE_STRIPS.map(({ id, marker, points }) => ({ id, marker, points })),
};
const runtime = (includeAppliance = false, includeLed = false) => {
const devices = {};
const entities = {};
const states = {
@@ -199,6 +245,15 @@ const runtime = (includeAppliance = false) => {
unit_of_measurement: 'lqi',
});
if (includeLed) {
for (const room of ledRooms) areas[room.area] = { area_id: room.area, name: room.name };
for (const strip of LED_REFERENCE_STRIPS) {
add(strip.marker, 'light', ledRooms[strip.id === 'led-free' || strip.id === 'led-wall' ? 0 : 1].area,
0.5, 0.5, 'on', { rgb_color: strip.color, brightness: 255 });
layout[strip.marker] = { s: 'golden-led', x: 0.5, y: 0.35 };
}
}
if (includeAppliance) {
const washerId = 'golden-washer';
devices[washerId] = {
@@ -247,16 +302,23 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
});
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
export const makeVisualMatrixFixture = ({ applianceLifecycle = false, ledStrips = false } = {}) => ({
config: {
spaces: [
structuredClone(geometrySpace), structuredClone(lightingSpace),
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
...(ledStrips ? [structuredClone(ledSpace)] : []),
],
// A persisted marker is part of the fixture contract for scenarios that
// override per-source Glow controls. The device/layout alone are not a
// saved marker configuration and must not be silently treated as one.
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
markers: [
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
// #780: a strip link writes the marker's explicit space (ТЗ §5).
...(ledStrips ? LED_REFERENCE_STRIPS.map(({ marker }) => ({
id: marker, binding: `device:${marker}`, space: 'golden-led',
})) : []),
],
settings: {
glow_radius_cm: 360,
north_deg: 0,
@@ -269,7 +331,7 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) =>
},
},
},
...runtime(applianceLifecycle),
...runtime(applianceLifecycle, ledStrips),
counts: applianceLifecycle ? {
...VISUAL_MATRIX_COUNTS,
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
+54 -23
View File
@@ -1,8 +1,8 @@
{
"schema": 3,
"matrixVersion": 70,
"acceptedAt": "2026-10-01T00:05:17.573Z",
"sourceFingerprint": "182c3c9d02d59822dca143cc7626b316b9be5ba20ec8403c282b973832501e45",
"matrixVersion": 71,
"acceptedAt": "2026-10-05T12:14:58.027Z",
"sourceFingerprint": "14a902be33654296da3bbc915dcdd2c5b918936f24526ddc7a2e4b45a73afe25",
"chromium": "151.0.7922.34",
"capturedOn": "linux",
"acceptedOn": "linux",
@@ -10,18 +10,41 @@
"platform": "linux",
"arch": "x64",
"chromium": "151.0.7922.34",
"buildFingerprint": "182c3c9d02d59822dca143cc7626b316b9be5ba20ec8403c282b973832501e45",
"ci": {
"buildFingerprint": "14a902be33654296da3bbc915dcdd2c5b918936f24526ddc7a2e4b45a73afe25",
"ci": null
},
"localAttestation": {
"schema": "houseplan-golden-wsl/v1",
"sha256": "c0d5ed83d3bf2591a21fff465403ce4ab71c10df781a78736cf690a578a5df52",
"artifactSha256": "5d2082d6a19c7f4ecce6ea5bdb40da9ec76d804052847b9863f770213dd0b0c0",
"createdAt": "2026-10-05T12:14:24.651Z",
"source": {
"repository": "Matysh/houseplan-card",
"run": 36790529482,
"attempt": 1,
"sha": "16616f09b897a465dcfa7e40ad543fd28394fa9b"
"branch": "issue/798-provider-zigbee-routes",
"commit": "22828495bb3ca3c038a87b68327d068e60874d9d",
"tree": "0959cf050c960f44d6ada12c31e76335dd380430",
"remoteSha": "22828495bb3ca3c038a87b68327d068e60874d9d",
"clean": true,
"status": ""
},
"environment": {
"platform": "linux",
"arch": "x64",
"kernel": "6.18.33.2-microsoft-standard-WSL2",
"wsl": true,
"distro": "Ubuntu",
"filesystem": "ext2/ext3"
},
"toolchain": {
"node": "22.23.2",
"npm": "10.9.8",
"playwright": "1.62.0",
"chromiumExecutableSha256": "0b20b130e7edd9dd51873be867761295fe0cfad490c2b9a64f95bd3cfc08fa71"
}
},
"localAttestation": null,
"foreignCapture": null,
"witnesses": {
"count": 145,
"count": 107,
"floor": 10
},
"scenarios": {
@@ -112,17 +135,17 @@
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
"isometric-opening-symbol-parity-dark": "639e19dddf6d0a8e4f145b6850b0ad5d20d83100fd769e2aad678eb093a33067",
"geometry-devices-editor-dark": "1cc18ac3250a0cc20981b090ef7f964555b237e085e3ddbe10cc7b6081342c8c",
"geometry-devices-editor-dark": "0744447d86d071d5ad1c66e44ce3fdde40b6f06b36bdab59c5a2b2cd1da72292",
"device-inbox-desktop-en-light": "3a1229f1f5134c329ee457a62e555896ffd664d1d6cbd217e4e8248dc0c07dc7",
"device-inbox-desktop-ru-dark": "2b0981e7cc134a8f2cfe107c58833e33b862a00a89cadc5033f3b633562f9379",
"device-inbox-narrow-ru-dark": "7694119b368baf49fe8c0aeeec46a75bd77c0987266e4a64f5d13a2099e5f098",
"geometry-decor-editor-dark": "0fddb5ba477ecad15827215d04d22e9ba1adfacbbfc853712b1e76832182fa5f",
"device-inbox-narrow-ru-dark": "694b84a02e4122b973229488440e19e177b3d77dd8e9eb423304c709ffed6ad5",
"geometry-decor-editor-dark": "4077eb3f49182b5075f8cfd3c488cd6affa0fa26f7977ebb1e94cf57baabdc4e",
"tray-wide-selection-en": "e544ee6b4cde64aae188bac9be9e52c1232a3de8e98e1f68af42d8c7064f0a38",
"tray-wide-tool-ru": "d3c49be18142f280c21062612cf06dc8df6e5a076c53aeb2d62da8e3975c8003",
"tray-medium-group-en": "6ba416e0ca3cb2fba4a420703318e62d8651053d76ae1936a37537ab486af4a8",
"tray-medium-selection-ru": "d5a82d474f75f438c06a340897a9e009e7df0c95c5ef07edd3952f0fe43a0f40",
"tray-narrow-palette-en": "d2147c10ec130bead031db16090b0638188a548163ca8dfbfa86e68e71865051",
"furniture-categories-light": "8ffc0d18edc76dad428190f7c041ce9fdf9c5cc71a8329116bc1c7ae040a7fd8",
"furniture-categories-light": "4fdc9db78f6199abe86d1f982e9b1d0b71316f4ee0cbb8a1966742b141e0e0be",
"furniture-variants-dark": "368f863d070b8f2f02cf657731aab0e7d80bdf5a817193709bb2838d429ac4e1",
"furniture-variants-light": "a702b38b8bd42919a5ed8753b87489545eb6d6538726e5cc3cb856a586834974",
"furniture-placement-preview-light": "8a78103d7cdd2808496f6d73b8a65859c4e6b57ee53a0c18f4d90580945673ba",
@@ -185,25 +208,25 @@
"large-house-zoom-040-dark": "84f9e93b5fd25caaf031dc7758553c29459d4dda31e086b7d79e904c9b3d6230",
"large-house-zoom-250-dark": "fa901d097023320a599c1c80be9f9c65a0ce6245bb7182bc0f3d90d679668c17",
"large-house-warm-remount-dark": "6ed51a1c76cd7c62875eadafc169f7c6fc25604e64edabd9594a3664c886987d",
"device-dialog-desktop-en": "f5d660f9244712f016c02c560a4bd301f3729edd4ca1e63b3d2cf0fab9c71802",
"device-dialog-desktop-en": "1a3d6c9c77b263a438eb896f24282af6998ec3e478267423b5c2fbebda75c9b5",
"device-dialog-mobile-ru": "2486302565fb8dadb7758384e592b059df45f6f8b92195738e5a0bf62758c713",
"device-dialog-desktop-de": "972d56b0a0307ad7a061175aa0f6147c3170fc64eadb88bafd038835fd2ce331",
"device-dialog-desktop-de": "082969eec2b7fd472cd65a9ae162430a07bc0e72661a3b5eaae689f490369efa",
"room-temperature-dialog-desktop-en": "cea714dbe38185ea3d936fdb7cb71a0205feb74687d3ade517aed28407abd4a9",
"room-temperature-dialog-mobile-ru": "5ca1c4f18eab3fb4c0667f76b0d0340df94f24e0457baca89234e0ef159f62d6",
"room-discard-dialog-mobile-ru": "9c85af0cdbd2842808ef5ac8fdb306f621a726b0ac77c1449722c85399b4cb3f",
"room-discard-dialog-mobile-ru": "311cc2da1d85191ca26e72928a86f0846b021be85ed5ba83dd02dfe04397c10a",
"german-view-mobile-light": "1c14ef6e97f0f7c616fe61ad72f4dfa7d94c605efc3d8b26931016c8c13a4282",
"toggle-entity-dialog-desktop-en": "075a714a4f013c99614c4941b1202d9ae411e6382f72cb0d0a98e0f669b3388b",
"toggle-entity-dialog-mobile-ru": "c5bf5077a33c1acfa5f744e0ab1c15ce6c2147bc3622a7e8754d972aeedb555b",
"toggle-entity-dialog-desktop-en": "4a8de7f02995b992cb2e8110305280db4899f6fe9bbe967bb43398a5b479a694",
"toggle-entity-dialog-mobile-ru": "e6e11b5a4f0181d0115199d5c3eac93fa2db19c73bf743713a0821ce6d9fc5d9",
"device-help-popover-light-ru": "7645a419f8214dacc6b97be894643d12b2a0486342e568b0b3ee42d74b8cf878",
"decor-color-popover-mobile-ru": "bdf90e0e265d41d032db948a737eeedd6e60cdacc9f85c9356aa809894654960",
"decor-color-popover-desktop-en": "46b38745da10d4467b510c5a257102395a797e0d3229e7ce8c89e4bbf3b937e7",
"general-color-popover-desktop-en": "b54ef50909c2e7b667d321457b17205d5de38dd39b219eef05bee5ef8fb6bef2",
"general-color-popover-desktop-en": "8ffdaaff5c859539985cf77304ee0acd4044d8b13eae0d80ea9b5ce1ef9f346f",
"settings-help-zoom-200-en-light": "5539d8df2691eee0b8e90fa1fe086380870eeca5d945a34bb71e65f18bb3c22f",
"settings-help-zoom-200-ru-dark": "7be1e0c156ae3a8f71d6b55fc75e5a20e5a4404efee674fa4bebdb82004daa80",
"support-desktop-empty-light-en": "55ffd7a63b8e4d65b8cb695a7b166e0c4bb2e719ce925b8d28b8e8595897750d",
"support-desktop-preview-dark-en": "326c1b1ecbe71e0bdaed6445137ee4145418317bf5d5faf10faa297707fdb924",
"support-phone-validation-light-ru": "acc540f9459c9dc38981ccb70e75a5415f17c849d3840340648448884bd2ab31",
"support-phone-success-dark-en": "b2edd4415043d673d22dfe89ee16b9c190e7f3f9537371f01665e8e41444a00e",
"support-phone-validation-light-ru": "85baca107a2620c3d36c09ca9d4f72e029cf41661970983560efc4066f2e5688",
"support-phone-success-dark-en": "dcf2cb53e904dd3196052f5e0001b8aee712db9c9c0e4f8cd2982645e4bf7dbd",
"support-relay-error-light-en": "adf30f96a926a1730c9ad34d18b4234d9b19140f11d56c8fb7508976f366c11d",
"support-tablet-preview-dark-ru": "a69ba86bf069d9beba94eedf6cc3b385b026caeee66348e4ef42c1a072b6df6a",
"device-ripple-color-popover-mobile-ru": "8fc31025d32d87e5d3d3df896937d182577cd5169d520e7cf5fc267e7d080776",
@@ -216,6 +239,14 @@
"optimize-orphan-references-dark-en": "6a9ab05787bc789af6181e6f2abca19d3cf92ac166aac661ad8deb7c7a3c88bb",
"optimize-orphan-references-light-ru": "332e56edb151a5a820fc307d69ffd4060348978572e48704b0a2c14ba9802720",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
"led-strip-design-reference-off-light": "1a558111dbeb1962b10f47baad20b4689248a347510261152f0d6196906044fd",
"led-strip-design-reference-on-light": "1d6b4c2cfac2e1aeb9ce17ae2b3ddb62707e3f46c2ad3967b3669712a8e577fd",
"lighting-led-strip-glow-dark": "e6cdec5a16fde53bd1eca255eff554a5923f710d2b6e24904e46e39d8e5ab4ab",
"led-strip-long-zigzag-glow-light": "342749c29bac93a9a53f58921a40a10731fbcfdfff4345e71af81ce4de4dfa61",
"led-strip-endcaps-zoom-light": "468293d57b96b4b17f61027467c7c43b25d5a0f91972ba0451998cdc61ea3980",
"led-strip-rectangle-door-zoom-light": "00e8d937924bff957ef413f5d8b7c6964e44c96e90b9209ea24158217328b2ac",
"led-strip-off-light": "cbb4f4794c871d592917653e8b5713d87a6be6dfe470b2161da760c2bb3cebdc",
"iso-led-strip-dark": "6605a62b15a47da09920743793b5c7f8cb2768610b6d57b739fa91eeef883676"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 KiB

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+14 -1
View File
@@ -43,7 +43,9 @@ const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture({ includeStairs: false })
: makeVisualMatrixFixture({ applianceLifecycle: !!scenario.applianceLifecycle });
: makeVisualMatrixFixture({
applianceLifecycle: !!scenario.applianceLifecycle, ledStrips: !!scenario.ledStrips,
});
const themeVars = {
dark: {
@@ -626,6 +628,17 @@ export function prepareGoldenFixture(scenario) {
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
return space;
};
if (scenario.ledStripOverride) {
const space = requireSpace();
if (space.id !== 'golden-led' || !Array.isArray(scenario.ledStripOverride.points)
|| scenario.ledStripOverride.points.length < 2) {
throw new Error(`invalid golden ledStripOverride: ${scenario.id}`);
}
space.led_strips = [{
id: 'led-owner-repro', marker: 'demo-led-strip-loop',
points: structuredClone(scenario.ledStripOverride.points),
}];
}
if (scenario.stage3Fixture) {
const contract = scenario.stage3Fixture;
const space = requireSpace();
+40 -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 = 70;
export const GOLDEN_MATRIX_VERSION = 71;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -266,6 +266,8 @@ export const STAGE6_ACCEPTANCE_SCENARIOS = Object.freeze([
},
]);
const LED_STRIPS_OFF = Object.fromEntries(['light.demo_led_strip', 'light.demo_led_strip_wall', 'light.demo_led_strip_corner', 'light.demo_led_strip_loop'].map((id) => [id, { state: 'off' }]));
export const GOLDEN_SCENARIOS = Object.freeze([
...STAGE6_ACCEPTANCE_SCENARIOS,
// #663 AC3/AC10/AC12: both stair geometries, both rise directions and
@@ -1314,4 +1316,41 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'card-editor-invalid-default-floor-dark-ru', fixture: 'visual',
cardEditorInvalidDefaultFloor: 'removed-floor', language: 'ru', theme: 'dark',
viewport: { width: 900, height: 760 }, ...page },
// #780: LED strips. The two design references keep all four strips of the
// designer's composition at once (free, wall face, corner polyline, closed
// loop) on the reference floor #868D94; the others pin the dark Glow field,
// the off stripe in the light theme and the raised 2.5D stripe.
{ id: 'led-strip-design-reference-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'led-strip-design-reference-on-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'lighting-led-strip-glow-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'dark', viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'led-strip-long-zigzag-glow-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
// A free diagonal joins a wall-face run and returns to free floor. Before
// #785 the active visibility clip kept only the wall fans and dropped both
// free diagonals because SVG clip paths ignore open-path strokes.
ledStripOverride: { points: [[0.12, 0.15], [0.551667, 0.35], [0.551667, 0.55], [0.16, 0.45]] }, ...stage },
{ id: 'led-strip-endcaps-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
// #786: at 4× the free end and the end inside one Glow radius of masonry
// must keep the same smooth circular rim; the wall still clips its side.
zoom: 4, zoomCenter: [500, 380],
ledStripOverride: { points: [[0.43, 0.35], [0.535, 0.35]] }, ...stage },
{ id: 'led-strip-rectangle-door-zoom-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', language: 'en', theme: 'light', viewport: { width: 1000, height: 760 },
// #787: four stored corners must remain four straight visible sides. The
// right side crosses led-door: the opening is optically open, but neither
// the stripe nor its emitter line may step back to the wall axis there.
zoom: 1.15, zoomCenter: [310, 350],
ledStripOverride: { points: [[0.068333, 0.088333], [0.551667, 0.088333],
[0.551667, 0.611667], [0.068333, 0.611667], [0.068333, 0.088333]] }, ...stage },
{ id: 'led-strip-off-light', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', fillMode: 'none', stateOverrides: LED_STRIPS_OFF, language: 'en', theme: 'light',
viewport: { width: 1000, height: 760 }, ...stage },
{ id: 'iso-led-strip-dark', fixture: 'visual', ledStrips: true, space: 'golden-led',
mode: 'view', projection: 'iso', fillMode: 'none', language: 'en', theme: 'dark',
viewport: { width: 1000, height: 760 }, ...stage },
]);

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