Compare commits

...
Author SHA1 Message Date
claude[bot] 3aa9ffbd1a docs: review document for #545
Issue: #545
User-Visible: no
2026-09-12 20:36:47 +00:00
Sergey Matyunin 81daebd788 fix: complete radar source inventory (#545)
Issue: #545
User-Visible: yes
2026-09-12 23:19:16 +03:00
claude[bot] 036a15aade docs: review document for #545
Issue: #545
User-Visible: no
2026-09-12 20:07:39 +00:00
claude[bot] ce6650b1e0 docs: review document for #543
Issue: #543
User-Visible: no
2026-09-12 19:54:45 +00:00
Sergey Matyunin a1fac44d72 fix: не откатывать конфигурацию поздним reload (#543)
Issue: #543
User-Visible: yes
2026-09-12 22:29:16 +03:00
claude[bot] 3342910b27 docs: review document for #543
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 36s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 22s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 58s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 45s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m10s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Классификация изменённых файлов (push) Successful in 21s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Issue: #543
User-Visible: no
2026-09-12 18:57:06 +00:00
claude[bot] 4a91d5cbe8 docs: review document for #543
Issue: #543
User-Visible: no
2026-09-12 18:51:40 +00:00
claude[bot] 6d2facc2e3 docs: review document for #544
Issue: #544
User-Visible: no
2026-09-12 17:59:35 +00:00
Sergey Matyunin 6502f516f9 fix: keep live pan edges covered (#544)
Issue: #544
User-Visible: yes
2026-09-12 20:44:05 +03:00
claude[bot] 0f06b60084 docs: review document for #544
Issue: #544
User-Visible: no
2026-09-12 17:30:56 +00:00
Codex 66a6485418 Контракт беты пинит отсутствие ветки события, а не её наличие
`release-contract.test.mjs` требовал в анонсе условие про
`github.event_name == 'release'` — оно и было единственным местом, где путь
события закреплялся. Раз анонс вызывается только после выкладки, требование
перевёрнуто: ветки события в файле быть не должно.

Issue: #538
User-Visible: no
2026-09-12 14:02:27 +03:00
Codex 7baa1cfa13 Свидетель требует директиву, а не её цитату в комментарии
Мутант, снимающий `needs: build` с анонса, выживал: проверка искала подстроку
`needs: build` в блоке задания, а ровно эта строка процитирована двумя
строками выше — в комментарии, объясняющем, зачем зависимость нужна. Проверка
зеленела на собственном объяснении.

Issue: #538
User-Visible: no
2026-09-12 14:02:27 +03:00
Codex 293309355b Анонс уходит после выкладки ассетов, а не рядом с ней
Три воркфлоу висели на одном событии `release: published` и бежали
параллельно. Анонс выигрывал эту гонку всегда: проверять ему нечего. 12.09
стабильную v1.75.0 объявили в канале в ту же минуту, когда гейт отказал —
Full Performance был красный (#537), E2E после него не выполнялся вовсе,
ассеты не выкладывались. Подписчики получили сообщение о релизе, страница
которого осталась без `houseplan-card.js`.

Триггер события снят: у анонса остаются кнопка проверки связи и вызов из
воркфлоу. `release.yml` зовёт его после джобы выкладки (`needs: build`), то
есть красный гейт или несостоявшаяся выкладка сообщения не рождают. Путь беты
не тронут — `publish-prerelease.yml` звал анонс сам и раньше.

Мёртвая ветка чтения события из шага убрана вместе с триггером: тело берётся
из заметок ветки тега, как в вызове из беты.

Issue: #538
User-Visible: no
2026-09-12 14:02:27 +03:00
Codex c291384b7a Свидетель ожидания пинит саму сверку, а не её обвязку
Мутант, выкидывающий сравнение ответа REST с новой вершиной, выживал: проверка
смотрела на вызов `gh api`, токен, порядок шагов и текст отказа — всё это
мутант оставляет на месте, а цикл после него выходит на первой же итерации, и
ожидание становится декорацией.

Issue: #539
User-Visible: no
2026-09-12 13:54:38 +03:00
Codex ccfd2565a7 Диспатч уходит после того, как ссылка ветки доехала
`workflow_dispatch` в API принимает только ref: SHA туда передать нельзя, имя
ветки резолвится на стороне GitHub в момент запуска. Конвейер перед этим сам
переписывает ветку ребейзом — и 12.09 на #536 диспатч, отправленный через три
секунды после force-push, встал на ДОпушевый SHA. Гейт искал прогон строго на
SHA материала, не нашёл и вернул задачу автору со словами «материал сменился».
Чинить было нечего: дерево задачи не менялось ни на байт, материал сдвинул сам
конвейер.

Две меры, у каждой своя роль.

Шаг ребейза не заканчивается, пока REST не отдаст новую вершину — именно REST,
потому что через него же идёт диспатч. Минута ожидания, после чего отказ, а не
молчание: диспатч на устаревший SHA стоит трёх минут гейта и потерянного
захода.

Гейт, не дождавшись прогона на материале и увидев на ветке диспатч на другом
SHA, сначала пробует запустить ещё раз. Своя гонка этим закрывается, чужой
коммит переживает и вторую попытку, а формулировка отказа больше не называет
сменой материала то, что ею не является.

Issue: #539
User-Visible: no
2026-09-12 13:54:03 +03:00
Claude 1ef35883e0 Ask a foreign manifest whether it can be loaded, not how it was wired
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 52s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 27s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 21s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m10s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 13m27s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 15m22s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The performance harness runs the candidate's benchmark against a
baseline checkout, so the candidate's validator reads a manifest built
by an older commit. #535 put a rule about the CURRENT build into that
shared validator — the panel graph must not contain the card facade —
and it is false of every build before #535 by construction. The
candidate then refused to load any older baseline: all nine performance
profiles went red at once on the same step, the stable release gate
withheld `houseplan-card.js` from the published v1.75.0, and none of it
was about speed.

assertBundleManifest now answers only the loader's question: paths
exist, nothing is duplicated, graphs reference listed assets, sizes add
up. The topology of the current build moves to assertOwnBundleTopology,
called from bundle-sync.mjs, which materializes our own dist, and from
the unit test that reads dist/houseplan-assets.json. Neither ever looks
at a foreign tree.

Reproduced end to end, not only in a unit: a v1.74.0 worktree built with
its own code, then `node demo/benchmark_large_house.mjs
--target-root=<baseline>` from this tree. Before the change it stops
with «initial panel graph must contain its own stable entry only»; after
it, the profile is captured.

Issue: #537
User-Visible: no
2026-09-12 09:39:05 +03:00
Claude 2c6410bb87 Release v1.75.0
Promotion-only on top of v1.75.0-beta.1: seven version sources, the
generated bundle snapshots and the release metadata. No product source
code moves in this commit.

The line aggregates from the stable v1.74.0 and carries one user-visible
fix. The House Plan sidebar page could serve a previous version of the
card for hours: the panel entry fetched it through `./houseplan-card.js`,
a relative specifier, and relative resolution does not inherit the `?v=`
a dashboard gets from its Lovelace resource, while the entry files carry
no Cache-Control at all. The panel now imports the implementation by its
content-hashed name, so either the matching card arrives or the panel
says out loud that the page is stale (#535). Internal in the line: #536.

Known contradiction, recorded rather than silenced: the release contract
in scripts/release-contract.mjs requires the grouped "small fixes"
bullet unconditionally, while `npm run release:notes -- v1.75.0
--verify` rejects it because every user-visible issue of the range is
already itemised. The two rules deadlock any stable with a single
user-visible issue. The bullet stays, because the contract is the
automated gate that Validate enforces; the verifier's objection is
written down in STATUS and will get its own issue.

npm test 2532/2531/0 fail, release contract green on all seven sources,
docs strict green.

Issue: #535
User-Visible: yes
Release: v1.75.0
2026-09-12 09:02:29 +03:00
Claude e921d5906d Prepare v1.75.0-beta.1
Seven version sources move together to 1.75.0-beta.1, both changelogs
close their section over what has landed since the stable v1.74.0, the
release notes carry the one shipped bullet on each side with the grouped
small-fixes bullet last, and STATUS says what this beta is.

What the user gets: the House Plan sidebar page can no longer serve a
previous version of the card. The panel entry fetched it through
`./houseplan-card.js` — a relative specifier, and relative resolution
does not inherit the `?v=` a dashboard gets from its Lovelace resource,
while the entry files carry no Cache-Control at all. A browser was free
to answer from its own heuristic cache for hours, and a stale loader
named a stale immutable chunk, so the panel ran an old card against the
current backend with nothing but the version banner to show for it —
and reloading could not help, because the address never changed (#535).

Internal in the line: #536 — the version banner now asks the host to
repaint when it drops the notice on disconnect, instead of leaving its
removal to whatever unrelated update happened to run next.

npm test 2532/2530/0 fail (the #349 manifest-tracking check goes green
with this commit), release contract green on all seven sources, bundle
287 323 B gzip inside the ceiling, docs strict green.

The `Release:` trailer is what asks CI for the heavy gates — smokes,
golden and the full performance comparison — on this exact SHA (#479).

Issue: #535
User-Visible: yes
Release: v1.75.0-beta.1
2026-09-12 07:18:21 +03:00
claude[bot] bb031d8599 docs: review document for #536
Issue: #536
User-Visible: no
2026-09-12 04:06:12 +00:00
Claudeandclaude[bot] 1e1880f010 Tell the host when the version notice goes away with the element
The controller dropped its banner on disconnect without asking the host
to repaint. Lit renders neither on disconnect nor on reconnect, so the
markup produced before the detach outlived it: the notice stayed on
screen while the controller no longer owned one, and it left only when
some unrelated update happened to run. Correctness rested on a
coincidence.

Measured on the built module with a counter: the sequence mismatch ->
disconnect -> versions agree -> connect asked for exactly one repaint,
the one that showed the notice. It now asks for two, and the second is
the one that takes the notice away.

Nothing else about the teardown changes. The field is still cleared
because a detached element cannot deliver animationend, reconnect still
rebuilds the notice from the retained input, and a disconnect with no
notice still asks for nothing.

Issue: #536
User-Visible: no
2026-09-12 03:49:46 +00:00
claude[bot] 4906f0a97f docs: review document for #536
Issue: #536
User-Visible: no
2026-09-12 03:40:45 +00:00
claude[bot] 05a2c68da2 docs: review document for #535
Проверка (CI) / Классификация изменённых файлов (push) Successful in 27s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 53s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m16s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 21s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 55s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m10s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #535
User-Visible: no
2026-09-11 22:42:21 +00:00
Claudeandclaude[bot] 063de0eef4 Let the panel reach the card by its hashed name, not by the one address without a version
The sidebar page could serve a previous card for hours. It imported the
card through the stable facade, `./houseplan-card.js` — a relative
specifier, and relative resolution does not inherit a query. A dashboard
reaches the same file as `houseplan-card.js?v=1.74.0`, so an upgrade
changes its URL and the browser must refetch. The panel always asked for
the same address, and entries are served with no Cache-Control at all —
only ETag and Last-Modified — so the browser applies heuristic freshness
and may answer from cache without asking. A stale 1164-byte loader names
a stale chunk, chunks are immutable for a year, and the panel then ran a
previous card against the current backend without a single error. The
version banner was telling the truth; reloading could not help, because
the address never changed.

Rollup already emits the right edge: the panel's side-effect import
points straight at the shared implementation. The rewrite in
entryFallbackPlugin replaced it with the facade for a fallback that the
hashed name gives anyway — and better: a chunk the manifest no longer
serves now raises the panel's own "House Plan was updated" notice
instead of silently working on old code.

Two #486 assertions change meaning and are rewritten, not adjusted: the
panel no longer routes through the facade, and its initial graph no
longer contains it. The invariant they defended — the panel reuses the
exact card graph, never a second copy — is now stated over the
implementation, and a new test pins that no built entry reaches the card
by an address without a version.

Issue: #535
User-Visible: yes
2026-09-11 22:12:11 +00:00
claude[bot] a52dcab77a docs: review document for #535
Issue: #535
User-Visible: no
2026-09-11 21:42:04 +00:00
Codex 1ca3b5fae3 Записать #532 и #534 в раздел v1.74.0, а не в «не выпущено»
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 2m56s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m54s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 1m11s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m9s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m15s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 4m48s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 3m35s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 2m0s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 3m20s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m7s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 30m30s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 33m30s
Обе задачи вошли в стабильный тег кодом, но их строки чейнджлога остались под
«Не выпущено»: #532 и #534 закрывались в тот же день, что и сам выпуск, и
секция версии к тому моменту уже была закрыта. Тело релиза при этом не называло
ни ту, ни другую — обе молча попали под «мелкие исправления и улучшения», хотя
#532 это как раз та правка, ради которой владелец и присылал три профиля
Firefox.

Строки перенесены в секцию v1.74.0, тело релиза переписано: первый пункт теперь
называет все четыре правки скорости (#532, #531, #524, #534). Проверка правил
#328 зелёная на диапазоне v1.73.0..v1.74.0.

Тег не двигается: он указывает на протестированное дерево, а тело релиза на
GitHub обновляется отдельно — именно его читает человек.

Issue: #534
User-Visible: no
2026-09-12 00:17:21 +03:00
Codex e63460f068 Принять четыре эталона дневного цикла, которые изменил #532
К2 задачи #532 назвала это заранее: промоушен поверхности контура в свой
композиционный слой делит SVG надвое, оставшееся содержимое ложится на другую
субпиксельную сетку, и диагональная штриховка стен сглаживается иначе. Кадры
полагалось пересмотреть на кандидате — они дожили до кандидата стабильного,
потому что golden-джоба гоняется только на дереве с трейлером `Release:`, а
мерж #532 такого трейлера не нёс.

Проверено по критериям AC3 самой #532: разошлись **ровно четыре** кадра
`day-cycle-{dawn,day,dusk,night}-dark`, средний цвет кадра сдвинулся на
0,001–0,080 из 255 при пороге 0,1, средний знаковый сдвиг на разошедшихся
пикселях от −0,19 до +0,87. Кадры просмотрены глазами: отличается только
сглаживание штриховки стен, палитра, геометрия, подписи и ореол те же.

Приняты из артефакта полного прогона Linux CI на этом же дереве, остальные
165 сцен сохранены без изменений, свидетелей среды 129.

Issue: #532
User-Visible: no
Release: v1.74.0
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34641155082
2026-09-11 23:05:44 +03:00
Codex ac1e69be7f Release v1.74.0 — слой пространства с ключом
Догоняем main после #534: смена пространства больше не сверяет двести
маркеров поэлементно, чтобы затем всё выбросить.

Issue: #534
User-Visible: no
Release: v1.74.0
2026-09-11 22:52:06 +03:00
claude[bot] 5f0dfa84e1 docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 19:51:30 +00:00
Codex 5afdb644c7 Смена пространства выбрасывает слой, а не сверяет его поэлементно
#525 увёл оба списка сцены с `map()` на `repeat(items, (item) => item.id, …)`,
чтобы Lit не переиспользовал узлы по позиции и не проигрывал анимацию двери,
которой не было. Правка верная, но на плане с двумя сотнями маркеров она
оказалась дорогой ровно там, где пользы не приносит: при смене пространства
ключи не пересекаются вовсе, и `repeat` строит две карты ключей и обходит оба
списка, чтобы затем всё равно выбросить всё и создать заново.

Бисект по медиане `switchCycleMs` на `large-house`: 871,2 перед #525 → 953,5
после. На раннере эти 80 мс распадаются на шесть-девять дополнительных длинных
задач, и `longTask.countP95` вышел за порог стабильного гейта.

Теперь оба списка рендерятся как `keyed(space.id, repeat(…))`. Внешний ключ
делает смену пространства: поддерево выбрасывается целиком, дифа нет. Внутренний
остаётся, потому что состав списков едет и внутри пространства — у маркеров от
призраков редактора и живого синка, у проёмов от записи с нерешённым хостом,
которая живёт только в режиме plan, — а переходы на `.device-shell-frame`,
`.op-leaf` и `.op-arc` никуда не делись.

Замер после правки: `switchCycleMs` 889,1 против 936,0 на `main` и 871,2 до
#525. Потолок карточки поднят на одну строку — на `import { keyed }`;
переносить нечего, сам рендер не вырос ни на символ.

Issue: #534
User-Visible: yes
2026-09-11 22:30:11 +03:00
claude[bot] a9a6d37bba docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 19:14:21 +00:00
claude[bot] 1fb84755c2 docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 19:07:13 +00:00
claude[bot] e40d3f18d2 docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 18:53:32 +00:00
Codex 8f68cb51bd Release v1.74.0
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 2m24s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 2m30s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m19s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m8s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m2s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 2m16s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 2m16s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m41s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 3m24s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 59s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Successful in 2m31s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Successful in 3m33s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Successful in 3m30s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Successful in 2m40s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Successful in 2m42s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Successful in 2m39s
Слияние протестированного dev (7a81d41b) в main. Собственные CI-коммиты main
сохраняются: у main свой validate.yml, зеркалятся только process.yml и
mutation-gate.yml.

Issue: #533
User-Visible: no
Release: v1.74.0
2026-09-11 20:25:15 +03:00
claude[bot] 41ae5e9986 docs: review document for #532
Проверка (CI) / Классификация изменённых файлов (push) Successful in 52s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m29s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 19s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 15s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m44s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m45s
Issue: #532
User-Visible: no
2026-09-11 17:12:06 +00:00
Claudeandclaude[bot] 1cfe7fa53c Say what the day-cycle layer change does to the picture, and where the ratio was measured
Code review r1 found two documentation claims that the task's own contract
contradicts.

Both changelogs promised the picture was unchanged "to the pixel". It is
not: splitting the layer moves the wall hatch by a sub-pixel, and that is
exactly why four day-cycle baselines are re-taken. The entry now says what
a reader can check — the outline around the plan matches pixel for pixel,
the hatch inside the walls is anti-aliased a shade differently.

docs/SUN.md credited Firefox with the fifteen-fold ratio. That number comes
from headless Chromium through CDP tracing; the Firefox profile that opened
the issue measured the same cause differently — 23 MB of texture uploads per
frame and about nine frames per second. Each number now names its engine.

Issue: #532
User-Visible: no
2026-09-11 17:01:04 +00:00
claude[bot] 3322c20c09 docs: review document for #532
Issue: #532
User-Visible: no
2026-09-11 17:01:04 +00:00
Claudeandclaude[bot] ced9b41642 Keep the day-cycle outline on its own compositing layer
The "Follows the sun" background made the plan crawl in Firefox: 23 MB of
textures per frame, sixteen of about twenty picture-cache tiles thrown
away every frame, nine frames per second. The card's own JavaScript was
idle for 89 % of that.

The cost is the outer outline. It is a triple drop-shadow over the
grouped paper footprint, and although the group holds paper silhouettes
only and never changes on hover or pan, the filter lived in the plan's
own layer — so every repaint of the plan re-ran three blur passes over
the whole sheet. One hint moves the filtered paper into its own layer
and unhooks it from the plan's repaints.

Measured on a demo-stand pan (Chromium, CDP, summed RasterTask): 1456.6
ms against a static background's 96.7 ms before, 84.7 ms against 103.2
ms after. The new smoke measures that ratio and fails above two.

The picture does not change: the outline outside the plan matches byte
for byte and the frame's mean colour moves from 176.59 to 176.66 of 255.
Splitting the layer does move the remaining content onto a different
sub-pixel grid, so the diagonal wall hatch anti-aliases differently and
the four day-cycle baselines are re-taken on the beta candidate.

Issue: #532
User-Visible: yes
2026-09-11 17:01:04 +00:00
Codex 7a81d41bab Кандидат v1.74.0 после починки свидетеля
Первый кандидат держал не дефект продукта, а мигающий свидетель ресайза: он
фиксировал экранные координаты заранее, а карточка переводит их обратно в момент
события. Свидетель починен (#533), содержание релиза не изменилось.

Трейлер `Release:` просит CI прогнать тяжёлые гейты — смоки, golden и полное
сравнение производительности — на этом SHA.

Issue: #533
User-Visible: no
Release: v1.74.0
2026-09-11 19:56:20 +03:00
claude[bot] 590f5ab127 docs: review document for #533
Issue: #533
User-Visible: no
2026-09-11 16:55:33 +00:00
Codex 11ccd5b095 Ревью r2: лейблы доставки называют свой сценарий
Medium: две обёртки унаследовали имя соседа. Блок `aria-disabled`-ручки звался
`owner_boundary`, а блок с проверками `owner_boundary_*` — `range_role`, имени,
которого нет ни у одной проверки. Логика при этом верна, страдает ровно то,
ради чего правка и делалась: чтение красной строки с раннера.

Теперь `disabled.*` и `owner_boundary.*` стоят на своих блоках. Смок зелёный.

Issue: #533
User-Visible: no
2026-09-11 19:46:29 +03:00
claude[bot] e09bb2faee docs: review document for #533
Issue: #533
User-Visible: no
2026-09-11 16:44:22 +00:00
Codex 73c13e37b4 Ревью r1: доставка проверяется у всех событий смока
Medium: обёрнуты были три вызова из двадцати двух — главный сценарий. Остальные
девятнадцать по-прежнему молча отбрасывали возвращаемое значение, и жест,
не доехавший до ручки, оставлял смок зелёным.

Теперь `sent()` стоит на каждом вызове, имя проверки называет сценарий и тип
события. Отрицательный прогон: подмена `cx` на несуществующую ручку в
`mixed_role` красит ровно `mixed_role.pointerdown_sent`.

Issue: #533
User-Visible: no
2026-09-11 19:33:53 +03:00
claude[bot] e74823737f docs: review document for #533
Issue: #533
User-Visible: no
2026-09-11 16:31:13 +00:00
Codexandclaude[bot] acea695069 Свидетель ресайза меряет то же и тогда же, что и карточка
Смок считал экранные точки один раз, заранее, а карточка переводит их обратно в
момент события — от текущего размера стейджа и текущего вида. Стоило раскладке
осесть между замером и жестом, и 34 экранных пикселя превращались уже не в 50
единиц плана: ресайз коммитил не ту величину, а свидетель сообщал об этом
четырьмя немыми `expected true, got false`. Раннер это ловил, локальная машина —
нет, и красный гейт закрыл выпуск v1.74.0.

Теперь координаты передаются в единицах плана, а перевод живёт внутри того же
кадра, что и отправка события. Доставка события проверяется (прежде
возвращаемое значение хелпера для `pointermove` игнорировалось молча), а
устойчивость отображения — отдельной проверкой `safe_resize.mapping_stable`:
масштаб на захвате и на движении обязан совпасть, иначе в имени проверки
печатаются оба масштаба и оба размера стейджа.

Продуктовый код не тронут: ни один из экспериментов не указал на дефект
ресайза. Если `mapping_stable` когда-нибудь покраснеет на раннере — это и будет
доказательством обратного, с числами в первой же строке.

Issue: #533
User-Visible: no
2026-09-11 16:18:55 +00:00
claude[bot] 9584933c98 docs: review document for #532
Issue: #532
User-Visible: no
2026-09-11 16:05:14 +00:00
claude[bot] 8afba7e6ed docs: review document for #532
Issue: #532
User-Visible: no
2026-09-11 15:31:27 +00:00
Codex 116cfd5fef Prepare v1.74.0
Seven version sources move together to 1.74.0; both changelogs gain the
stable section aggregated from the previous stable v1.73.0, the release
notes carry three itemised bullets on each side plus the grouped
small-fixes bullet, and STATUS says what this release is.

What the user gets since v1.73.0: the plan is noticeably smoother and in
Firefox dramatically so — a pan frame now moves what is already drawn by
a composited transform with the scene re-rasterizing on a budget (#531),
and a marker shadow sized in container units no longer restarts a
non-composited animation on every marker at once (#524); the printed plan
is about a third larger, because the in-plan dimension labels shrank and
the separate column of external dimensions is gone (#530); a plan left
over from the era of rooms without walls can be edited again, the
leftover cleaned up on the first save instead of refusing every change
(#529, reported in #527). Grouped under small fixes: alignment guides
follow the gesture again (#521), the summary panel neither freezes the
first frame nor claims an unavailable source (#509) and its settings
dialog scrolls again (#508), a space switch no longer replays a
neighbouring door's leaf (#525), and the card downloads 12.8 KB less on a
cold start (#526).

The speed verdict for #531 is deliberately not claimed here: headless
Firefox and Chromium hold 60 fps on both the old and the new path, so it
is owed by a profile from the owner's machine on the same pan.

`npm test` 2528/0/1 skip, `pytest tests_backend` 467 passed, release
notes pass the #328 verifier against v1.73.0..HEAD, bundle 287 242 B gzip
inside the ceiling, docs strict green.

Issue: #531
User-Visible: yes
Release: v1.74.0
2026-09-11 17:25:52 +03:00
Codex f65d07e3ea Принять три PDF-эталона, которые изменил #530
Golden-джоба запускается только на кандидате с трейлером `Release:`, поэтому
Validate на мерже #530 её не гонял, и первый же релизный прогон
v1.74.0-beta.3 показал расхождение ровно в трёх сценах экспорта PDF:
`pdf-export-geometry-light`, `pdf-export-polish-light`,
`pdf-export-stepped-dimensions-light`.

Расхождение — это и есть предмет #530: план на листе крупнее, подписи
размеров стоят на чертеже, столбца выносов сбоку больше нет
(`sceneCoverage` 0.577). Кадры приняты с явным `--expect-change` из
артефакта того самого прогона (отпечаток исходников совпадает),
остальные 166 сцен сохранены без изменений, свидетелей среды 133.

Issue: #530
User-Visible: no
Release: v1.74.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34597337319
2026-09-11 15:28:34 +03:00
Codex 45d44608a1 Prepare v1.74.0-beta.3
Seven version sources move together to 1.74.0-beta.3; the changelog
sections in both languages close over what has landed since beta.2, the
release notes carry the two shipped bullets on each side with the grouped
small-fixes bullet last, and STATUS says what this beta is.

What the user gets: dragging the plan is smooth again — a gesture frame
now moves what is already drawn by a composited transform, and the scene
re-rasterizes on a budget (100 ms, or a 15 % shift) instead of on every
frame, which on the owner's 144 Hz Firefox cost 200 ms per frame with the
refresh driver skipping 124-144 ticks a second waiting for paint (#531);
and the printed plan is about a third larger, because the in-plan
dimension labels shrank by a quarter and the separate column of external
dimensions is gone, so the drawing keeps a whole scale step (#530).

The speed verdict for #531 is deliberately not claimed here: headless
Firefox and Chromium hold 60 fps on both the old and the new path, so it
is owed by a profile from the owner's machine on the same pan.

`npm test` 2528/0/1 skip, release contract green on all seven sources,
bundle 287 238 B gzip inside the ceiling, docs strict green.

The `Release:` trailer is what asks CI for the heavy gates — smokes,
golden and the full performance comparison — on this exact SHA (#479).

Issue: #531
User-Visible: yes
Release: v1.74.0-beta.3
2026-09-11 15:06:32 +03:00
claude[bot] 0d63529e90 docs: review document for #531
Issue: #531
User-Visible: no
2026-09-11 11:55:24 +00:00
Codex 77a3d2607c Пересборка бандла и отпечатка доков после ребейза на dev
Ребейз на `dev` (#530) обнулил обе производные копии: хэши чанков считаются от
содержимого, а отпечаток скриншотов — от `src/**`. Пересобрано, отпечаток
принят с `--identical`: все 11 кадров попиксельно совпали.

Issue: #531
User-Visible: no
2026-09-11 14:25:35 +03:00
Codex 9d1ba1103c Ревью r1: отпечаток доков и изометрический кадр в юните
Medium: `check-docs` красный — любая правка `src/**` делает отпечаток
скриншотов стухшим (#479), а `docs`-job на обычном push идёт в режиме `warn`.
Все 11 кадров попиксельно совпали с закоммиченными, принят только отпечаток
исходников.

Наблюдение без правки закрыто заодно: во всех прежних кадрах свидетелей
`floor === view`, то есть изометрия, ради которой камера и пол проецируются
раздельно, не была тронута ничем. Новый юнит разводит виды и по сдвигу, и по
размеру.

Issue: #531
User-Visible: no
2026-09-11 14:23:53 +03:00
claude[bot]andCodex af3be59a2c docs: review document for #531
Issue: #531
User-Visible: no
2026-09-11 14:23:48 +03:00
Codex 58e31668c9 Панорама двигает сцену трансформом, viewBox пишется по бюджету
Перезапись `viewBox` — это не сдвиг, а инвалидация растеризации всей сцены.
Кадр жеста делал её каждый раз: в профиле владельца (Firefox 155, 144 Гц) кадр
доезжал до экрана 200 мс, а драйвер пропускал 124–144 тика в секунду с пометкой
«ждём краску».

Теперь `paintLiveViewport` держит якорь — кадр, чей `viewBox` записан в DOM, и
момент записи. Кадр жеста двигает узлы сцены тем же проективным преобразованием,
которым уже двигались HTML-слои, а `viewBox` переписывается по бюджету: 100 мс
либо 15 % сдвига/масштаба. Ни атрибут, ни стиль не пишутся, если строка не
изменилась.

Issue: #531
User-Visible: yes
2026-09-11 14:23:48 +03:00
claude[bot] 9d35ab35ac docs: review document for #530
Issue: #530
User-Visible: no
2026-09-11 11:06:47 +00:00
Claudeandclaude[bot] 2e26116bbb Print the plan a third larger and drop the numbered dimension column
The A4 export spent a whole step of the scale series on the "Internal
dimensions" column beside the drawing: a ten-metre house printed 1:100
landscape on a quarter of the sheet while the same plan fits 1:75
upright. The column bought little — "R1" takes as much room beside the
wall as "2.31m" does — and it was read separately from the drawing.

The column is gone. A value with no free lane beside its own wall is
simply not printed; the rectangular rule became the general one. In-plan
type drops to three quarters of its former size (dimensions 5.25 pt,
areas 6 pt, names 6.75/5.25 pt) so the larger drawing is not crowded by
labels that do not scale with it.

On the owner's file the sheet goes from 1:100 landscape to 1:75 upright
and prints 37 values inside the plan against 35 before; the cost is
about seven short values (0.41-1.13 m) that no longer appear anywhere.

Issue: #530
User-Visible: yes
2026-09-11 10:40:05 +00:00
claude[bot] d21797dc66 docs: review document for #531
Проверка (CI) / Классификация изменённых файлов (push) Successful in 26s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 52s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m59s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 7m4s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #531
User-Visible: no
2026-09-11 10:02:27 +00:00
claude[bot] b4a16ad704 docs: review document for #531
Issue: #531
User-Visible: no
2026-09-11 09:55:36 +00:00
claude[bot] a261e54ce7 docs: review document for #530
Issue: #530
User-Visible: no
2026-09-11 09:54:24 +00:00
claude[bot] af2081cbc4 docs: review document for #530
Issue: #530
User-Visible: no
2026-09-11 09:46:23 +00:00
Codex 8f5a08a609 Prepare v1.74.0-beta.2
Seven version sources move together to 1.74.0-beta.2; the changelog
sections in both languages close over what has landed since beta.1, the
release notes carry four bullets on each side with the grouped
small-fixes bullet last, and STATUS says what this beta is.

What the user gets: a plan left over from the era of rooms without walls
can be edited again — the leftover is cleaned up on the first save
instead of refusing every change, pointing at an Optimize that cannot
help and blocking the export too (#529, reported in #527); the plan is
smooth again in Firefox, where a marker shadow sized in container units
restarted a non-composited animation on all markers at once and cost 9.4
frames per second (#524); switching spaces no longer replays a
neighbouring door or marker (#525); and the card downloads 12.8 KB less
on a cold start, because the stylesheet minifier had never actually run
(#526).

Internal in the same line: the space-switch witness judges node identity
instead of a transition that #524 removed (#528), and the review gate
charges a round only for what the round changed (#518).

`npm test` 2520/0/1 skip, `pytest tests_backend` 467 passed, release
contract green on all seven sources, bundle 287 286 B gzip inside the
ceiling, docs strict green.

The `Release:` trailer is what asks CI for the heavy gates — smokes,
golden and the full performance comparison — on this exact SHA (#479).

Issue: #529
User-Visible: yes
Release: v1.74.0-beta.2
2026-09-11 11:49:16 +03:00
claude[bot] db39b620ba docs: review document for #529
Issue: #529
User-Visible: no
2026-09-11 08:40:14 +00:00
Codex 85e5d61fb1 docs: refresh the screenshot source fingerprint after the room-drafts fix
All 11 frames are pixel-identical (docs:accept --identical, #512).

Issue: #529
User-Visible: no
2026-09-11 11:19:59 +03:00
Codex 49912ac989 fix(plan): legacy room drafts are healed, not a locked door
A plan that still carried a `room_drafts` key while already on the
current wall model could not be edited at all. The card mirrors the same
migration, so a structural edit was refused before the request ever left
the browser; the toast sent the user to "Optimize plans", which reports
that everything is already optimal because it looks at something else
entirely; and the export path calls the same migration, so the one way
out — take a backup, fix the file by hand — was shut too. An empty
`room_drafts: []`, carrying no data at all, was enough to do it.

The carrier is now removed the way the first migration removes it: an
empty key silently, drafts converted one for one into partitions. The
#478 protection against a stale client re-adding the carrier moves to
the layer that can actually tell the two apart —
`validate_wall_model_transition` sees both the submission and the stored
plan, and refuses when the drafts appear over a plan that does not have
them. It no longer keys on the submitted model number: a stale card
echoes back the number it was given, which is exactly how the outdated
client slipped past this guard and met "conflicting wall identifiers"
instead of "update the card and reload the page". The schema invariant
keeps refusing a non-empty carrier as the last line.

Both mirrors change together and stay identical; the parity fixture is
untouched.

Issue: #529
User-Visible: yes
2026-09-11 11:19:04 +03:00
claude[bot] b736878c0b docs: review document for #529
Issue: #529
User-Visible: no
2026-09-11 08:13:43 +00:00
claude[bot] 2977177283 docs: review document for #529
Issue: #529
User-Visible: no
2026-09-11 08:06:11 +00:00
claude[bot] e408b6fb70 docs: review document for #526
Issue: #526
User-Visible: no
2026-09-11 07:32:32 +00:00
Codex bde05697d1 docs: refresh the screenshot source fingerprint after the minifier fix
All 11 frames are pixel-identical (docs:accept --identical, #512): the
minified stylesheets render the same, which is the point.

Issue: #526
User-Visible: no
2026-09-11 10:12:13 +03:00
Codex b9e40aebc2 fix(mutants): a multi-line anchor is built from escaped parts
The registry lives in a JavaScript file, so an anchor that contains a
newline has to be written with escapes; pasting the real line break
broke the module and --check reported nothing while exiting non-zero.

Issue: #526
User-Visible: no
2026-09-11 10:12:13 +03:00
Codex 0a0a9f0f4c perf(build): the stylesheet minifier had never run
It looked for the tag written as `css` immediately followed by a
backtick. The plugin is a Rollup transform, so the module has already
been through TypeScript by the time it arrives, and the TS printer puts
a space there: `css `. The guard therefore returned null for every
stylesheet in the project, and minification never ran once — around
23 KB of explanatory comments went to every user in every release.

Matching the tag as a word with optional whitespace turns it on:

  chunk, raw       1 079 508 -> 1 021 115 B   (-58 393)
  initial view     300 111 -> 287 284 B gzip  (-12 816)
  room to the budget   955 -> 13 782 B

The ceiling moves down with the fact, as the tool asks when a graph
shrinks past the band.

The risk is not the two lines; it is that 23 KB of CSS is minified for
the first time. Two witnesses cover it: a browser smoke that puts the
original and the minified text into separate stylesheets and compares
the serialised rules — 1 049 of them, identical up to the whitespace
policy the minifier declares — and a test that takes real comment text
out of src/styles and requires it to be absent from dist, so a plugin
that silently stops working cannot pass again.

Issue: #526
User-Visible: yes
2026-09-11 10:12:13 +03:00
claude[bot] 7e39c3715c docs: review document for #528
Issue: #528
User-Visible: no
2026-09-11 07:09:35 +00:00
Codex f37ccdcebc test: judge marker reuse by node identity, not by a transition
The marker half of the space-switch witness asked whether a box-shadow
transition was running on the shell. That worked only because such a
transition existed; #524 removed it — the shadow is sized in container
units and animating it cost a real user 9.4 frames per second — and the
check became trivially true. The mutant that removes the keys from the
marker list has been surviving ever since, and nobody noticed until the
next gate ran it.

The witness now keeps references to the marker nodes and requires that
none of them stays in the DOM under a different data-id after the
switch. Node identity is what the keys are for, and it does not depend
on any stylesheet.

The door half is untouched: there the transition is part of the product
contract, not a side effect.

Issue: #528
User-Visible: no
2026-09-11 10:01:46 +03:00
claude[bot] 78bc76073e docs: review document for #526
Issue: #526
User-Visible: no
2026-09-11 06:23:19 +00:00
claude[bot] 4451cf50d9 docs: review document for #526
Issue: #526
User-Visible: no
2026-09-11 06:18:52 +00:00
claude[bot] 9609040d31 docs: review document for #524
Issue: #524
User-Visible: no
2026-09-11 05:54:09 +00:00
Codex 9ad8b1f710 docs: refresh the screenshot source fingerprint after the CSS note trim
All 11 frames are pixel-identical (docs:accept --identical, #512).

Issue: #524
User-Visible: no
2026-09-11 08:34:23 +03:00
Codex b59e245e32 perf(markers): keep the CSS note short — comments ship to the browser
The long explanation inside the css`` template cost 518 gzipped bytes in
the initial chunk: the template minifier leaves comments in this file, so
every character of them is downloaded by every user. The reasoning now
lives in docs/DEVELOPMENT.md and in the commit above; the stylesheet
keeps one line and the issue number.

Measured: 300 577 B with the long notes, 300 111 B with the short ones,
300 059 B on dev before the fix.

Issue: #524
User-Visible: no
2026-09-11 08:33:18 +03:00
Codex cd47a59d3f docs: refresh the screenshot source fingerprint after the marker shadow fix
All 11 frames are pixel-identical (docs:accept --identical, #512); only
the source fingerprint moved with src/**.

Issue: #524
User-Visible: no
2026-09-11 08:29:57 +03:00
Codex 6b64801603 perf(markers): a marker's shadow no longer animates
The shadow of a device marker is sized from the marker, and the marker is
sized from the container: --device-shell-shadow is expressed in
--dev-size, which resolves to 2.5cqw. With box-shadow in the transition
list, every container-query re-evaluation — a tooltip, a scrollbar, a
rotation — produced a new computed value and restarted a 150 ms
non-composited transition on every marker at once.

The owner's Firefox profile shows what that costs: 244 box-shadow
transitions, all on span.device-shell-frame, all oncompositor:false, in
bursts of exactly 61 (the markers on screen), four bursts in two
seconds. During them the tab presented 103 frames in 11 seconds — 9.4
per second, CONTENT_FRAME_TIME median 149 ms and up to 320 — while the
refresh driver waited for paint 381 times. Our JavaScript in the worst
three seconds: 16 ms. Chromium starts the same transitions (measured:
one pixel of container width starts two per marker in both engines) and
merely pays less for them, which is why this hid there.

The shadow itself is unchanged; it simply applies at once. The core
keeps the same treatment, so the selection and focus rings appear
without a fade — an instant ring is ordinary feedback, a faded one costs
a full-frame repaint per marker. Hover still animates border-color.

Issue: #524
User-Visible: yes
2026-09-11 08:28:49 +03:00
claude[bot] f4625dd4fb docs: review document for #524
Issue: #524
User-Visible: no
2026-09-11 05:04:43 +00:00
claude[bot] 8ba87b3ff7 docs: review document for #525
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m47s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 2m51s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 1m23s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 1m44s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 2m14s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m45s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m4s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #525
User-Visible: no
2026-09-11 02:00:05 +00:00
Claudeandclaude[bot] ab296302d1 test: register the space-switch witness for the two renderers (#525)
`smoke-select` answered НЕОПРЕДЕЛЁННОСТЬ on this diff: the smoke does not
name `_renderDevice` or `_renderOpenings` anywhere — it switches spaces the
ordinary way and reads running transitions off the nodes those renderers
produced. That is exactly the case `scripts/smoke-links.mjs` exists for, and
without the record the next edit to either renderer selects no witness at all.

Issue: #525
User-Visible: no
2026-09-11 01:42:46 +00:00
Claudeandclaude[bot] 8d5bade656 docs: refresh the screenshot source fingerprint (#525)
The fix touches `src/houseplan-card.ts` and `src/styles/plan.styles.ts`, and
the documentation fingerprint covers the whole `src/**` corpus.

Accepted with `npm run docs:accept -- --identical` (#512): all 11 frames were
re-captured locally and matched the committed ones byte for byte, so only the
fingerprint moves, `7a90ca04 → cd8e9598`. No PNG changes. Environment: Linux,
Chromium 152 — the canon platform of #455.

Issue: #525
User-Visible: no
2026-09-11 01:42:46 +00:00
Claudeandclaude[bot] 9edef041a9 fix: openings and markers keep their identity across a space switch (#525)
Lit reuses list nodes by position. The opening list and the device markers had
no keys, so on a space switch the leaf that held a slot kept its DOM node and
only changed values — and `.op-leaf` (transform) and `.op-arc`
(stroke-dashoffset) carry a 0.6 s transition, so the browser animated a door
that never moved: the new floor's leaf drove in from the previous floor's
opening angle. Measured on two spaces with a door in the same place and
opposite contact states: the node is reused, transform goes
`rotate(-90deg) → rotate(0deg)`, dash offset `0 → 125.66`, both transitions
`running`. The marker shell adds two more with its `box-shadow`.

Both lists are now rendered through `repeat(…, (item) => item.id, …)`, the
same lesson `glow-scene.ts` already learned for the Glow spots. The trap is
written where it starts — above the two transitions in `plan.styles.ts` —
because that is the file someone edits when adding the next animated property.
`houseplan-card.ts` is at its line ceiling, and the note would have cost the
budget a dozen lines for nothing: the swap itself is line-for-line.

The witness walks the shadow tree per element. `document.getAnimations()` is
empty here EVEN ON THE BROKEN CODE — the card lives in a shadow root and the
document-level call does not reach into it, and the issue proposed exactly
that call. The smoke also builds its own fixture: the demo home has no
openings at all, so two doors in two spaces are prepared in the smoke, and it
asserts the other half of the contract as well — a real contact change inside
one space still animates the leaf.

On `origin/dev` the smoke fails on five facts, naming the offenders:
`op-arc:stroke-dashoffset`, `op-leaf:transform`, `device-shell-frame:box-shadow`
twice. Mutants `openings-rendered-without-keys` and
`device-markers-rendered-without-keys` put each `map` back.

Perf, 7 samples against `19e421b3`: spaceSwitchMs 524.8 (limit 769.35, base
512.9), switchCycleMs 1293.9 (1696.28, 1256.5), firstStableRenderMs 2533.8
(3000, 2529.2), modelReadyMs 732.7 (944.97, 726.9), longTask.maxSingleMs 663
(910, 660) — `benchmark:compare` green in full.

The initial View graph grows 241 B gzip: `repeat` enters it for the first
time. The #438 ceiling is recentred 300 400 → 300 700 with the usual dated
note; measured 300 059 B keeps 641 B above and 1 359 B below the band. The
301 066 B budget is untouched, but only 366 B now separate the ceiling from
it — the #367 headroom debt has stopped being theoretical.

Issue: #525
User-Visible: yes
2026-09-11 01:42:46 +00:00
claude[bot] 95175a7616 docs: review document for #525
Issue: #525
User-Visible: no
2026-09-11 01:20:43 +00:00
Claude 19e421b3cf Refresh the v1.74.0-beta.1 candidate on the merged line
The candidate was assembled before #520 and #521 landed. Version fields are
already at 1.74.0-beta.1 in all seven sources and the bundles rebuild
byte-identical, so this commit only moves what the beta says about itself:
the #521 changelog entry leaves Unreleased for the beta.1 section in both
languages, the release notes gain its bullet on both sides (four and four,
the grouped small-fixes bullet stays last), and STATUS records the two
issues merged since — the alignment guides that follow the gesture again
(#521, a regression shipped in v1.72.0 and v1.73.0) and the cold-start
adoption fix behind it (#520, internal).

No product change: `npm run build` plus `bundle-sync` leave `dist/**` and
the two mirrored trees untouched, `node scripts/release-contract.mjs
v1.74.0-beta.1` is green on all seven version sources, `npm test` 2515/0.

The `Release:` trailer is what asks CI for the heavy gates — smokes, golden
and the full performance comparison — on this exact SHA (#479).

Issue: #521
Issue: #520
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 23:59:13 +03:00
claude[bot] 3f3583ac58 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 20:48:53 +00:00
Claude 9a3af808d9 fix: hide the settled guides copy for the whole gesture, and prove it (#521)
Code review r1 was right that AC5's evidence was empty: the smoke never
forced a settled render during a gesture, so the settled copy of
`.hp-editor-only-layer` was empty at every point it looked, and
`groups() === 1` held whether the copy was hidden or not.

Measuring the case the reviewer named turned up more than a weak assertion.
Ownership of the layer alternates on its own — every settled render ends in
`updated()` → `_commitLiveEditor()`, which empties the live root — so a
settled render mid-gesture takes the guides back and draws them itself, from
the same live `_alignPoint`. That much needs no suppression. But the copy it
leaves behind stays in the settled scene, and the NEXT live paint adds a
second one: measured two `.alignline` on one alignment, the settled one a
grid step behind the marker. So the suppression stays, and now it stays with
a witness.

The smoke counts what is visible, not what is in the DOM: the hidden copy is
still a node, and counting nodes is how this check could have looked green
while showing the user two lines. Its device scenario now drives the whole
handover — force an unrelated settled render mid-drag (`_hdrH`, the same
header-height observer that masked the defect in the S2 measurements), assert
the render actually happened, that the layer went back to the settled scene
with the live point on it, and that one real move later the live painter owns
it again — exactly one visible guide at every step.

Mutant `live-editor-keeps-the-settled-guides-visible` puts the suppression
back under the plan branch, as it was before this issue, and the smoke goes
red on `nextMoveTakesTheLayerBack`.

Issue: #521
User-Visible: no
2026-09-10 23:25:46 +03:00
claude[bot] deebcfb095 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 20:05:44 +00:00
Claude 0715321fdc docs: refresh the screenshot source fingerprint (#521)
The fix touches `src/live-editor.ts` and `src/houseplan-card.ts`, and the
documentation fingerprint covers the whole `src/**` corpus, so `check-docs`
called the committed screenshot index stale — the same step #520 needed.

Accepted with `npm run docs:accept -- --identical` (#512): all 11 frames were
re-captured locally and matched the committed ones byte for byte, so only the
fingerprint moves, `c615d580 → 7a90ca04`. No PNG changes, nothing here needs
the owner's visual acceptance. Environment: Linux, Chromium 152 — the canon
platform of #455.

Issue: #521
User-Visible: no
2026-09-10 22:24:27 +03:00
Claude 53585b451b fix: alignment guides follow the live gesture again (#521)
#451 moved every editor gesture onto the live painter, and the guides stayed
behind in the settled scene. While a gesture runs, the settled scene is not
re-rendered at all, so the guides did not follow the marker in the device
editor, the shape in the backdrop editor, or the cursor while a contour is
drawn in the plan editor. Measured with real pointer events on the demo stand
against `origin/dev`, after waiting for the editor chrome to settle: three
gestures, each exactly on another object's axis, 0 settled render cycles,
`.alignline` 0 and no `.alignguides` group in all three.

The report called it two breaks. It is one — the layer — plus one thing that
would have broken the repair: `_alignPoint` read `_pos`, which during a live
gesture answers from the snapshot of the last settled render. Over one drag:
live 254.17 → 220.83 while `_pos` stayed at 254.17, eight grid steps behind,
so a restored layer would have drawn the guide at the marker's old place.

The live template now paints the guides in all three modes (the device editor
had no template at all — `paintDevice` only moves the marker element), and
`_alignPoint` takes the live position. The settled copy of
`.hp-editor-only-layer` is made transparent for the duration of any editor
gesture, not only in plan mode: two guides, one of them stale, is what the
user would otherwise see when an unrelated settled render lands mid-gesture.
`_renderAlignGuides` on the card becomes soft — a gesture that starts while
the editor runtime is still loading must cost nothing, and an exception inside
a `requestAnimationFrame` paint would take the whole gesture with it.

The witness is rewritten around the defect that hid this for two stable
releases: the old smoke assigned `_deviceDrag`/`_decorDraft` wholesale, and an
assignment with `oldValue == null` does not route to the live path — it
verified a state a real gesture never reaches. Every scenario now drives real
`PointerEvent`s, waits for silence first (the `_hdrH` settling window right
after entering a mode hands out settled frames that make even the broken code
draw a guide), and asserts zero settled cycles during the movements plus
exactly one `.alignguides` group. #400's exclusion is checked without touching
the drag state: the dragged marker must simply be absent from the candidates.
On `origin/dev` the smoke fails on nine of its facts; a witness that stays
green before the fix was the actual bug here.

Mutants: `live-editor-devices-drops-align-guides`,
`live-editor-decor-drops-align-guides`, `live-editor-plan-drops-align-guides`,
`align-point-reads-frozen-snapshot` — one per AC, all guarded by the smoke.
`test/smoke-harness-contract.test.mjs` pins that the smoke cannot go back to
fabricating gesture state.

Issue: #521
User-Visible: yes
2026-09-10 22:20:18 +03:00
claude[bot] fa7ac02c0f docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 19:05:32 +00:00
claude[bot] 548e7a8563 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 18:57:55 +00:00
claude[bot] 9592092b8e docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 18:49:42 +00:00
claude[bot] c50bc725c8 docs: review document for #522
Issue: #522
User-Visible: no
2026-09-10 18:40:54 +00:00
claude[bot] b2346eaac3 docs: review document for #520
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 2m16s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m55s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 52s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #520
User-Visible: no
2026-09-10 18:26:40 +00:00
Claude a325294d7a docs: refresh the screenshot source fingerprint (#520)
The fix touches `src/houseplan-card.ts` and `src/config-adoption.ts`, and the
documentation fingerprint is computed over the whole `src/**` corpus, so
`check-docs` called the committed screenshot index stale — the Medium of code
review r2.

Accepted through `npm run docs:accept -- --identical` (#512): all 11 frames
were re-captured locally and compared byte-for-byte with the committed ones —
«Все 11 кадров попиксельно совпали с закоммиченными». Only the fingerprint
moves, `ccbbfb4e → c615d580`; not a single PNG changes, so nothing here needs
the owner's visual acceptance. Environment: Linux, Chromium 152 (the canon
platform of #455; capture refuses elsewhere).

`node scripts/check-docs.mjs` — «Documentation checks passed (7 files, 12
external links)».

Issue: #520
User-Visible: no
2026-09-10 21:15:42 +03:00
claude[bot] 3066fec17e docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 17:47:45 +00:00
Claude 462b56453c fix: the authoritative adoption keeps its own task (#520)
The r1 diagnosis was wrong, and the measurement in the code review proved
it: removing the two declarations from `static properties` left the cold
start at 19 update cycles, 4 model builds and 4 config epochs, exactly the
numbers of the bug. Lit's forced first-update change does mark `_serverCfg`
changed, but at that moment the body and `_cfgEpochPreservedConfig` are both
null, `preserveGeometry` is true and the epoch does not move. The comment
above `static properties` now says that; the declaration still stays out,
because two owners of one reactivity is what #500 removed.

The real cause is the `await`. Before #500 everything from
`_adoptStructuralResponses` to the end of the load ran in one task: the
adopted bodies, `_adoptInitialSpace`, the viewport restore, `_loadOk`, and
the device seeding — whose `_syncNewDevices`/`_seedHiddenDevices` write the
config back — all landed in a single Lit update. #500 made the adoption an
async sequence, so the caller resumes one microtask later, after Lit has
already painted the adopted config; the seeding writes then arrive as a
second config epoch, a second model build and a second paint of a 60-room
house.

`GatedAdoptionInput` gains `afterAdopt`, the mirror of `beforeAdopt`: it
runs synchronously at the end of the sequence, before the promise resolves.
`_loadFromServer` moves the viewport restore, `_loadOk` and the device
rebuild into it — `_syncNewDevices` refuses to write before `_loadOk`, so
the order inside the hook matters — and the load tail now rebuilds devices
only when nothing was adopted. `_reloadConfigOnly` takes the same route.

Measured with the project's own runner, 7 samples per profile, base
`a44fbd37` against this tree (Chromium 152, sandbox):

  interaction  modelReadyMs 761.3 ≤ 950.56 (base 731.2)
               firstStableRenderMs 2567.2 ≤ 3000 (base 2542.7)
               longTask.maxSingleMs 690 ≤ 921 · cache.entries.cleanFloor 100
  isometric    modelReadyMs 1252.9 ≤ 1499.76 (base 1249.8)
               firstStableRenderMs 1378 ≤ 1610.16 (base 1341.8)

Boot diagnostics on both trees: 18 update cycles, 3 model builds, 3 config
epochs, with the same epoch trace — the candidate is no longer
distinguishable from the base.

Witnesses. `config-adoption.test.mjs` queues a microtask at the start of
the adoption and pins that `afterAdopt` runs before it — the probe fails the
moment the hook crosses an await; `config-adoption-ownership.test.mjs` pins
the wiring in the card and the hook's place in the sequence. Mutants
`adoption-tail-defers-caller-hook` (defers the hook by one microtask) and
`authoritative-load-seeds-devices-after-the-await` (drops the rebuild from
the hook) redden them.

The initial View graph grows 40 B gzip, so the #438 ceiling is recentred
300 300 → 300 400 with the usual dated note; measured 299 812 B keeps 588 B
above and 1 412 B below the band. The overall 301 066 B budget and the #367
headroom debt are untouched.

Issue: #520
User-Visible: no
2026-09-10 20:12:13 +03:00
claude[bot] 49a02637ca docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 16:27:39 +00:00
Claude a625871538 test: a mutant for the only surviving wake-up of a replaced config body (#520)
With the bodies no longer declared as Lit properties, `onBodyReplaced` is the
single path that turns a replaced config reference into an update — AC2 of
this issue rests on it, and until now only a hand-run mutation stood behind
that column. The mutant drops the notification from `setConfig`; the existing
unit in `test/config-adoption.test.mjs` reddens on it.

Verified: `node scripts/mutation-gate.mjs --id=adoption-notifies-no-host-on-config-replacement`
— «поймано 1 из 1».

Issue: #520
User-Visible: no
2026-09-10 19:03:21 +03:00
Claude e181b08f2b fix: the adoption bodies are no longer declared Lit properties (#520)
#500 gave `_serverCfg` and `_layout` prototype accessors but left them in
`static properties`. Lit marks such a property `wrapped` and, on the FIRST
update, force-writes it into `changedProperties` with an `undefined` old
value even though nobody assigned anything (`reactive-element.js:249-252`
and `:880-886`). `willUpdate` reads that as a config replacement, raises
`_cfgEpoch`, the memoized model key changes, and a 60-room house builds and
paints its model a second time: measured 19 update cycles, 4 builds and 4
epochs against 18 / 3 / 3 before #500, worth ~550 ms of `modelReadyMs` and
the same on `firstStableRenderMs` (3355 against a 3000 ceiling).

The declaration goes; the bodies stay reactive through the owner —
`_adoption` → `onBodyReplaced` → `requestUpdate(field, previous)` — which
needs no declaration: `getPropertyOptions` falls back to the default and
`changed.has('_serverCfg')` works as before. `noAccessor: true` would not
help, `wrapped` is set before that flag is read. The trap is written above
`static properties`, where someone would put the declaration back.

`cache.entries.cleanFloor` returns to 100 in both interaction budgets: the
120 entries were the extra epoch re-keying the per-room cache, not a
property of the design — the reasoning in 914e8402 was wrong.

Witness: test/config-adoption-ownership.test.mjs pins that neither body is
declared; the mutant `adoption-bodies-declared-reactive` puts the
declaration back and reddens it.

The boot diagnostics of the previous three commits touch four private
members, so they are declared in the performance contract: `_buildModel` and
`_cfgEpoch` outright (both exist in every supported comparison base), and the
adoption entry point as a current/legacy pair — #500 turned the private
`_adoptStructuralResponses` into the public `_adoptAuthoritative`, and an
undeclared rename would have the counter report zero adoptions instead of
failing.

The same commits carried a `node_modules` symlink: `.gitignore` had the
pattern with a trailing slash, which does not cover a symbolic link, and
`git add -A` in a sandbox worktree committed it. The link is removed and the
pattern loses the slash; a mutant run on this branch failed with `EEXIST` on
it.

Issue: #520
User-Visible: no
2026-09-10 18:58:24 +03:00
claude[bot] 50fa7f9be2 docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:38:40 +00:00
claude[bot] b49721a0bd docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:30:21 +00:00
claude[bot] ad2f9afd2b docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:20:07 +00:00
Claude 27ea23c1ec test(perf): trace every config-epoch bump with the frame that made it (#520)
The comparison already names the mechanism: base does 18 update cycles, 3
model builds and ends at epoch 3, the candidate does 19, 4 and epoch 4, and
the extra build is the whole ~550 ms. What is still missing is the caller.
The diagnostic now installs an instance-level setter over `_cfgEpoch` and
records `from->to` with the top stack frames, so the extra bump names itself.

Issue: #520
User-Visible: no
2026-09-10 17:57:27 +03:00
Claude 3d43980568 test(perf): the boot diagnostics travel out of the page into the run log (#520)
The first attempt printed them with console.log inside page.evaluate, and
nothing forwards the page console to Node — the numbers went nowhere. The
sample now returns `bootDiag`, the runner prints it and strips it before the
row is recorded, so the budgeted record keeps its shape.

Issue: #520
User-Visible: no
2026-09-10 17:47:18 +03:00
Claude 37ef970dfc test(perf): count update cycles, model builds and adoptions during boot (#520)
The full comparison says model readiness grew by ~500 ms inside #500 and
that the growth sits in one long task, but neither contentFingerprint
(2.8 ms on this fixture) nor spaceModels (0.1 ms) can account for it. The
benchmark now prints, per sample, how many Lit update cycles ran before the
first stable frame, how long they took together, how many models were built,
how many adoptions happened and the config epoch. Diagnostics only: printed
to the log, never part of the budgeted record, and the same harness runs
against the comparison bundle, so candidate and base are counted alike.

Issue: #520
User-Visible: no
2026-09-10 17:38:36 +03:00
Claude 914e84028d perf: the clean-floor cache holds one entry per room per config epoch (#509)
Проверка (CI) / Классификация изменённых файлов (push) Successful in 52s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m32s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 43s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 41s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m11s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Failing after 4m28s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Failing after 4m36s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Failing after 4m43s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Failing after 4m38s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Failing after 3m58s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Failing after 4m7s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m49s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m2s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The pre-release perf gate of the v1.74.0-beta.1 candidate reported
cache.entries.cleanFloor 120 against a ceiling of 100 (run 34480302982,
large-house-interaction-v1). The ceiling was calibrated when only the visible
space populated `_cleanFloorCache`; since #509 the summary panel computes the
clean-floor total in per-room slices through the same cache, so the sixty
fixture rooms are cached under both config epochs the interaction profile
creates — 60 × 2 = 120, exactly what the run measured.

The ceiling moves to 180 in the smoke and in the full interaction profile:
one entry per room per epoch with room for a third epoch, far below the LRU
cap of 600 and far below anything a per-frame or per-marker regression would
produce. The leak detector is untouched: cacheGrowth.cleanFloor stays 0.

Issue: #509
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 16:29:31 +03:00
Claude d5575f6393 Prepare v1.74.0-beta.1
Version fields and the generated bundles move to 1.74.0-beta.1 and open the
line after the stable v1.73.0. The changelog entries of #509 and #508 leave
Unreleased for the beta.1 section; release notes and STATUS describe them and
name the internal work of this beta. No product change beyond what is already
reviewed and merged.

Issue: #509
Issue: #508
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 16:01:59 +03:00
Claude 1950df608f docs: refresh the screenshots fingerprint before the v1.74.0-beta.1 candidate
The #500 adoption work changed src/** without touching any of the eleven
documented frames. The owner re-captured them in WSL — the canonical Linux
environment, because DirectWrite on native Windows never reproduces an
accepted frame — and `npm run docs:accept -- --identical` (#512) found zero
differing pixels, so only the source fingerprint moves:
48f770cf → ccbbfb4e. Frame bytes, their hashes and the capture-script guard
stay exactly as accepted.

Issue: #500
User-Visible: no
2026-09-10 16:01:58 +03:00
claude[bot] 94ea4694f4 docs: review document for #500
Проверка (CI) / Классификация изменённых файлов (push) Successful in 47s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m24s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m14s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #500
User-Visible: no
2026-09-10 12:32:21 +00:00
Codex be6d57e96f test(adoption): the post-write smoke resets its own pending writes
Review r2 M1. The smoke's reset() left the previous scenario's debounced
config/layout write pending; _deleteSpace flushes whatever is pending
before it writes, the fake socket answers without a rev, and the
documented rev+1 fallback then moved the revision on a body from another
scenario. The refused branch looked as if it had adopted:
onboardingDeleteRefusedAdoptsNothing was red on every run. The scenarios
are supposed to be independent, so reset() now cancels both debounced
writers, as smoke_danger_confirmation already does.

37/37 green, three runs in a row; with the cancel removed the same
single check goes red again.

The refused-tail fix from r1 had no witness in CI at all: no mutant
named this smoke as its guard, so the review gate never ran it and a red
witness survived a whole round. A witness that never runs is not a
witness, so the early return in _undoPlanOptimization now has a mutant
that names the smoke.

Issue: #500
User-Visible: no
2026-09-10 15:16:12 +03:00
claude[bot] 5acf04fa33 docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 12:08:21 +00:00
claude[bot] 93c6c7551b fix(adoption): post-write callers skip their tail on a refused gate (#500 r1 M1)
`space/delete` (both runtimes), Optimize Undo and Import apply now treat
`asset-wait` like every reload path: nothing was adopted, so no toast, no
space switch, no history/undo reset — the dialog is released and the
scheduled reload owns the rest. Unit and smoke cover the refused branch for
all four paths; the spec's reactivity risk row states the real mechanism.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] bac63dbcaf docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 31c2583e46 fix(adoption): a replaced body stays a reactive host event (#500)
`willUpdate` keys the geometry epoch and render-lifecycle invalidation on
`changed.has('_serverCfg')`. Before #500 every body replacement went through
Lit's accessor and produced that event; the owner wrote its field directly
and the epoch stopped moving on adoption, staging and rollback — the safe
Resize smoke then measured against a stale model (Validate on c360bcc9).
`MutableConfigAdoption` now reports each replaced reference through
`onBodyReplaced`, which the card wires to `requestUpdate(field, previous)`;
echoes and identity-only changes stay silent, exactly as an unchanged
reference never fired the accessor.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 2f41c0b29e test(adoption): ownership lint, post-write smoke, harness seam and docs (#500)
`test/config-adoption-ownership.test.mjs` pins identity writes to the owner
and ratchets body staging (AC1/AC2). `demo/smoke_post_write_adoption.mjs`
drives space/delete (both runtimes), Optimize Undo and Import apply with a
concurrent backdrop change between the write and the re-read (AC4). Smokes
that seed revisions from outside the card keep working through the
`seedIdentity` harness seam behind the card's delegate setters. The initial
View ceiling is re-centred with the measured fact; ARCHITECTURE.md gets the
boundary paragraph.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] a3e7ac1fc6 test(adoption): revision guard witness for optimistic rollback (#500 AC6)
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 7194fb2df0 feat(adoption): one owner for config/layout identity and the adoption sequence
`src/config-adoption.ts` owns config/layout with revision and fingerprint;
the host keeps `_serverCfg`/`_cfgRev`/`_layout`/`_layoutRev` as delegates.
All seven authoritative adoptions go through `adoptAuthoritativeGated`
(backdrop readiness → continuity → adopt → profile tail); the post-write
paths (space/delete ×2, optimize_undo, import/apply) gain the gate and take
revisions from the re-read bodies. `rollbackOptimistic` moves to the owner;
plan-optimize, space copy and the vacuum writers stop assigning identity.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] a44fbd373d docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] c21e9de32b docs(spec): #500 r3 — post-write profile keeps caller tails untouched
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 9bbba66d11 docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] b4a2d5b952 docs(spec): #500 r2 — seven adoption callers, two profiles, vacuum writers
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] b6a66a1bac docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 88eb382403 docs(spec): #500 one ownership boundary for config adoption
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
Codex f6eac1d971 docs: the review gate charges a round only for what the round changed
Issue: #518
User-Visible: no
2026-09-10 14:31:06 +03:00
Codex 67c780a742 test: the review gate's own job names follow the six shards
Issue: #518
User-Visible: no
2026-09-10 14:20:34 +03:00
Codex 2783ceff94 perf(ci): judge a witness by its anchor's neighbourhood, not the whole file
The review gate re-ran almost every selected witness on every round even
when the executor's fix was twelve lines: the ledger fingerprint and the
diff selection both worked on whole files, and the card hosts are
thirteen thousand lines each. On #500 those twelve lines in
houseplan-editor-runtime.ts pulled 53 of the 75 witnesses the third
round ran, and the gate cost 140 job-minutes and an hour of the
reviewer's wall clock across three rounds.

The patch side is now judged by the anchor's neighbourhood — the anchor
lines plus ANCHOR_RADIUS_LINES on each side — in both the ledger
fingerprint and the diff selection, which now reads hunk ranges from
git diff --unified=0. The guard side keeps whole-file granularity: a
guard has no anchor and changes as a whole. An anchor that is not found
exactly once falls back to the whole file, and so does a file whose
hunks were not read: not knowing is not proof. Same class of
approximation as the existing diff selection, with the nightly full
gate (#513) as the floor.

Two more cuts to the wall clock of a review round. The shard plan is now
computed before the environment is installed — restore the ledger,
select, split, and only then pay for npm ci, Python and Chromium; the
job still runs, so the review gate's proof (#510) is unaffected. And the
matrix goes from three shards to six: the same job-minutes, half the
wall time.

On the #500 round the selection drops 60 → 7. Four witnesses guard the
new logic, including the two unsafe defaults (ambiguous anchor, missing
hunks).

Issue: #518
User-Visible: no
2026-09-10 14:17:36 +03:00
claude[bot] 0c74828240 docs: review document for #509
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 51s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 37s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 55s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m29s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 1m3s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 8m21s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m42s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #509
User-Visible: no
2026-09-10 09:12:51 +00:00
Codex 9b00002a5b test: allow the indivisible per-space masonry slice in the first-paint budget
The residual 150–200 ms tasks are one space's wallBodiesGeometry — a
single polyclip union that cannot be split — not the aggregate the issue
is about. The threshold now names them and leaves room for a slower CI
runner; the mutant that computes everything in one task still produces
~1.5 s and fails.

Issue: #509
User-Visible: no
2026-09-10 11:48:30 +03:00
Codex 19b14d3976 test: the first-paint smoke watches only the panel's own long tasks
On the CI runner the smoke went red without any mutant: the observer was
started before the 60-room plan had finished drawing, and that render —
a long task of its own, unrelated to this issue — landed in the window.
The smoke now waits for a quiet main thread before it starts watching,
prints what it measured, and allows up to 450 ms per task: the residual
150–200 ms slices are one space's masonry union, which polyclip cannot
split, while the mutant that puts the whole aggregate back into one task
still produces ~1.5 s.

Issue: #509
User-Visible: no
2026-09-10 11:47:25 +03:00
Codex 172d9d5a00 docs: refresh the screenshots fingerprint after the summary-panel fix
All 11 frames are pixel-identical (docs:accept --identical, #512); only
the source fingerprint moved with src/**.

Issue: #509
User-Visible: no
2026-09-10 11:35:26 +03:00
Codex 588ef42546 fix: the clean-floor total is computed in per-room slices, not one long task
Moving the aggregate out of render fixed the first frame, but the work
itself was still one uninterrupted ~1.5 s task on the large-house
fixture — the interface stayed frozen, just a moment later, which is the
same symptom the issue reports. cleanFloorAreaSteps yields after every
room; the runtime advances it with an 8 ms budget per frame and
reschedules until it finishes, so no slice outlives a frame and the
skeletons stay until the number is ready.

The smoke now watches longtask entries for the whole show, not only the
first frame: a single long task while the values are computed fails it.

Issue: #509
User-Visible: no
2026-09-10 11:31:37 +03:00
Codex e2bb1e5390 test: the clean-floor total is timed on a real floor, not just counted
The injected-counter test proves "one geometry pass per space" but not
that its result reaches innerContourForRoom — without the shared
arguments that function rebuilds the masonry per room, and the only
observable difference is time (176 ms per room, S2). One large-house
floor: ~0.6 s with the shared pass, ~3.7 s without, so a 2.5 s threshold
is coarse enough not to flake.

Issue: #509
User-Visible: no
2026-09-10 11:26:29 +03:00
Codex 398281a93f fix: the summary panel's first paint no longer freezes or claims the sources are gone
Two halves of the same first paint. The panel showed «Source unavailable»
in every row until the lazy metrics chunk arrived, because value() could
not tell "not loaded yet" from "source is dead"; and metrics() ran inside
render, walking the HA registry and unioning the clean floor of every
space synchronously — 11 s on the large-house fixture.

- totalCleanFloorAreaM2 computes the space's masonry and junction
  topology once per SPACE and hands them to innerContourForRoom, which
  otherwise unions the whole space again for every room: 11 045 → 1 488 ms
  on that fixture, same 306.3 m². The card has always done this through
  its own _innerContour cache; the panel now does the same.
- Aggregates leave the render path: the first frame paints skeletons and
  the work starts right after the frame is shown (timeout → rAF →
  timeout, never requestIdleCallback, which under load would leave the
  skeleton up for seconds). Stale memo keeps the previous number on
  screen instead of flashing back to a skeleton.
- valueState() separates pending from unavailable; a pending row keeps
  the same plate, grid and height and carries a pulsing rectangle the
  height of the line, replaced by the value with a short fade. Reduced
  motion keeps the rectangle and drops the pulse.
- Panel enter/exit animation (#505, 190 ms) is now actually visible —
  the main thread is free — and the smoke witnesses it.

Mutants: summary-first-paint-shows-unavailable, summary-metrics-block-first-frame,
summary-area-recomputes-walls-per-room, summary-stale-metric-falls-back-to-skeleton.

Issue: #509
User-Visible: yes
2026-09-10 11:24:10 +03:00
claude[bot] 9d375dcb40 docs: review document for #509
Issue: #509
User-Visible: no
2026-09-10 08:14:22 +00:00
claude[bot] 57f38fedba docs: review document for #509
Issue: #509
User-Visible: no
2026-09-10 08:05:30 +00:00
Codex ae8f6728d7 ci: mirror process.yml from dev (#517 — issue-body digest in review anchors)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 1m50s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m58s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m12s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m14s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 1m49s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m48s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 1m54s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m58s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 57s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 39s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m53s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 4m9s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 4m15s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m18s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m1s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The review pipeline runs from the default branch; byte-identical to
dev@d204bf5d.

Issue: #517
User-Visible: no
2026-09-10 10:38:38 +03:00
claude[bot] d204bf5d77 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 07:36:17 +00:00
Codex 156835c048 ci: specs live in the issue body — body digest in review anchors, gate without a spec file
The spec file solved exactly one problem — proving that a review verdict
was passed on a given text — and created two: docs/specs/README.md
conflicted between parallel tasks and served as a second, stale status
dictionary, and every spec edit cost a commit, a push and a label. The
proof moves into the pipeline.

- review-doc-guard: normalizeIssueBody / issueBodyDigest (CRLF, trailing
  whitespace, trailing newlines), the anchor line `Тело issue: <sha256>`,
  anchorIssueBodyFrom, and issueBodyChanged — the finding "the spec
  changed after a green spec review", judged against the pipeline's own
  record in the last green SPEC-REVIEW, never against prose.
- reusableGreenVerdict takes the current digest: reuse (#499) skips the
  model entirely, so without this a spec edit between rounds would pass
  unseen. Documents without the record (the whole backlog) keep judging
  by tree.
- process.yml: the material step reads the body with `gh issue view` in
  the same run that fixes the material — the event snapshot describes a
  text the reviewer may never see; the digest goes into the anchors, into
  reuse and, when it differs, into the reviewer's prompt.
- process-gate: rule 3 judges the text (a `## ТЗ` heading or an AC1) with
  the archived file still accepted; adding a new file under docs/specs/
  warns — the directory is frozen.
- task-packet reads AC from the body first, the archived file second.
- PROCESS.md §2.3/§5/§7.1/§7.3/§10.5, AGENTS.md and docs/specs/README.md
  say so; the index table is gone with the long-standing §7.3 debt.

Mutants: review-anchor-drops-issue-body, review-ignores-changed-spec-body,
reuse-ignores-changed-issue-body, process-gate-requires-spec-file.

Issue: #517
User-Visible: no
2026-09-10 10:18:17 +03:00
claude[bot] e20560afa8 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 07:08:05 +00:00
claude[bot] 6b31e94501 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 06:54:51 +00:00
claude[bot] a824acc1b1 docs: review document for #502
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 58s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 40s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m11s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #502
User-Visible: no
2026-09-10 06:34:45 +00:00
Codex a72f5274ca ci: main keeps its own validate.yml — only process.yml and mutation-gate.yml are mirrored
Mirroring validate.yml into main (e9638977, #510) left the branch with a
workflow its own tests do not describe: main still carries the v1.73.0
test suite, which pins the pre-#510 `changed_mutants` condition, so every
push to main has been red since 09.09 15:56. Only process.yml and
mutation-gate.yml must match across branches — they run from the default
branch, which is why the preflight compares exactly those two;
validate.yml runs from the branch under test and travels with dev on the
next stable promotion.

Issue: #510
User-Visible: no
2026-09-10 09:34:41 +03:00
Sergey Matyuninandclaude[bot] 441d3cc3de test(i18n-dead-keys): narrow dynamic consumers to key-shaped joins (#502)
Issue: #502
User-Visible: no
2026-09-10 06:27:59 +00:00
Codex 9ab4896c90 ci: mirror release.yml from dev (#514 — E2E gate for stable releases)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m55s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 1m58s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m5s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m4s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 1m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m58s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 2m3s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m10s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 42s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m59s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 21s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 48s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 9m58s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m26s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The `release` event runs the workflow from the default branch;
byte-identical to dev@44046025.

Issue: #514
User-Visible: no
2026-09-10 00:44:17 +03:00
claude[bot] 9293109ba1 docs: review document for #516
Проверка (CI) / Классификация изменённых файлов (push) Successful in 35s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 53s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 22s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 22s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m1s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m54s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m28s
Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
claude[bot] 96bfd94313 docs: review document for #516
Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
Codexandclaude[bot] 3ad5d0baea ci: merge-candidate compares patch-ids without the review documents
The candidate is the branch tip, which already carries the round's
CODE-REVIEW-N-rK.md; the material the reviewer read does not. With
docs/reviews in the diff the two patch-ids never matched once dev had
moved, so every green candidate went back to review whenever another
task published its own document in the meantime — #514 looped twice on
09.09 and #508 only merged when dev happened to stand still. The
patch-id now excludes docs/reviews, exactly like `reviewedFresh` next to
it; a real change of the patch under rebase still returns the task.

Mutant: merge-rereviews-own-review-doc.

Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
claude[bot] 440460255b docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:37:26 +00:00
Codex aeb0a473db ci: mirror process.yml from dev (#515 — material anchors after the rebase)
The review pipeline runs from the default branch; byte-identical to
dev@ad2858a8.

Issue: #515
User-Visible: no
2026-09-10 00:21:26 +03:00
Codexandclaude[bot] 0ce5e4eed3 ci: the E2E gate fails loudly when the release list cannot be read
Code review r3 (M1): realOps.releases() swallowed a failing `gh release
list` into an empty list, so a fine-grained token scoped to houseplan-e2e
alone would have dispatched with upgrade_from=stable — the tag onto
itself — and the red run would look like the bug 4143f998 already fixed.
The call now throws like dispatch() and lands in the same catch: result
`error` with the token hint, which now names both repositories. L4:
PROCESS.md says who dispatches and who waits.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] ed6d1d76db docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] a8a9b7d99e docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] 39571e20d6 docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 0796a01571 ci: the E2E gate recognises its run by the suites that install the tag
Two findings from the live run on v1.73.0 (houseplan-e2e run
34393136097): the upgrade job carries the previous stable's tag in its
name, so "any job with HP <tag>" let a gate for v1.72.0 adopt the
v1.73.0 run — recognition now keys on `journeys`/`first-run`; and the
first poll after a dispatch sees only the matrix-planning job, which
marked the run as foreign forever — a run without any `· HP … ·` job is
undecided and polled again. Live: v1.73.0 → green with the run link,
v1.72.0 → no run of its own.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 57b19f9914 ci: the E2E gate upgrades from the previous stable, not from the tag under test
Live run on v1.73.0 (houseplan-e2e run 34392391382): at `release:
published` the new tag is already the newest stable, so
`upgrade_from=stable` made the upgrade suite update v1.73.0 onto itself
and fail with `Expected: not "1.73.0"`. The gate now resolves the newest
non-prerelease, non-draft release other than the tag from `gh release
list` and passes it as `upgrade_from`; the first stable ever falls back
to `stable`. Spec §4/§6 record the change and the matrix-planning job in
houseplan-e2e (a job-level `if` cannot read `matrix.*`).

Mutant: release-upgrades-stable-onto-itself.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] c50458a098 ci: a stable release waits for a green E2E run on a real Home Assistant
The stable gate proved Validate and Full Performance on the exact SHA but
never ran the release in Home Assistant itself. houseplan-e2e installs
the release's houseplan.zip — the bytes HACS ships — into HA in docker
and walks the sidebar page, dashboards, roles, PDF, restart and the
stable→tag upgrade. release.yml now dispatches e2e.yml on the tag for
`!prerelease` releases and waits for it (scripts/e2e-gate.mjs, modelled
on validate-gate.mjs): the gate recognises its own run by `HP <tag>` in
the job names, ignores foreign dispatches, and reports red / missing /
cancelled / token error with the run link. Betas are untouched.

Mutants: release-ships-on-red-e2e, release-trusts-foreign-e2e-run.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] d20ef60037 docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 8f037a74dc docs: spec for #514 — E2E on a real Home Assistant as the stable release gate
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] ad2858a80c docs: review document for #515
Issue: #515
User-Visible: no
2026-09-09 21:13:52 +00:00
Codex 5a1cddeaf0 ci: review anchors are taken after the pipeline's rebase, not before
The material anchors (commit, tree, spec blobs) written into every
review document came from the checkout step, before "Привести ветку к
dev". Whenever dev had moved — since 09.09 every review-document publish
moves it — the pipeline rebased and force-pushed the branch, orphaning
the pre-rebase commit and its tree. A fresh clone in the next run could
not resolve that tree: reuse (#499) always reported false and the
model reviewed the same code again, and the #413 post-step refused the
green round because neither the cited SHA nor the tree anchor was
reachable — #508 took three identical green rounds this way.

The `material` step, which already fixes the reviewed SHA after the
rebase, now also records the tree and spec blobs, and the publish step
reads all three from it. The contract test pins the order and forbids
reading anchors from the checkout step.

Issue: #515
User-Visible: no
2026-09-10 00:04:41 +03:00
claude[bot] 3c1b4ebee2 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 20:55:09 +00:00
claude[bot] be93291065 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 20:02:08 +00:00
claude[bot] 6ac513caa0 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:49:18 +00:00
Codexandclaude[bot] e38beed796 fix: the summary panel settings dialog scrolls in Home Assistant
In HA hp-dialog renders ha-dialog, whose own `.body` is the scroller and
is not a flex container; `.summary-editor` (overflow:auto,
overscroll-behavior:contain, min-height:0) therefore grew to its content
and became a scroll container that never scrolls — Chromium stops wheel
and touch scroll chaining at such a child, so nothing moved (reproduced
on ha.jbstudio.pro, HA 2026.9.1, and in an isolated Playwright page).
hp-dialog gains an opt-in `flex-content` attribute forwarded as
ha-dialog's `flexcontent`, which lays the body out as a flex column: the
editor is height-bound again and scrolls itself, header and footer stay,
exactly as the native branch already did. Only the summary dialog opts in.

Smoke demo/smoke_summary_dialog_scroll.mjs stubs ha-dialog after the HA
2026.9 contract: wheel on desktop, touch swipe on a phone, dialog within
the viewport, and a witness that the stub reproduces the bug without
flexcontent. Docs screenshots: 11/11 pixel-identical (docs:accept
--identical, #512), fingerprint refreshed.

Mutants: summary-dialog-drops-flex-content, hp-dialog-ignores-flex-content.

Issue: #508
User-Visible: yes
2026-09-09 19:49:18 +00:00
claude[bot] 6b592c13c0 docs: review document for #502
Issue: #502
User-Visible: no
2026-09-09 19:35:30 +00:00
claude[bot] 72764cff90 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:21:54 +00:00
claude[bot] f02af9330b docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:01:45 +00:00
Codex c544c26b66 ci: mirror mutation-gate.yml from dev (#513 — nightly schedule)
The schedule runs from the default branch; byte-identical to dev@fa01aa00.

Issue: #513
User-Visible: no
2026-09-09 21:12:20 +03:00
claude[bot] fa01aa00bf docs: review document for #513
Issue: #513
User-Visible: no
2026-09-09 18:09:24 +00:00
Codex 0b9acd6382 docs: the local pre-push gate no longer calls the full mutation registry a pre-release gate
Code review r1 (M1): scripts/pre-push-gate.mjs — in its comment and in
the text every developer sees before a push — still named the full
mutation registry a pre-release gate; the same phrase lived in the
header of test/mutation-gate.test.mjs. Both now point at the nightly
schedule (#513); golden/smokes/HA harness are named as the heavy
Validate set on the candidate.

Issue: #513
User-Visible: no
2026-09-09 21:00:54 +03:00
claude[bot] f5fb7a0f8a docs: review document for #513
Issue: #513
User-Visible: no
2026-09-09 17:58:17 +00:00
Codex 61905bacdc ci: full mutation gate runs nightly, outside the development and release cycle
The full registry run proves that tests can fail, not that the product
works; 4 of its 5 runs since 02.09 were manual dispatches tied to
releases. Owner decision 09.09: a daily schedule (01:00 UTC, before the
02:30 nightly Validate), failures reported as an issue by the existing
#472 job, no place in the development or release flow. Docs and the
workflow comments say so; the test pins the daily cron.

Issue: #513
User-Visible: no
2026-09-09 20:49:08 +03:00
claude[bot] 9b65d4bcf7 docs: review document for #512
Проверка (CI) / Классификация изменённых файлов (push) Successful in 36s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m0s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 41s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 19s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m10s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m6s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m0s
Issue: #512
User-Visible: no
2026-09-09 16:15:24 +00:00
Codex e96389778f ci: mirror process.yml and validate.yml from dev (#510 — mutants on request, review gate)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m59s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 2m4s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m38s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m3s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 2m6s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 1m56s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m8s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 41s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 2m3s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 40s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 38s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 56s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 9m39s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m56s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 16m28s
The review pipeline runs from the default branch: the Validate-with-
mutants gate before Review and the `mutants` dispatch input must exist
here as well. Files are byte-identical to dev@33cb131b.

Issue: #510
User-Visible: no
2026-09-09 18:56:03 +03:00
Codex ffe2ec51c0 test: golden baselines show 0.0.0-golden instead of the card version
Re-acceptance after the version seam (#512 §7) from the full Validate
dispatch on 605991ef (run 34366858855): seven frames carried the version
text — three version-mismatch banners, three PDF footers, the support
preview — and now read `0.0.0-golden`; six of them were within threshold
and still showed stale betas (beta.4, beta.8). The other 162 frames are
kept as reviewed; 128 environment witnesses matched byte for byte.
Sub-threshold drift in nine unrelated frames (tray, resize handles,
compass, junction) is not accepted.

Issue: #512
User-Visible: no
Release: v1.74.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34366858855
2026-09-09 18:53:46 +03:00
Codex b7d8b9e6ee test: displayed version through a seam; docs:accept --identical for pixel-identical screenshots
Golden frames carried the card version text (about, version banner,
support/export previews), so every beta bump re-accepted up to 20
baselines that had not visually changed. The version now reaches the DOM
and stand requests through displayVersion() (src/card-version.ts); the
golden harness pins window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'
before the card is created. CARD_VERSION literals stay where the release
contract reads them; cache-busting and the console banner keep the literal.

Docs screenshots: `npm run docs:accept -- --identical` re-captures
locally, compares decoded RGBA pixels with the committed frames inside
Chromium (scripts/png-identical.mjs) and, only when every frame is
identical, refreshes the manifest fingerprints and captureScriptSha256;
bytes stay, any difference refuses with a per-frame count. First run on
this tree: 11/11 identical, manifest refreshed.

Mutants: version-seam-ignores-override, docs-identical-accepts-any-frame.

Issue: #512
User-Visible: no
2026-09-09 18:53:38 +03:00
claude[bot]andCodex e5a62f7ffd docs: review document for #512
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
Codex 7674b78c33 docs: spec #512 r2 — capture.mjs untouched, --identical refreshes the capture-script guard
Spec review r1 (H1): the `--out` flag would have changed the only file
guarded by `captureScriptSha256` and reddened check-docs on its own. The
identical-accept mode now leaves demo/docs/capture.mjs as is (frames are
backed up and restored around the standard capture) and takes
`captureScriptSha256` from the candidate manifest together with the
source fingerprint; the docs re-acceptance for this issue is the first
local --identical run in the same branch.

Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
claude[bot]andCodex 3f402a3481 docs: review document for #512
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
Codex 278d9257eb docs: spec for #512 — version seam outside golden frames, docs:accept --identical
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
claude[bot] 33cb131b3a docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:52:11 +00:00
Codex 615181050b test: the real waitValidate is exercised against a cancelled dispatch
Code review r2 (M1): the cancelled-run filter in merge-candidate's
waitValidate had no test or mutant — every test replaced ops.waitValidate
with a fake. realOps now takes an injectable `exec` (default: the same
spawnSync wrapper) so the real implementation runs against scripted
`gh run list` answers: a cancelled dispatch is skipped and its
replacement followed; a lone cancelled run ends in `missing`, never red.

Mutant: merge-trusts-cancelled-dispatch.

Issue: #510
User-Visible: no
2026-09-09 18:45:44 +03:00
claude[bot] 8acbd333d8 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:43:09 +00:00
Codex 00130e08aa ci: a cancelled Validate dispatch proves nothing to the review gate
Code review r1 (M1): validate-gate.mjs and merge-candidate's waitValidate
read a `cancelled` dispatch run on the material as red, so a dispatch
replaced by the next one in the `validate-dispatch-<ref>` concurrency
group would have returned the task S7→S6 for nothing — the same class
#511 fixed in release-gate.mjs. Cancelled runs are now ignored: the gate
follows the replacement dispatch, or starts its own when there is none.

Mutant: review-returns-task-on-cancelled-dispatch.

Issue: #510
User-Visible: no
2026-09-09 18:33:24 +03:00
claude[bot] f6e64ba985 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:29:18 +00:00
Codexandclaude[bot] cbece6324f test: re-anchor the review-starts-on-red-validate mutant after the r1 verdict split
The mutant registry pointed at the pre-r1 verdict helper that no longer
exists; the anchor test caught it in CI. The patch now removes the red
branch of the completed-run check.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 97dfa457a4 ci: diff mutants only on request; the review pipeline proves them on the material before reviewing
Validate ran the three "Мутанты по диффу" shards on every push of every
branch: 48 of 56 job-hours on 08–09.09, most of them cancelled by the
next push. Mutants now run when asked — pull requests, the nightly
schedule, a push carrying a `Release:` trailer, or a dispatch with
`mutants=true` (classify-changes.mjs → `mutants_requested`); an ordinary
push runs the light checks only.

The proof moves to where it is consumed. process.yml gets a gate after
the #499 reuse step: on the code stage it looks for a dispatch Validate
run on the exact material SHA whose mutant jobs executed and passed
(scripts/validate-gate.mjs); none → it dispatches one and waits; red or
missing → the task goes back S7→S6 with the run link and the review
cycle is not spent. Spec stage and the reuse fast-path skip the gate
(`proceed=true`); all later steps branch on `proceed` in place of the
old conflict conjunct only. merge-candidate.mjs dispatches Validate on
the pushed candidate and waits for that dispatch run.

PROCESS.md/AGENTS.md: review does not start on red code; one handoff —
one push.

Mutants: mutants-run-on-every-push, review-starts-on-red-validate,
review-trusts-push-run-without-mutants, merge-waits-push-run-without-mutants.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 34c5c2d134 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 73d6e2eeeb docs: spec #510 r4 — proceed replaces only the conflict conjunct
Spec review r3: `proceed` is true on the reuse fast-path too, so it must
replace the `rebase.conflict != 'true'` conjunct alone; the existing
`reuse != 'true'` (#499), stage and decide conjuncts stay on every step
that has them. The "single variable" claim is narrowed accordingly.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 24cd39fca5 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 65526a6a7f docs: spec #510 r3 — one branching variable for the gate outcome
Spec review r2: §5.2 named the gate outcome three different ways; the
skip branch (spec stage, reuse fast-path) would have matched a literal
`result != 'green'` and produced a spurious S7→S6 return. All step
conditions now branch on `proceed` only (true = green or skipped, false =
red/missing); `result` feeds the comment text alone.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] b593489041 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] a91ba16a5c docs: spec #510 r2 — gate after reuse, proof requires executed mutant jobs
Spec review r1: place the gate after the #499 reuse step so its output is
defined; a green dispatch counts only when the "Мутанты по диффу" jobs ran
and passed (a foreign dispatch with mutants=false leaves them skipped);
therefore the Validate job runs whenever mutants are requested, even on an
empty selection. Explicit User-Visible/UX/i18n N/A statement added.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 1063156f12 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 2b8b7b2aa7 docs: spec for #510 — mutants on the review candidate, review waits for a green Validate, handoff rules
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] eed7239aa3 docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:45:45 +00:00
Codex 9d8f89d260 ci: release gate judges the latest non-cancelled Validate run of the SHA
The gate used to require every Validate run on the tag SHA to be green:
a cancelled duplicate or a red flake that a later re-run had fixed kept
the stable release blocked (v1.73.0, 09.09 — released by hand). Now the
verdict comes from the newest run that was not cancelled: not completed →
wait, success → pass, anything else → fail, no run → wait. The same rule
is documented for the perf workflow and the release runbook.

Mutants: release-gate-counts-cancelled-runs, release-gate-oldest-run-wins.

Issue: #511
User-Visible: no
2026-09-09 17:38:48 +03:00
claude[bot] 590b3a7e67 docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:27:52 +00:00
claude[bot] 6842b69eed docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:20:48 +00:00
Codex ceab345215 perf: give the Stage 3 dense isometric budget the same long-task count allowance (#507)
The #160 contract test keeps the dense profile's longTasks block equal to
the historical isometric one, and both profiles boot through the same lazy
iso-scene-render chunk; the accepted split applies to both. Validate
34356856702 caught the divergence.

Issue: #507
User-Visible: no
Release: v1.73.0
2026-09-09 16:30:18 +03:00
Codex d72cb704f3 perf: accept the lazy isometric chunk boot-task split in the isometric long-task count budget (#507)
Full Performance of the v1.73.0 stable candidate against the v1.72.0 product
(run 34354409872) was red on one check of the isometric profile:
longTask.countP95 16 → 20 against max(16×1.2, 16+3) = 19.2, with every
timing, longTask.totalP95Ms and longTask.maxSingleMs green. The trace behind
#506 shows why: since v1.73.0-beta.1 the isometric renderer is the lazy
iso-scene-render chunk (#160 Stage 3), so the single v1.72.0 boot task is
split in two around that import — the same work, +2 tasks.

Owner decision 2026-09-09: accept the split. countNoiseAllowance 3 → 5 for
large-house-isometric-v1 only; the ratio, the hard ceiling, total and
maximum single task keep gating real growth. The downloaded CI artefact
re-evaluated with this budget passes (limit 21, actual 20, no failures).
Documented in demo/performance/README.md; the budget test pins the
allowance and the untouched profiles.

Issue: #507
User-Visible: no
Release: v1.73.0
2026-09-09 16:24:15 +03:00
Codex 913e3187f1 Release v1.73.0
Promote the nine published v1.73.0 betas without new product behaviour:
stable version fields, synchronized generated bundles, the aggregated
bilingual release notes from v1.72.0 and status metadata only. beta.9
carried the startup-regression fix (#506) that Full Performance caught on
the first promotion attempt; the stable body keeps it out as an in-line
regression per the #328 curation rules.

Issue: #506
User-Visible: no
Release: v1.73.0
2026-09-09 15:22:01 +03:00
Codex 43809178ee test: poll smoke_preloader's reduced-motion phase from Playwright, with navigation diagnostics
The beta.9 candidate failed the same phase twice in CI with "Resulting
promise was garbage collected" (runs 34346813552, 34347910231) while it
passes locally; a timer guard did not help, which points at a destroyed
context rather than a starved animation-frame chain. The wait now runs as
page.waitForFunction with raf polling, and the smoke logs frame navigations
and page crashes as `diagnostic …` lines so the next failure names its
cause. Verdict unchanged (animationName of the boot house, or 'missed').
Pre-release gate repair per PROCESS §11.4; locally OK ×2.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 15:05:46 +03:00
Codex 581d165bda test: keep smoke_preloader's reduced-motion wait alive without animation frames
Validate 34346813552 on the beta.9 candidate failed only in browser smoke
shard 1: smoke_preloader phase 3 died with "Resulting promise was garbage
collected" — its rAF-only wait for the boot house had no other reference
while the runner withheld animation frames. A timer now bounds the wait
and keeps the promise reachable; the verdict is unchanged (animationName
of the house, or 'missed'). Pre-release gate repair per PROCESS §11.4:
locally `node demo/smoke_preloader.mjs` → OK ×2; all other heavy gates of
that run (golden, perf-smoke, backend, shards 2–3) were green.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 14:53:45 +03:00
Codex a9d0cab2dd Prepare v1.73.0-beta.9
Version fields and generated bundles move to 1.73.0-beta.9; the #506
changelog entry leaves Unreleased for the beta.9 section; release notes and
STATUS describe the startup-regression fix that unblocks the stable
promotion. No product change beyond #506, already reviewed and merged.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 14:40:28 +03:00
claude[bot] 480180290e docs: review document for #506
Issue: #506
User-Visible: no
2026-09-09 11:35:33 +00:00
Codex 9d6ac98d3a fix: attach a warm summary runtime before the first render (#506)
Every card instance attached its summary-panel runtime through
import().then(), even when the chunk had loaded long ago. The first render
therefore measured a header without summary controls; the controls arrived
a beat later, the stage shrank, the deferred refit opened a `stage-resize`
continuity candidate and the first HA tick paid three extra render passes
(Full Performance: blend stateUpdate1 50 → 130 ms, overlay 217 → 809 ms;
locally 4 performUpdate per tick instead of 1).

summary-runtime-loader.ts separates the summary code from its state: the
loaded factory is cached per page, every host builds its own runtime from
it (no shared preferences, drafts, subscriptions, timers or DOM). A warm
factory yields the runtime synchronously in connectedCallback, before the
first Lit render; a cold mount still pays one lazy import, concurrent cold
mounts share the pending import, a failed import is forgotten so the next
connection retries, and a disconnect cancels the pending attachment of that
connection. SummaryRuntimeSlot owns the per-host lifecycle so the card core
stays under its line ceiling.

Witnesses: loader unit tests (distinct instances, shared pending import,
cancelled attachment, retry after failure); demo/smoke_summary_warm_attach
(warm replacement and cold-key instance on a warm page own the runtime
before the first render, header/stage stable from the first frame, no
stage-resize, one performUpdate per geometry-neutral tick, a real viewport
resize still opens stage-resize); mutant summary-runtime-attaches-after-
first-render. smoke_summary_panel waited for `_summary` as a readiness
proxy; it now waits for the server config load, which stays asynchronous.

Local paired glow benchmarks (4× CPU throttle): blend 159.7 → 49.9 ms and
overlay 326.7 → 120.4 ms at stateUpdate1 with renders 4 → 1; the isometric
load loses the three summary-owned long tasks.

Docs screenshots: all 11 frames decode pixel-identical to the committed
ones; the manifest carries only the new source fingerprint. Initial View
ceiling recentred 299 100 → 299 600 for the +299 B loader.

Issue: #506
User-Visible: yes
2026-09-09 13:57:53 +03:00
claude[bot] 2e502e09a2 docs: review document for #506
Issue: #506
User-Visible: no
2026-09-09 10:02:25 +00:00
Matysh e17cdb5162 docs: specify startup performance regression fix
Issue: #506
User-Visible: no
2026-09-09 12:57:12 +03:00
Matysh d11f95bc40 Release v1.73.0 candidate
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m0s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m12s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 42s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 42s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m5s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 4m21s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 4m0s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m1s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 7m16s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m20s
Promote the eight published v1.73.0 betas without new product behavior. Synchronize version fields and generated bundles; aggregate public release notes from v1.72.0. Main-only workflow mirrors were merged with an identical beta.8 product tree.

Issue: #505
User-Visible: no
Release: v1.73.0
2026-09-09 12:05:08 +03:00
Matysh 6dfcb0e9bb Merge origin/main before v1.73.0 promotion 2026-09-09 11:58:56 +03:00
Matysh 8b07711d27 Release v1.73.0-beta.8: accept reviewed panel-control golden frames
Personally reviewed all 20 diffs from complete Linux capture34323743229: #505 header/control placement and beta version text only. 149 baselines remain untouched,112 exact witnesses. Product and timing guard unchanged; candidate reruns the same50ms search threshold after a single CI timing failure (local p95=17ms).

Issue: #505
User-Visible: no
Release: v1.73.0-beta.8
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34323743229
2026-09-09 10:35:04 +03:00
Matysh ab71f27298 Release v1.73.0-beta.8 candidate
Accept complete Linux documentation capture 34323466079 after personal review: four expected header/control changes, seven byte-identical witnesses. Unrelated dialog footer dimensions restored. Scope: independently accepted #495, #498 and #505.

Issue: #495
Issue: #498
Issue: #505
User-Visible: no
Release: v1.73.0-beta.8
2026-09-09 10:25:00 +03:00
Matysh 3d2bfe9f6d Keep footer documentation out of the shipped CSS
Move the explanatory comment to TypeScript without changing styles; preserve the existing bundle-budget noise margin. Include independently accepted #498 in the release status.

Issue: #505
Issue: #498
User-Visible: no
2026-09-09 10:21:48 +03:00
Matysh 6fe01ecc1f Prepare v1.73.0-beta.8 and preserve dialog footer targets
Restore the shared footer minimum exposed by the #505 Linux screenshot gate; the focused browser assertion fails before the restoration and passes after it.

Issue: #505
Issue: #495
User-Visible: yes
2026-09-09 10:19:04 +03:00
claude[bot] 7d30a39020 docs: review document for #498
Issue: #498
User-Visible: no
2026-09-09 07:14:55 +00:00
Codexandclaude[bot] 4f040c4c8e fix: exact upload quota, support palette allowlist, bounded SVG reference chains (#498)
Attachment uploads stage the body as `.upload-*` under files_root and then
asked the quota to count that file as stored usage *and* as the incoming
size, so the last file that still fit was refused at the boundary — by
bytes and by count. check_quota/dir_usage now take `exclude` for the
caller's own staged file; other staged files keep counting, so two
concurrent uploads can never both land past the limit.

The support package copied every string key of settings.fill_colors. The
schema stays open for compatibility, but the projection now keeps only the
eleven slots the card defines (SUPPORT_FILL_COLOR_KEYS, pinned to
src/logic.ts DEFAULT_FILL_COLORS by a test); an empty palette is omitted.

The SVG local-reference walk was a recursive DFS: a flat chain of a few
thousand hrefs passed every #436 bound and died with RecursionError, which
the upload view turned into a 500. The walk is iterative and measures the
longest chain through each node (memoised, order-independent); chains
deeper than MAX_SVG_REF_DEPTH = 64 are refused as too_large, cycles stay
invalid_image.

Tests: quota boundaries on the validator and the HA endpoint, a barrier
test for concurrent uploads, palette allowlist and TS parity, reference
chains (plain, hostile id order, cycle) on the validator and the endpoint;
six mutants caught by the standard runner.

Issue: #498
User-Visible: yes
2026-09-09 07:06:20 +00:00
claude[bot] 5f8d40caf0 docs: review document for #498
Issue: #498
User-Visible: no
2026-09-09 07:06:20 +00:00
Codexandclaude[bot] c71f21f3c9 docs: spec #498 r2 — numbered AC, longest-chain reference limit, concurrent quota semantics
Spec review r1: add the AC list with proofs and the perf/touch statement;
measure the SVG reference limit as the longest chain through a node
(memoised), not the stack height of the first traversal, so a hostile id
order cannot cut a long chain into short segments; state that two uploads
checked while both are staged are both refused (conservative), never both
stored.

Issue: #498
User-Visible: no
2026-09-09 07:06:20 +00:00
claude[bot] 5d6f610a7c docs: review document for #498
Issue: #498
User-Visible: no
2026-09-09 07:06:20 +00:00
Codexandclaude[bot] ac478127d5 docs: spec for #498 — exact upload quota, support palette allowlist, bounded SVG reference chains
Issue: #498
User-Visible: no
2026-09-09 07:06:20 +00:00
claude[bot] 2aa6ed21e7 docs: review document for #505
Issue: #505
User-Visible: no
2026-09-09 07:01:42 +00:00
Matysh 7b9af60541 Remove unused summary visibility translations (#505)
Issue: #505
User-Visible: no
2026-09-09 09:54:14 +03:00
claude[bot] f03a9e5ddc docs: review document for #505
Issue: #505
User-Visible: no
2026-09-09 06:48:00 +00:00
Matysh d32dfc9be1 Record unchanged visual evidence after dev integration (#505)
Issue: #505
User-Visible: no
2026-09-09 09:33:28 +03:00
Matysh 2f607a04f1 Record summary panel visual and negative-witness evidence (#505)
Issue: #505
User-Visible: no
2026-09-09 09:32:36 +03:00
Matysh 05778a47c0 Align summary panel with designer prototype (#505)
Issue: #505
User-Visible: yes
2026-09-09 09:32:36 +03:00
claude[bot]andMatysh d23cb267d0 docs: review document for #505
Issue: #505
User-Visible: no
2026-09-09 09:32:23 +03:00
Matysh bb14064b18 docs: address summary panel spec review r1
Issue: #505
User-Visible: no
2026-09-09 09:32:23 +03:00
claude[bot]andMatysh f29b9831a5 docs: review document for #505
Issue: #505
User-Visible: no
2026-09-09 09:32:23 +03:00
Matysh 598e95f4c4 docs: specify summary panel designer parity
Issue: #505
User-Visible: no
2026-09-09 09:32:23 +03:00
claude[bot] 2e387ac1e0 docs: review document for #495
Issue: #495
User-Visible: no
2026-09-09 05:48:32 +00:00
Codex 2946e28352 fix: import result follows the commit; dropped route runs reach the store (#495)
Apply re-validated the preview token after both halves were durable, so a
TTL that lapsed during the write, or an eviction by a newer preview of the
same user, answered "preview expired" for a plan that was already replaced
and withheld both update events. The token is now simply spent after the
commit; validity is decided once, on entry under write_lock.

Route runs dropped by drop_unknown_routes never reached the store unless a
marker happened to be orphaned in the same pass; an idle robot kept them in
memory only and a restart brought them back. The drop now happens under
_refresh_lock, leaves with the orphan transaction or with an immediate
write of its own, and rolls back like #335 when the store refuses.

Tests: HA harness for both apply races and a live config/set route drop,
recorder stubs for the four durability cases; five mutants caught by the
standard runner.

Issue: #495
User-Visible: yes
2026-09-09 08:34:46 +03:00
claude[bot] adbd349920 docs: review document for #495
Issue: #495
User-Visible: no
2026-09-09 05:33:25 +00:00
Codex 70a394d228 docs: spec for #495 — import result follows the commit, dropped route runs reach the store
Issue: #495
User-Visible: no
2026-09-09 08:26:52 +03:00
Sergey Matyunin abbca50ce7 build: prepare v1.73.0-beta.7 candidate
Release: v1.73.0-beta.7
Issue: #437
Issue: #485
Issue: #489
Issue: #490
Issue: #491
Issue: #492
Issue: #493
Issue: #499
User-Visible: no
2026-09-09 07:04:02 +03:00
Sergey Matyunin 7f1cae21cf test: expose resize smoke coordinate diagnostics (#437)
Issue: #437
User-Visible: no
2026-09-09 06:51:55 +03:00
Sergey Matyunin 8e48ba80f6 build: prepare v1.73.0-beta.7 candidate
Release: v1.73.0-beta.7
Issue: #437
Issue: #485
Issue: #489
Issue: #490
Issue: #491
Issue: #492
Issue: #493
Issue: #499
User-Visible: no
2026-09-09 06:45:58 +03:00
Sergey Matyunin 8721d7d911 docs: refresh fixed warm-remount screenshot fingerprint (#437)
Issue: #437
User-Visible: no
2026-09-09 06:45:49 +03:00
Sergey Matyunin 1d072be0a9 fix: freeze warm-remount stage through chrome settle (#437)
Issue: #437
User-Visible: yes
2026-09-09 06:43:16 +03:00
Sergey Matyunin b607ade972 build: prepare v1.73.0-beta.7 candidate
Release: v1.73.0-beta.7
Issue: #437
Issue: #485
Issue: #489
Issue: #490
Issue: #491
Issue: #492
Issue: #493
Issue: #499
User-Visible: no
2026-09-09 06:27:50 +03:00
Sergey Matyunin a56df6eb8e docs: refresh warm-remount screenshot fingerprint (#437)
Issue: #437
User-Visible: no
2026-09-09 06:27:36 +03:00
Sergey Matyunin 590d6b59b6 fix: preserve warm-remount viewport through layout settle (#437)
Issue: #437
User-Visible: yes
2026-09-09 06:24:32 +03:00
Sergey Matyunin 244efed878 ci: persist mutation ledger between rerun attempts (#499)
Release: v1.73.0-beta.7
Issue: #499
User-Visible: no
2026-09-09 05:30:00 +03:00
Sergey Matyunin 7648ea79ce test: accept presence-toggle golden for beta.7 (#485)
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34298831137
Issue: #437
Issue: #485
Issue: #489
Issue: #490
Issue: #491
Issue: #492
Issue: #493
Issue: #499
User-Visible: no
Release: v1.73.0-beta.7
2026-09-09 04:28:32 +03:00
Sergey Matyunin 931f1d45aa build: refresh v1.73.0-beta.7 candidate after #485
Issue: #437
Issue: #485
Issue: #489
Issue: #490
Issue: #491
Issue: #492
Issue: #493
Issue: #499
User-Visible: no
Release: v1.73.0-beta.7
2026-09-09 04:17:13 +03:00
claude[bot] e77f7b7361 docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 01:12:38 +00:00
Matysh ca25e91087 test: keep radar bundle ratchet outside gzip noise
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m36s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 3m42s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m4s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 20s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 20s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 11m19s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 12m16s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 13m34s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 9m19s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Successful in 1h36m17s
Issue: #485
User-Visible: no
2026-09-09 04:01:00 +03:00
Matysh 3f0b4aeeea test: cover radar radial calibration guard
Issue: #485
User-Visible: no
2026-09-09 03:54:42 +03:00
claude[bot]andMatysh 02289aee44 docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 03:54:09 +03:00
Matysh f2007326c4 docs: record radar field evidence
Issue: #485
User-Visible: no
2026-09-09 03:54:09 +03:00
Matysh e9fb255a29 fix: close radar review gaps
User-Visible: yes
Issue: #485
2026-09-09 03:54:02 +03:00
claude[bot]andMatysh f911a442c4 docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 03:53:33 +03:00
Matysh d433b64cf0 fix: keep ordinary marker dialog close synchronous
User-Visible: no
Issue: #485
2026-09-09 03:53:33 +03:00
Matysh 3938caf00a build: integrate radar after dev rebase
User-Visible: no
Issue: #485
2026-09-09 03:52:46 +03:00
Matysh d12488f40d fix: harden presence radar stage one
User-Visible: yes
Issue: #485
2026-09-09 03:52:23 +03:00
claude[bot]andMatysh 63eb4f315c docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 03:52:22 +03:00
Matysh 95a3889699 test: cover radar backend contracts
User-Visible: no
Issue: #485
2026-09-09 03:52:22 +03:00
Matysh a53356e090 fix: close radar backend gate gaps
User-Visible: no
Issue: #485
2026-09-09 03:52:22 +03:00
Matysh e6a31489fe fix: satisfy backend radar lint gate
User-Visible: no
Issue: #485
2026-09-09 03:52:22 +03:00
Matysh e645e6df9f chore: refresh radar config schema
User-Visible: no
Issue: #485
2026-09-09 03:51:25 +03:00
Matysh 58f5f18540 feat: add presence radar stage 1
User-Visible: yes
Issue: #485
2026-09-09 03:51:25 +03:00
claude[bot]andMatysh 7e4751ba62 docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
Sergey MatyuninandMatysh 4e9feaaead docs: preserve manual radar setup entry (#485)
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
claude[bot]andMatysh 1d3d379d6c docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
Sergey MatyuninandMatysh 51d5858b3d docs: restrict radar setup section to eligible devices (#485)
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
claude[bot]andMatysh a253dc83be docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
Sergey MatyuninandMatysh 1fff72aa3f docs: address radar specification review #485
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
claude[bot]andMatysh 2d8e7871f6 docs: review document for #485
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
Sergey MatyuninandMatysh 78afb68962 docs: specify radar presence stages for #485
Issue: #485
User-Visible: no
2026-09-09 03:50:50 +03:00
Sergey Matyunin 3434747d1c build: prepare v1.73.0-beta.7 candidate
Проверка (CI) / Классификация изменённых файлов (push) Successful in 58s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 14m42s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m20s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 24s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 12m28s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 12m22s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 13m36s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 16m48s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 10m4s
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34294899732
Issue: #437
Issue: #489
Issue: #490
Issue: #491
Issue: #492
Issue: #493
Issue: #499
User-Visible: yes
Release: v1.73.0-beta.7
2026-09-09 03:38:16 +03:00
claude[bot] 260435e438 docs: review document for #493
Issue: #493
User-Visible: no
2026-09-09 00:21:40 +00:00
Sergey Matyunin 578cae5240 fix: harden summary panel settings and writes (#493)
Issue: #493
User-Visible: yes
2026-09-09 02:53:36 +03:00
claude[bot]andSergey Matyunin 0bce4b1123 docs: review document for #493
Issue: #493
User-Visible: no
2026-09-09 02:53:36 +03:00
Sergey Matyunin 13a0bfdf87 docs: define summary panel hardening contract (#493)
Issue: #493
User-Visible: no
2026-09-09 02:53:36 +03:00
Claude 687056f3f5 ci: check-inputs trackedFiles no longer prints git noise on synthetic trees
`execFileSync('git ls-files')` inherited stderr, so every call on a test's
temporary tree printed "fatal: not a git repository" although the failure is
caught and the tree is walked instead — dozens of lines in the owner's Windows
run and in CI logs. stderr is ignored; behaviour unchanged.

Issue: #496
User-Visible: no
2026-09-09 02:29:08 +03:00
Claude eb3abe2053 test: spawn CLI scripts via fileURLToPath, not URL.pathname
Owner's Windows run after #496 (2338 pass, 2 fail): both remaining failures
are tests building the script path as `new URL(...).pathname`, which is
`/C:/Users/...` on Windows and makes Node look for `C:\C:\Users\...`.
check-inputs and classify-changes CLI tests now use fileURLToPath; the
portability test forbids `import.meta.url).pathname` in test/**.

Issue: #496
User-Visible: no
2026-09-09 02:18:58 +03:00
claude[bot] 3113ca8c57 docs: review document for #491
Issue: #491
User-Visible: no
2026-09-08 23:17:51 +00:00
Sergey Matyuninandclaude[bot] 1787e505a1 test: prove unresolved pair blocks following writes (#491)
Issue: #491
User-Visible: no
2026-09-08 23:01:49 +00:00
claude[bot] a0f61551cb docs: review document for #491
Issue: #491
User-Visible: no
2026-09-08 23:01:49 +00:00
Sergey Matyuninandclaude[bot] ce575f4012 docs: remove trailing blank from #491 spec
Issue: #491
User-Visible: no
2026-09-08 23:01:49 +00:00
Sergey Matyuninandclaude[bot] 488f70c493 fix: make paired plan writes recover before the next edit (#491)
Issue: #491
User-Visible: yes
2026-09-08 23:01:49 +00:00
claude[bot] a0a9e4b579 docs: review document for #491
Issue: #491
User-Visible: no
2026-09-08 23:01:49 +00:00
Sergey Matyuninandclaude[bot] 18e98ac5f2 docs: specify durable Optimize and Undo recovery (#491)
Issue: #491
User-Visible: no
2026-09-08 23:01:49 +00:00
Codex fff171c7dc ci: mirror process.yml from dev (#503 — deterministic Claude Code install)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 37m3s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 40m28s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 48m49s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 51m39s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m45s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 29m14s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 26m20s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 51m32s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m2s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 27s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 25m16s
Issue: #503
User-Visible: no
2026-09-09 02:00:55 +03:00
Codex 9bfe78850d ci: install Claude Code binary ourselves before the review action (#503)
claude-code-action v1.0.218 (Claude Code 2.1.265) runs `claude install`,
which on ubuntu-latest sometimes leaves no launcher at ~/.local/bin/claude
while still reporting success; the action trusts the exit code and the SDK
then fails with ENOENT (anthropics/claude-code-action#1817). Four review
runs in a row died this way after 22:27 UTC 08.09.

Add a step that fetches the exact version the action pins (read from its
run.ts, fallback 2.1.265) from downloads.claude.ai, verifies the sha256
from the release manifest, checks `--version`, and hands the path to the
action via `path_to_claude_code_executable`, which makes the action skip
its own installer entirely.

Issue: #503
User-Visible: no
2026-09-09 02:00:39 +03:00
Claude e0e68f255d ci: added npm script does not select every mutant
Run 2795: changed_mutants shard 1/3 hit the 30-minute job limit with zero
failures. package.json sits in the guard-input closure of 195 of 590 mutants
(`npm run …`, `npx …`), so adding one script — toolchain:check — selected
nearly the whole registry. A guard depends only on what it calls: a changed
or removed existing script, dependencies, engines. An added script is not an
input of any earlier guard. packageJsonRelevance() decides from the base and
head package.json; unparsable or anything outside scripts still counts as
relevant. For the same range the selection drops from 209 to 38.

Issue: #496
User-Visible: no
2026-09-09 01:58:26 +03:00
Claude 71aeb86079 test: check-docs mutant guard judges docs, not screenshot freshness
Run 2793 (changed_mutants 2/3): the clean run of
`resource-docs-flatten-current-yaml` was red before any mutation because the
screenshot fingerprint is stale after #490 — strict mode, which #479 reserves
for the beta candidate. The guard now runs `--screenshots=warn`; a test keeps
every check-docs guard in that mode.

Issue: #496
User-Visible: no
2026-09-09 01:20:06 +03:00
Claude f0c8ae24ba infra: portable Windows gate, CI toolchain pins, task packet, deterministic wait, gate:small --smokes
Windows portability (the three red tests on the owner's machine at green CI):
- scripts/spawn-portable.mjs: isMainModule via pathToFileURL (the
  `file://${argv[1]}` form gives file:///C:/C:/... and the CLI stays silent);
  portableCommand — a shell only for npm/npx/.cmd, node and git run directly
  (spawn via shell dropped the quotes of `node -e "…"`). Applied to
  classify-changes, mutation-gate-report, review-doc-guard, check-inputs,
  merge-candidate, gate-small, rebase-on-dev.
- the rebase-on-dev test pins core.autocrlf=false / core.eol=lf through
  GIT_CONFIG_* for its temp repository instead of touching the user's git
  config; test/windows-portability.test.mjs forbids both anti-patterns.

Pins: scripts/toolchain-pins.mjs reads Node/Python from validate.yml, the HA
stack from tests_backend/requirements.txt, Playwright/Chromium from the
lockfile — no second dictionary; `npm run toolchain:check` compares the
machine; .nvmrc/.python-version are derived and tested equal;
scripts/wsl-setup.sh provisions WSL/Linux with those pins.

scripts/task-packet.mjs: one derived view of an issue (status, rights, owner
decisions, branch vs dev, Validate on the tip, previous verdict with recorded
tree, AC → evidence, unwitnessed). scripts/wait-verdict.mjs: polls labels,
pipeline comments and optionally Validate, prints only on state change, exit
0/3/4, writes nothing.

gate:small --smokes: after build the browser phase runs bundle-sync and then
the directly matched and registered smokes, two at a time; broad matches stay
with the reviewer. package.json changed, so the bundle is rebuilt here.

Issue: #496
User-Visible: no
2026-09-09 01:11:24 +03:00
Codex c5325b0a17 ci: зеркало process.yml и nightly.yml из dev — точный кандидат и ожидание Validate (#492)
Issue: #492
User-Visible: no
2026-09-09 00:57:05 +03:00
claude[bot] 6e95c649e6 docs: review document for #492
Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Codexandclaude[bot] 405bce42c3 docs: spec notes how the manifest categories are computed
Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
claude[bot] 165ba9eca2 docs: review document for #492
Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Codexandclaude[bot] a358ed11fd docs: input manifest and exact-candidate merge in TESTING.md and PROCESS.md
Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Codexandclaude[bot] 32b1baa189 ci: merge the exact candidate; nightly waits for its Validate
scripts/merge-candidate.mjs owns the review pipeline's merge: when dev
moved during review, the rebased candidate is pushed to the issue branch,
its diff is compared to the reviewed one by patch-id, Validate on that SHA
is awaited, and only then dev is advanced with --force-with-lease on the
base the candidate was built on — a rejected lease restarts, at most three
times. Every non-merge outcome moves the label with a comment, so the
"label always changes" invariant holds. nightly.yml now finds the Validate
run it dispatched and inherits its conclusion. Three mutants guard this.

Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Codexandclaude[bot] 51beeeb2c4 ci: mutant selection sees wrapper defaults, guard imports, fixtures and registry edits
guardInputs() replaces guardFiles() in selection and fingerprints: the
files named in the guard, the GUARD_INPUTS a wrapper declares (read
statically — the wrappers run on import), and the closure of imports and
path literals of every guard file, stopping at src/** which stays the
patch side. A diff that touches the registry itself selects every added or
changed definition against the base registry read from git. Five mutants
guard the manifest and this selection.

Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Codexandclaude[bot] 658e395360 ci: one input manifest for job selection and reuse keys
scripts/check-inputs.mjs declares every Validate check with its roots and
entry points and computes the rest: imports and path literals of the
entries, transitively for code, as leaves for data. classify-changes and
gate-reuse both read it, so "which job runs" and "what its key hashes"
cannot disagree any more. An executable file no check knows widens the run
to the full set and is named in the summary; the coverage list makes such
a file a red unit test rather than a permanent widening. The workflow file
is a toolchain input of every job; backend no longer hashes src/**.

Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Codexandclaude[bot] 227b73683e docs: AC6 gets its negative probe (spec review r1)
Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
claude[bot] fd64422276 docs: review document for #492
Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Codexandclaude[bot] 6b70e5a3ad docs: spec for the exact integration candidate and the input manifest
Issue: #492
User-Visible: no
2026-09-08 21:55:07 +00:00
Claude d271d8beab docs: specs/README — статус задачи это метка, Projects v2 не используется
Файл называл GitHub Projects v2 частью канонического backlog и держал
второй словарь статусов ТЗ. Проекты не используются с 2026-08-14 (#139),
статус — метка на issue (PROCESS §9); лёгкий трек держит ТЗ в теле issue.

Issue: #499
User-Visible: no
2026-09-09 00:47:06 +03:00
claude[bot] c9a0690c2d docs: review document for #490
Issue: #490
User-Visible: no
2026-09-08 21:20:14 +00:00
Sergey Matyunin 01c653a4c6 fix: preserve backdrop continuity during summary recovery (#490)
Issue: #490
User-Visible: yes
2026-09-09 00:10:16 +03:00
claude[bot]andSergey Matyunin 87f19941a3 docs: review document for #490
Issue: #490
User-Visible: no
2026-09-09 00:10:16 +03:00
Sergey Matyunin a43bc3eb27 fix: keep summary recovery and live state current (#490)
Issue: #490
User-Visible: yes
2026-09-09 00:10:16 +03:00
claude[bot]andSergey Matyunin 5c9e79081b docs: review document for #490
Issue: #490
User-Visible: no
2026-09-09 00:10:16 +03:00
Sergey Matyunin fd364c57a8 docs: specify summary recovery consistency (#490)
Issue: #490
User-Visible: no
2026-09-09 00:10:16 +03:00
Claude 7f029f768d ci: зеркало process.yml из dev — идемпотентный контроллер ревью (#499)
Конвейер исполняется из ветки по умолчанию; preflight требует совпадения
main = dev. Содержимое — коммит 24c1b723 в dev.

Issue: #499
User-Visible: no
2026-09-09 00:09:54 +03:00
Claude 24c1b723f9 infra: idempotent review controller, verdict reuse, single mutant build, current docs
Review pipeline (process.yml):
- concurrency moves from the workflow to the guard/review jobs and the guard
  runs only for S4-spec-review / S7-code-review. Any other label used to enter
  the issue's concurrency group and evict the pending review run (sample of
  150 runs since 2026-09-01: 92 empty guard-only runs, 30 cancelled).
- the guard reads the issue's current labels instead of the event snapshot; a
  label removed before the run starts is a withdrawn request, no comment.
- a green verdict is re-applied without calling the model when the latest
  review document carries the pipeline-recorded verdict `green`/High 0 and the
  tree differs from its anchor in nothing outside docs/reviews/** (#437 r4
  re-reviewed an unchanged tree for 7 minutes). The verdict from
  structured_output is now written into the anchor block for that purpose.
- the reviewer is pinned to the captured material SHA in the prompt; the
  broken escaping in the "merge cancelled" comment (empty SHAs) is fixed.

Mutation gate: nine browser guards started with `npm run bundle:sync` although
the runner already builds the mutant bundle — a second rollup plus a
`tsc --noEmit` that fails on a non-strict mutant before the smoke even runs.
Prefix removed; `--check` refuses guards that build the bundle themselves.

Docs: SCOPE (Project v2 dropped, three editors), STATUS (#437 merged, HACS zip
automated), USER-GUIDE ru/en (static card shows live states; kiosk double tap
on free background fits all), #34 → #425 references, #367 named as closed in
bundle-budget messages, PROCESS §10.4 and AGENTS.md describe the controller.

Issue: #499
User-Visible: no
2026-09-09 00:06:27 +03:00
claude[bot] ea6061e9fa docs: review document for #489
Issue: #489
User-Visible: no
2026-09-08 19:35:20 +00:00
Sergey Matyunin 5586e0591c perf: recenter initial bundle ceiling for data-hp contract (#489)
Issue: #489
User-Visible: no
2026-09-08 21:46:30 +03:00
Sergey Matyunin 15e0efe3bf docs: accept data-hp contract screenshots (#489)
Issue: #489
User-Visible: no
2026-09-08 21:30:58 +03:00
Sergey Matyunin 9c61ac6768 feat: declare stable data-hp test contract (#489)
Issue: #489
User-Visible: yes
2026-09-08 21:26:29 +03:00
claude[bot] 1d2ed1e30d docs: review document for #489
Issue: #489
User-Visible: no
2026-09-08 18:08:10 +00:00
Sergey Matyunin e072b22274 docs: address first spec review for data-hp contract (#489)
Issue: #489
User-Visible: no
2026-09-08 21:04:21 +03:00
claude[bot] a48827ec7c docs: review document for #489
Issue: #489
User-Visible: no
2026-09-08 18:03:05 +00:00
Sergey Matyunin 9cf10bdde7 docs: specify public data-hp test contract (#489)
Issue: #489
User-Visible: no
2026-09-08 20:53:22 +03:00
claude[bot] 586494b726 docs: review document for #437
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 50s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 29s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 55s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #437
User-Visible: no
2026-09-08 17:44:24 +00:00
claude[bot] 63ceef6618 docs: review document for #437
Issue: #437
User-Visible: no
2026-09-08 17:37:25 +00:00
Sergey Matyunin f87d3dc545 fix: stabilize summary panel transitions (#437)
Issue: #437
User-Visible: yes
2026-09-08 20:23:20 +03:00
claude[bot] b371b68309 docs: review document for #437
Issue: #437
User-Visible: no
2026-09-08 17:09:16 +00:00
Sergey Matyunin 89976c85ed fix: settle first camera action after summary layout (#437)
Issue: #437
User-Visible: yes
2026-09-08 19:51:04 +03:00
Sergey Matyunin 336d8f30ba test: allow inert summary style in lost-space smoke (#437)
Issue: #437
User-Visible: no
2026-09-08 19:30:18 +03:00
Sergey Matyunin af44114992 fix: sort summary API backend import (#437)
Issue: #437
User-Visible: no
2026-09-08 19:24:27 +03:00
Sergey Matyunin 3c1a9f8ff7 fix: harden summary panel review cases (#437)
Issue: #437
User-Visible: yes
2026-09-08 19:20:39 +03:00
claude[bot] 584a154099 docs: review document for #437
Issue: #437
User-Visible: no
2026-09-08 16:12:14 +00:00
Sergey Matyunin 96e07b9a02 feat: add configurable summary panel (#437)
Issue: #437
User-Visible: yes
2026-09-08 18:49:36 +03:00
claude[bot] 7dbb6c8e72 docs: review document for #437
Issue: #437
User-Visible: no
2026-09-08 14:17:34 +00:00
Sergey Matyunin 14597ccdf9 docs: address spec review locale coverage (#437)
Issue: #437
User-Visible: no
2026-09-08 17:11:44 +03:00
claude[bot] 2a7d1dd206 docs: review document for #437
Issue: #437
User-Visible: no
2026-09-08 14:09:48 +00:00
Sergey Matyunin 4a1aac16fe docs: specify configurable summary overlay (#437)
Issue: #437
User-Visible: no
2026-09-08 16:59:58 +03:00
Matysh ed9ee026dc build: prepare v1.73.0-beta.6 candidate
Проверка (CI) / Классификация изменённых файлов (push) Successful in 41s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 55s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 31s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 51s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 10m22s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m20s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 20m14s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 10m33s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 12m20s
Issue: #487
Issue: #488
User-Visible: yes
Release: v1.73.0-beta.6
2026-09-08 12:09:47 +03:00
claude[bot] 3a702b3124 docs: review document for #487
Проверка (CI) / Классификация изменённых файлов (push) Successful in 24s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 43s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 32s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 32s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 46s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m21s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 13m11s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 10m1s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 18m36s
Issue: #487
User-Visible: no
2026-09-08 08:11:43 +00:00
Matysh 68462b81e0 docs: refresh screenshot provenance after rebase
All eleven documented frames remain byte-identical in the canonical Linux capture after combining #487 with current dev.

Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34201357630
Issue: #487
User-Visible: no
2026-09-08 10:54:17 +03:00
Matysh 89deee0fb2 test: accept room temperature golden baselines
The two reviewed scenes come from the canonical full Linux Validate capture for the current source fingerprint.

Issue: #487
Release: v1.73.0-beta.6
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34198109380
User-Visible: no
2026-09-08 10:49:07 +03:00
Matysh ea12650f33 fix: type room temperature controls
Issue: #487
User-Visible: no
2026-09-08 10:44:49 +03:00
Matysh e2751062dc feat: add per-room temperature thresholds
Issue: #487
User-Visible: yes
2026-09-08 10:44:13 +03:00
claude[bot]andMatysh d2d9b1ac5d docs: review document for #487
Issue: #487
User-Visible: no
2026-09-08 10:42:13 +03:00
Matysh 42b44c7564 docs: specify room temperature thresholds
Issue: #487
User-Visible: no
2026-09-08 10:42:13 +03:00
claude[bot] b13a63f587 docs: review document for #488
Issue: #488
User-Visible: no
2026-09-08 07:27:14 +00:00
Codex 2b2fc94479 docs: refresh screenshot provenance for the panel mount fix
Fingerprint-only: the panel change does not alter any documented frame
(the docs harness already gave the panel host the viewport height), so the
committed images stay and only the source provenance moves.

Issue: #488
User-Visible: no
2026-09-08 10:10:20 +03:00
Codex e97e568111 docs: record the real HA panel container and mount order in the #486 spec
Issue: #488
User-Visible: no
2026-09-08 10:05:45 +03:00
Codex 2877213396 test: replace the content-slot height assumption with the #488 contract
Issue: #488
User-Visible: no
2026-09-08 10:05:45 +03:00
Codex 471527c474 fix: mount the House Plan panel the way Home Assistant does
HA assigns panel/hass/narrow/route before the top-level-await entry has
defined the element, so the values landed as own properties that shadowed
the accessors and the card never received hass. And <ha-panel-custom> has
no height, so the percentage host height collapsed the stage to 0 px.
Adopt pre-upgrade properties through the accessors and size the panel from
the viewport minus HA's safe-area padding. The smoke now reproduces HA's
real mount order and container; two mutants guard both contracts. The
bundle is rebuilt from these sources.

Issue: #488
User-Visible: yes
2026-09-08 10:05:45 +03:00
Sergey Matyunin cba4eeba56 ci: вернуть validate.yml main к прежней версии — скриптов dev в main нет (#479)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 1m55s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m59s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m1s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m2s
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 2m0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 2m0s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 45s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m3s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 2m4s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m6s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
В main остаётся только nightly.yml: он диспатчит Validate на dev,
где workflow_dispatch уже есть. Run 2643 падал на classify-changes.mjs.

Issue: #479
User-Visible: no
2026-09-06 19:31:51 +03:00
Sergey Matyunin 90472ee583 ci: nightly.yml и workflow_dispatch Validate в main (#479)
Issue: #479
User-Visible: no
2026-09-06 19:27:10 +03:00
Codex 0d85510ba3 ci: зеркало mutation-gate.yml из dev — адресат у отказов расписания
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 2m40s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 3m33s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 33m54s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 35m46s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 43m8s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m6s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 27s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m16s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 22m51s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 59m21s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 34m8s
Расписание исполняет файл из ветки по умолчанию; содержимое идентично dev.

Issue: #472
User-Visible: no
2026-09-06 15:38:04 +03:00
653 changed files with 77215 additions and 21024 deletions
+16 -23
View File
@@ -3,9 +3,14 @@ name: Анонс релиза
# Stable releases are announced; prereleases are deliberately silent.
# workflow_dispatch exists purely as a connectivity test button and therefore
# remains allowed to send a test message.
# #538: НЕТ триггера `release: published`. Три воркфлоу висели на одном событии
# и бежали параллельно, а анонсу нечего было проверять — он выигрывал гонку
# всегда. 12.09 v1.75.0 объявили в канале в ту же минуту, когда гейт ассетов
# отказал: релиз остался без `houseplan-card.js`, E2E не выполнялся вовсе, а
# подписчики получили сообщение. Анонс вызывается только после того, как работа
# сделана: `release.yml` зовёт его после выкладки ассетов,
# `publish-prerelease.yml` — после публикации беты.
on:
release:
types: [published]
workflow_dispatch: {}
workflow_call:
inputs:
@@ -37,7 +42,7 @@ permissions:
jobs:
telegram:
name: Оповещение в Telegram (только стабильные)
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
runs-on: ubuntu-latest
steps:
- name: Check out release notes for a reusable call
@@ -54,32 +59,20 @@ jobs:
INPUT_NAME: ${{ inputs.release_name }}
INPUT_URL: ${{ inputs.url }}
INPUT_PRE: ${{ inputs.prerelease }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
RELEASE_BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
if [ "$CALLED" = "true" ]; then
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
else
TAG=$RELEASE_TAG
NAME=$RELEASE_NAME
URL=$RELEASE_URL
PRE=$RELEASE_PRE
BODY=$RELEASE_BODY
fi
# Единственный путь к сообщению о релизе — вызов из воркфлоу, который
# уже закончил работу (#538). Тело берётся из файла заметок ветки
# тега, а не из события: события здесь больше нет.
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
if [ "$PRE" = "true" ]; then
echo "Prerelease Telegram announcement is disabled"
exit 0
+12 -10
View File
@@ -5,11 +5,12 @@ name: Мутационный гейт
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
# test/mutation-gate.test.mjs.
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
# Мутанты, задетые диффом, конвейер ревью гоняет отдельно на кандидате (#510).
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
# обычными юнитами: test/mutation-gate.test.mjs.
#
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
@@ -25,14 +26,15 @@ on:
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
- cron: '0 1 * * *'
permissions:
contents: read
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
# запуск (отладка гейта) отменял идущий по расписанию — так 24.08 погиб
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
@@ -111,8 +113,8 @@ jobs:
if-no-files-found: warn
retention-days: 30
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
# для отладки самого гейта, его результат смотрят в прогоне — issue на
# каждый такой отказ был бы шумом, который снова перестанут читать.
#
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
+28 -2
View File
@@ -13,6 +13,11 @@
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
# там полный набор идёт заново.
#
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
# успехом в момент постановки Validate в очередь, и красный полный прогон не
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
# его: успешный dispatch — не успешная проверка.
name: Ночной полный прогон dev
on:
@@ -26,13 +31,34 @@ permissions:
jobs:
dispatch:
name: "Запустить Validate на dev с полным набором"
name: "Запустить Validate на dev с полным набором и дождаться результата"
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
since=$(date -u +%FT%TZ)
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)"
echo "Validate(dev, full=true) поставлен в очередь: $since"
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
# раньше момента запуска. До трёх минут на появление в списке.
run_id=""
for _ in $(seq 1 18); do
sleep 10
run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
[ -n "$run_id" ] && break
done
if [ -z "$run_id" ]; then
echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
exit 1
fi
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
echo "дочерний прогон: $url"
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
# Ждём завершения; красный дочерний прогон — красный ночной.
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
+310 -101
View File
@@ -21,10 +21,12 @@ on:
issues:
types: [labeled]
concurrency:
# Два события по одному issue не должны запускать два прогона.
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
# Concurrency стоит на job, а не на workflow (#499). На уровне workflow в
# группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки,
# которую переставил сам конвейер. GitHub держит в группе один идущий и один
# ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review
# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает
# ни одной job (`if` на guard) и в группу не входит.
permissions:
contents: read
@@ -36,7 +38,13 @@ permissions:
jobs:
guard:
name: "Страж: ребейз на dev и предпосылки ревью"
# Только статусные метки этапов ревью запускают конвейер (#499). Остальные
# события помечаются skipped и не занимают место в группе concurrency.
if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review'
runs-on: ubuntu-latest
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
outputs:
stage: ${{ steps.decide.outputs.stage }}
cycle: ${{ steps.decide.outputs.cycle }}
@@ -57,12 +65,20 @@ jobs:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
REPO: ${{ github.repository }}
LABEL: ${{ github.event.label.name }}
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
NUM: ${{ github.event.issue.number }}
run: |
# Контроллер идемпотентен (#499): событие только будит его, а состояние
# читается ТЕКУЩЕЕ, не из снимка события. Прогон мог простоять в
# очереди concurrency, пока владелец снял метку или поставил blocked —
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
has blocked && BLOCKED=true
has review-4 && EXHAUSTED=true
has small && SMALL=true
has trivial && TRIVIAL=true
# Этап определяется первым: от него зависит, какие вердикты считать.
stage=""; marker=""
case "$LABEL" in
@@ -70,6 +86,13 @@ jobs:
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
*) echo "метка $LABEL конвейер не запускает" ;;
esac
# Метка, породившая событие, уже снята — запрос отозван. Это не отказ
# и не повод для комментария: владелец передумал сам, шум ему не нужен.
if [ -n "$stage" ] && ! has "$LABEL"; then
echo "метка $LABEL уже снята с issue — запрос отозван, конвейер не запускается (#499)"
echo "запрос отозван: \`$LABEL\` снята до старта" >> "$GITHUB_STEP_SUMMARY"
stage=""
fi
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
limit=4
@@ -236,6 +259,9 @@ jobs:
needs: guard
if: needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
steps:
@@ -295,20 +321,10 @@ jobs:
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
# ветку, и спрашивать его поздно.
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
# Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ
# ребейза (#515): снятые здесь, они после force-push приведённой
# ветки указывали на осиротевший коммит, и ни reuse (#499), ни
# страховка #414 не находили дерева в свежем клоне.
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
@@ -331,6 +347,8 @@ jobs:
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
# #539: тем же токеном спрашивается REST — через него идёт и диспатч.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
# rebase, в отличие от commit, не принимает -c user.*: он запускает
# свои процессы и требует личность в окружении, иначе падает с
@@ -373,6 +391,25 @@ jobs:
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
exit 1
fi
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
# нельзя. 12.09 на #536 диспатч, отправленный через три секунды после
# этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на
# материале и вернул задачу автору, которому чинить было нечего.
# Поэтому шаг не заканчивается, пока REST не отдаст новую вершину —
# именно REST, потому что через него же идёт и сам диспатч.
after=$(git rev-parse HEAD)
settled=false
for _ in $(seq 1 30); do
seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \
--jq .object.sha 2>/dev/null || true)
if [ "$seen" = "$after" ]; then settled=true; break; fi
sleep 2
done
if [ "$settled" != "true" ]; then
echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA"
exit 1
fi
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
# и без этого он ребейзил бы заново уже приведённое.
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
@@ -387,9 +424,73 @@ jobs:
- name: Зафиксировать SHA материала ревью
id: material
if: steps.rebase.outputs.conflict != 'true'
env:
NUM: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "материал ревью: $(git rev-parse --short HEAD)"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая
# копия равна тому, что ревьюер прочтёт, и коммит с этим деревом
# уже запушен в ветку — следующий прогон найдёт его в свежем клоне.
# В шаге публикации дерево уже сброшено на целевую ветку, и
# спрашивать его поздно.
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
# Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это
# единственный якорь, доказывающий «вердикт вынесен на этом тексте».
# Читается здесь, а не из github.event.issue.body: между событием
# метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание
# Validate), и снимок события описывал бы не тот текст.
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 { readFileSync } = await import("node:fs");
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
});
' "$body")
echo "issue_body=$digest" >> "$GITHUB_OUTPUT"
echo "тело issue: ${digest:0:12}"
else
echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт"
fi
echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')"
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
# в S7, и ревьюер двенадцать минут заново разбирал дерево, в котором с r3
# изменился ровно один файл — его собственный документ r3. Правило узкое:
# последний документ этапа несёт записанный конвейером вердикт `green`
# с High 0, и `git diff` между его якорем-деревом и HEAD пуст вне
# docs/reviews/**. Любое иное отличие — ребейз, тест, фикстура, скрипт,
# ТЗ — даёт полный разбор. Только этап code: материал spec может жить в
# теле issue, которого в дереве нет.
- name: "Зелёный вердикт прошлого захода применим без ревью (#499)"
id: reuse
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
# Правка ТЗ между раундами обязана отменять повторное применение
# зелёного вердикта: иначе вызов модели пропускается и находку
# «ТЗ менялось» некому напечатать (#517 AC6).
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
run: |
out=$(node 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"
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
echo "вердикт прошлого захода применяется повторно: модель не вызывается" >> "$GITHUB_STEP_SUMMARY"
fi
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
@@ -426,6 +527,64 @@ jobs:
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
# запускает Validate с мутантами на материале и ждёт его. Красный или
# пропавший прогон возвращает задачу автору без ревью — цикл не
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
# слияние само дожидается Validate на кандидате.
- name: Validate с мутантами на материале
id: gate
if: steps.rebase.outputs.conflict != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
STAGE: ${{ needs.guard.outputs.stage }}
REUSE: ${{ steps.reuse.outputs.reuse }}
BRANCH: ${{ steps.branch.outputs.name }}
SHA: ${{ steps.material.outputs.sha }}
run: |
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
exit 0
fi
if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then
echo 'proceed=true' >> "$GITHUB_OUTPUT"
else
echo 'proceed=false' >> "$GITHUB_OUTPUT"
fi
- name: Validate красный — вернуть автору без ревью
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
SHA: ${{ steps.material.outputs.sha }}
RESULT: ${{ steps.gate.outputs.result }}
NOTE: ${{ steps.gate.outputs.note }}
URL: ${{ steps.gate.outputs.url }}
run: |
short=$(git rev-parse --short "$SHA")
cat > /tmp/gate.md <<EOF
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
Задача переведена в \`S6-in-progress\`. Осталось:
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
2. дождаться зелёного дешёвого Validate на пуше;
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
# ревью тратится на повторение CI вместо чтения кода.
@@ -435,7 +594,7 @@ jobs:
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
- name: Зелёные гейты на этом SHA
id: validated
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
@@ -464,21 +623,21 @@ jobs:
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
@@ -486,9 +645,66 @@ jobs:
# внятной ошибкой — тогда флаг вернуть.
run: npx playwright install chromium
# Action ревью ставит Claude Code через `claude install`, и с его
# v1.0.218 (Claude Code 2.1.265) лаунчер ~/.local/bin/claude на
# ubuntu-latest иногда не появляется, хотя установщик рапортует об успехе;
# action верит рапорту и падает на ENOENT (anthropics, issue 1817).
# Кладём бинарник сами: версию берём ту, что пинит сам action (он уже
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
- name: Установить Claude Code детерминированно
id: claude_bin
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
run: |
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
ver="${ver:-2.1.265}"
base=https://downloads.claude.ai/claude-code-releases
bin="$HOME/.local/bin/claude"
mkdir -p "$(dirname "$bin")"
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
echo "$sum $bin" | sha256sum -c -
chmod +x "$bin"
"$bin" --version
echo "path=$bin" >> "$GITHUB_OUTPUT"
# ТЗ живёт в теле issue (#517), а GitHub хранит его правки без diff и без
# доступной чтением истории. Единственное, что можно доказать, — «текст
# изменился после того, как ревью ТЗ признало его зелёным». Это не отказ:
# автор вправе уточнять ТЗ. Это находка, доставляемая ревьюеру кода в
# промпт и владельцу в комментарий вердикта: разбор AC в таком заходе
# обязан быть полным, а не по дельте.
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
id: spec_body
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
DIGEST: ${{ steps.material.outputs.issue_body }}
run: |
if [ -z "$DIGEST" ]; then
echo "хеша тела нет — сравнивать не с чем"
exit 0
fi
out=$(node -e '
import("./scripts/review-doc-guard.mjs").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 ""; } };
const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n")
.filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name));
const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) }));
const changed = m.issueBodyChanged(docs, digest);
if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`);
else process.stdout.write("changed=false\n");
});
' "$NUM" "$DIGEST")
printf '%s\n' "$out"
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
- name: Review
id: review
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
uses: anthropics/claude-code-action@v1
env:
# Вне рабочей копии: восстановление дерева ревьюером не должно
@@ -498,6 +714,7 @@ jobs:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
prompt: |
Ты ревьюер проекта House Plan. Язык ответа — русский.
@@ -514,6 +731,8 @@ jobs:
${{ steps.rebase.outputs.note }}
${{ steps.spec_body.outputs.changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', steps.spec_body.outputs.doc, steps.spec_body.outputs.recorded) || '' }}
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
всех раундов, и повторный цикл заново выводил продуктовую рамку и
@@ -559,8 +778,9 @@ jobs:
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
есть у старой задачи, он и есть материал, новые не создаются.
Проверь обязательные разделы §7.1, однозначность каждого AC и
указание способа доказательства. Отдельно проверь, что автор не
выдал догадку за решение: утверждение о поведении, которого нет ни
@@ -573,7 +793,14 @@ jobs:
решаешь по существу в своём вердикте.
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
`${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не
делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
ним. Если автор в issue называет более новый коммит, которого в
материале нет, — это находка «материал не был запушен до метки», а не
повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так,
#499). Ручного тестирования в цикле нет,
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
По каждому AC: либо он доказан автотестом и ты убедился, что тест
умеет падать, либо разобран по коду с явной записью «проверено
@@ -704,7 +931,7 @@ jobs:
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
@@ -712,10 +939,17 @@ jobs:
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
# После ребейза конвейером — якоря приведённого материала (#515).
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
# Вердикт из structured_output попадает в блок якорей (#499): по нему
# следующий заход решает, можно ли применить зелёный вердикт повторно.
OUT: ${{ steps.review.outputs.structured_output }}
run: |
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
# Ветки задачи может не быть: у задач, размеченных до появления
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
@@ -770,7 +1004,9 @@ jobs:
# может; блок помечен как машинный, чтобы никто не правил его руками.
node scripts/review-doc-guard.mjs --anchor="$doc" \
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS"
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
--issue-body="$MATERIAL_ISSUE_BODY" \
--verdict="$verdict" --high="$high"
else
echo "::warning::$SOURCE не найден — документа для публикации нет"
fi
@@ -860,7 +1096,7 @@ jobs:
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
# пользы нет — он достанет только то, что есть на origin.
- name: "Материал раунда воспроизводим (#413)"
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
env:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
@@ -877,13 +1113,30 @@ jobs:
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
- name: Решение по вердикту
id: decide
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true'
env:
OUT: ${{ steps.review.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
REUSE: ${{ steps.reuse.outputs.reuse }}
REUSE_DOC: ${{ steps.reuse.outputs.doc }}
REUSE_ROUND: ${{ steps.reuse.outputs.round }}
REUSE_TREE: ${{ steps.reuse.outputs.tree }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
run: |
verdict=$(echo "$OUT" | jq -r '.verdict')
high=$(echo "$OUT" | jq -r '.high')
if [ "$REUSE" = "true" ]; then
# Модель не вызывалась: вердикт — записанный конвейером зелёный
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
verdict=green; high=0
short_tree=$(printf '%s' "$REUSE_TREE" | cut -c1-12)
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
"Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC}
Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор."
else
verdict=$(echo "$OUT" | jq -r '.verdict')
high=$(echo "$OUT" | jq -r '.high')
fi
echo "вердикт: $verdict, High: $high"
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
@@ -915,7 +1168,7 @@ jobs:
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
# случится (#364).
- name: dev ушёл вперёд, пока шло ревью
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
if: steps.gate.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
@@ -934,16 +1187,19 @@ jobs:
# S8-merged утверждает, что код в dev. Значит слияние обязано произойти
# ДО метки, иначе она врёт в промежутке.
#
# При конфликте шаг НЕ падает и метку не оставляет на месте. Первая
# редакция делала именно так, и это оказалось тупиком: автор ждёт смену
# метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы
# затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант
# теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА.
# Слияние — точный кандидат (#492 §4, scripts/merge-candidate.mjs):
# ветка сверяется с материалом (#312); если dev не двигался — push с
# lease на текущую вершину; если двигался — ребейз, сравнение patch-id
# с проверенным диффом, публикация кандидата в ветку, ожидание
# Validate на этом SHA и только потом push в dev с lease. Повторное
# движение dev — новая попытка, не более трёх. Каждый исход, кроме
# успеха, ведёт в S6-in-progress/S7-code-review с комментарием, ПОСЛЕ
# ПРОГОНА МЕТКА МЕНЯЕТСЯ ВСЕГДА — инвариант тот же, что и раньше.
- name: Слить ветку в dev
id: merge
if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
NUM: ${{ github.event.issue.number }}
@@ -954,61 +1210,11 @@ jobs:
echo "merged=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch -q origin dev "$BRANCH"
# #312: сливается только проверенный код. Допустимые вершины ветки:
# сам SHA материала либо он же плюс ровно один коммит публикации
# документа ревью (дифф только docs/reviews/). Любой другой коммит —
# ветка уехала после ревью, вердикт к ней не применим: возврат в
# S6-in-progress через merged=false, как при конфликте.
actual=$(git rev-parse "origin/$BRANCH")
reviewed="$MATERIAL_SHA"
fresh=false
if [ "$actual" = "$reviewed" ]; then
fresh=true
elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \
&& [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then
fresh=true
fi
if [ "$fresh" != true ]; then
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)"
cat > /tmp/stale-verdict.md <<EOF
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
exit 0
fi
git checkout -q -B merge-into-dev "$actual"
if ! git -c user.name="claude[bot]" \
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
rebase origin/dev; then
git rebase --abort || true
echo "merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::ветка $BRANCH не сливается в dev без конфликта"
cat > /tmp/conflict.md <<EOF
**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`$BRANCH\` конфликтует с \`dev\`.
Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:
1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;
2. запушить ветку;
3. вернуть метку \`S7-code-review\`.
Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/conflict.md
exit 0
fi
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev
echo "merged=true" >> "$GITHUB_OUTPUT"
echo "слито в dev: $(git rev-parse --short HEAD)"
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
--issue="$NUM" --repo="${{ github.repository }}"
- name: Переставить метку
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true'
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
@@ -1016,7 +1222,10 @@ jobs:
FROM: ${{ steps.decide.outputs.from }}
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
# автору: метка утверждала бы, что код в dev, а его там нет.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }}
# Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 —
# когда кандидат не слит (конфликт, красный Validate, изменившийся
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
run: |
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label "$TO" --remove-label "$FROM"
+31
View File
@@ -50,6 +50,20 @@ jobs:
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
# #514: the only check on a real Home Assistant. houseplan-e2e installs
# the release's houseplan.zip — the bytes HACS ships — into HA in docker
# and walks the sidebar page, dashboards, roles, PDF, restart and the
# stable→tag upgrade. A red, missing or cancelled run withholds the
# assets exactly like Full Performance. Cross-repository dispatch needs a
# token with Actions: write on houseplan-e2e; HP_PROCESS_TOKEN (classic,
# repo scope) has it, E2E_DISPATCH_TOKEN is the fallback for a
# fine-grained token.
- name: Require green E2E on a real Home Assistant for a stable release
if: ${{ !github.event.release.prerelease }}
env:
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: node scripts/e2e-gate.mjs --tag="$TAG"
build:
name: Сборка бандла и загрузка ассетов
needs: gate
@@ -82,6 +96,23 @@ jobs:
uses: softprops/action-gh-release@v3
with:
files: dist/houseplan-card.js
announce:
# #538: анонс — последнее звено, а не параллельное. Пока он висел на самом
# событии `release: published`, он обгонял гейт: 12.09 v1.75.0 объявили в
# канале в ту же минуту, когда проверка отказала выкладывать ассеты.
# `needs: build` означает, что молчание — это тоже ответ: красный гейт или
# несостоявшаяся выкладка сообщения не рождают.
name: Оповещение о релизе после выкладки
needs: build
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ github.event.release.tag_name }}
release_name: ${{ github.event.release.name }}
url: ${{ github.event.release.html_url }}
prerelease: ${{ github.event.release.prerelease }}
ref: ${{ github.event.release.tag_name }}
secrets: inherit
hacs-discovery:
name: HACS-видимость пре-релиза (порядок бет)
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
+100 -39
View File
@@ -18,9 +18,15 @@ on:
workflow_dispatch:
inputs:
full:
description: 'Полный набор: смоки, golden, performance_smoke'
description: 'Полный набор: смоки, golden, performance_smoke, мутанты по диффу'
type: boolean
default: true
# Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние
# кандидата запускают `-f full=false -f mutants=true` на материале.
mutants:
description: 'Мутанты по диффу на этом SHA (кандидат ревью)'
type: boolean
default: false
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
@@ -215,13 +221,13 @@ jobs:
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
mutants: ${{ steps.classify.outputs.mutants }}
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
heavy: ${{ steps.heavy.outputs.heavy }}
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
# судить сам диапазон на dev (#388). Общее имя означало бы, что
# потребитель однажды молча возьмёт чужую базу.
# `base` — до какого коммита классифицировать файлы ветки (#387),
# `range_base` — от какого судить диапазон на dev (#388).
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
@@ -241,6 +247,7 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
MUTANTS_INPUT: ${{ inputs.mutants }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
@@ -320,13 +327,28 @@ jobs:
fi
files=$(git diff --name-only "$base" "$HEAD_SHA")
printf '%s\n' "$files" | head -50
# Шаблоны — в scripts/classify-changes.mjs: там они проверяемы
# unit-тестом, здесь — только переписываются в выходы (#473 AC8).
printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT"
# Решение — в scripts/classify-changes.mjs по единому manifest входов
# (#492 §5.2): там оно проверяемо unit-тестом, здесь — только
# переписывается в выходы (#473 AC8).
printf '%s\n' "$files" | node scripts/classify-changes.mjs > /tmp/classify.out
tee -a "$GITHUB_OUTPUT" < /tmp/classify.out
# Файл, которого нет в manifest ни одной проверки, — не «не влияет»,
# а «неизвестно»: прогон расширен до полного набора, и это видно в
# summary, а не только в логе.
unknown=$(sed -n 's/^unknown_inputs=//p' /tmp/classify.out)
if [ -n "$unknown" ]; then
{
echo "### Неизвестные входы (#492)"
echo "Файлы, которых нет в manifest ни одной проверки — прогон расширен до полного набора:"
for f in $unknown; do echo "- \`$f\`"; done
echo "Впишите файл в \`CHECKS\` или \`NOT_AN_INPUT\` (scripts/check-inputs.mjs) с причиной."
} >> "$GITHUB_STEP_SUMMARY"
fi
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
# Переиспользование результата тяжёлой job (#208). Ключ = хеш содержимого
# всех входов job по единому manifest (#492, scripts/check-inputs.mjs):
# что собирает, что исполняет, что читает, чем запускается — включая
# `validate.yml`. Маркер в кэше пишет только успешный
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
# же входами уже завершилась успешно.
#
@@ -461,14 +483,21 @@ jobs:
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
# детерминированных непересекающихся шарда вместо ослабления проверки.
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
# материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на
# промежуточных пушах она стоила 48 из 56 часов job-минут и в основном
# отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
# её исход по job, и skipped был бы неотличим от «не запрашивали».
changed_mutants:
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
needs: changes
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
if: needs.changes.outputs.mutants_requested == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5, 6]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -479,33 +508,30 @@ jobs:
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
# сохраняется при любом исходе шага, а следующий пуш начинает с того
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (файлы патча и гарда, без строки версии) повторно не гоняется.
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
# пропуск станет известен только после установки окружения.
- name: Журнал свидетелей (последний по шарду)
uses: actions/cache/restore@v6
with:
path: artifacts/mutation-ledger
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
restore-keys: mutation-ledger-${{ matrix.shard }}-
- name: Затронутые мутанты ловятся
# Cache immutable: rerun с тем же run_id обязан получить новый key,
# иначе post-save не сможет записать прогресс поверх первой попытки.
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
mutation-ledger-${{ matrix.shard }}-
# #518: план шарда считается до установки окружения. npm ci, Python с
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
# их платили даже тогда, когда гонять было нечего: правка задела чужой
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
# git и node из образа.
- name: План шарда
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
@@ -527,22 +553,57 @@ jobs:
fi
if [ -z "$base" ]; then
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "диапазон: $base..$HEAD_SHA"
echo "base=$base" >> "$GITHUB_OUTPUT"
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
echo "к прогону в этом шарде: ${count:-0}"
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
- if: steps.plan.outputs.count != '0'
run: npm ci
- if: steps.plan.outputs.count != '0'
uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
if: steps.plan.outputs.count != '0'
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
if: steps.plan.outputs.count != '0'
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Затронутые мутанты ловятся
if: steps.plan.outputs.count != '0'
env:
BASE: ${{ steps.plan.outputs.base }}
HEAD_SHA: ${{ github.sha }}
SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
# пока job не убита целиком. Ключ уникален на прогон: cache не
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
# пока job не убита целиком. Ключ уникален на попытку: cache не
# перезаписывает существующий ключ, а restore-keys берёт предыдущую
# попытку этого run либо самый новый журнал шарда.
- name: Сохранить журнал свидетелей
if: always()
uses: actions/cache/save@v6
with:
path: artifacts/mutation-ledger
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
frontend:
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
+3 -1
View File
@@ -1,4 +1,6 @@
node_modules/
# Без слэша: с ним шаблон не покрывает СИМВОЛИЧЕСКУЮ ССЫЛКУ с этим именем,
# и `git add -A` в чужом worktree затягивает её в коммит (#520, прежде #429).
node_modules
tsout/
test-build/
*.log
+1
View File
@@ -0,0 +1 @@
22
+1
View File
@@ -0,0 +1 @@
3.14
+64 -12
View File
@@ -158,7 +158,16 @@ remaining work is the rebase. When the conflict is only in the committed bundle
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
the result into your last commit; a conflict anywhere else aborts and leaves the
tree as it was. Then push the branch and re-apply `S7-code-review`. The second review run is not a formality — after a rebase onto a
tree as it was. Then push the branch and re-apply `S7-code-review`. When the
only difference from the reviewed material is the pipeline's own review-document
commit, the next run re-applies the green verdict without calling the model
(#499); any other change to the tree — a rebase included — gets a full review.
The pipeline is an idempotent controller (#499): only `S4-spec-review` and
`S7-code-review` start it, it reads the issue's *current* labels rather than the
event snapshot, and a label removed before the run starts is treated as a
withdrawn request. Push the material **before** applying the label — the reviewer
is pinned to the SHA the pipeline captured and must not fetch newer commits. The second review run is not a formality — after a rebase onto a
moved `dev` this is different code, and accepting it unchecked is how regressions
arrive. Cycles are counted per stage, so a code review spends its own budget.
@@ -221,7 +230,12 @@ pipeline, which reviews without anyone asking and takes ten to forty-five minute
session.** Reporting "handed over for review" stops a conveyor that could have kept
moving on its own. An agent has no clock — it exists only during its own turn — so
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
the command timeout. Watch the **label**, not the comment: the label is the state,
the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue NN
[--sha <tip>]` (#496): it watches the label, the pipeline's own comments (conflict,
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
4 on timeout — the same 90 s × 30 without a model turn per tick. It writes
nothing. Watch the **label**, not the comment: the label is the state,
the comment only explains it. Do not wait at all while `blocked` is set — the task
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
tell the owner: a failed run leaves the label where it was, forever.
@@ -291,10 +305,14 @@ does not meet.
## Specs
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
on which surface, at what moment; and what the person sees before and after, in one
sentence without implementation terms.
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two
product ones: which persona meets this, on which surface, at what moment; and what
the person sees before and after, in one sentence without implementation terms.
Proof that a verdict was passed on a given text is the pipeline's job: it writes
the `sha256` of the normalised body into the review document's anchor block, and
an edit made after a green spec review reaches the code reviewer as a finding.
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
fact is the worst kind of defect: it passes review because it looks like a decision.
@@ -341,8 +359,18 @@ npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479):
unit tests, build with typecheck, `no-new-any` and `smoke-select` in parallel,
then the bundle-tree comparison and the bundle budget. It prints the smokes the
diff selects but does not run them — those, `golden`, `pytest` and `check-docs
--screenshots=strict` remain the author's call by diff and AC.
diff selects but does not run them by default — `npm run gate:small -- --smokes`
(#496) adds the browser phase after the artefact preparation: `bundle-sync`, then
the directly matched and registered smokes two at a time; "broad" matches stay
the reviewer's call. `golden`, `pytest` and `check-docs --screenshots=strict`
remain the author's call by diff and AC.
**Start a task from its packet** (#496): `node scripts/task-packet.mjs --issue NN`
prints one derived view — status and track, what the status permits, the owner's
recent decisions, the branch against `dev` and Validate on its tip, the previous
verdict with its recorded tree, AC → evidence from the last review document and
what is still unwitnessed. It reads GitHub and git and writes nothing; the labels
remain the only source of status.
**Heavy CI gates run on the beta candidate, nightly and on demand — not on every
push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
@@ -363,6 +391,18 @@ demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
whole review round that a local run would have caught immediately.
**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues`
locally with `gh` available before pushing (without `gh` the hook cannot check the
issue status and stays silent). After `S7-code-review` do not push to the branch
until the verdict or the return arrives: a push on top of a running review cancels
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
with the diff mutants on the material itself and returns a red one to `S6` without
spending a review cycle. Mutants by diff no longer run on ordinary pushes — only
on the review candidate, the merge candidate, the beta candidate, PRs and the
nightly run — so a routine push costs ~3 minutes; 08–09.09 they cost 48 of 56
Validate job-hours and were mostly cancelled by the next push.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
@@ -385,7 +425,14 @@ simply the only one we have). The deliberate override is
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
manifest. Baselines are still accepted only via
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
index records the platform next to the Chromium build.
index records the platform next to the Chromium build. The one local
shortcut is `npm run docs:accept -- --identical` (#512): it re-captures on this
machine, compares decoded pixels with the committed frames and, only when every
frame is identical, refreshes the manifest fingerprints — frames that differ go
through the artefact as before. The displayed card version reaches the DOM
through `displayVersion()` (`src/card-version.ts`); the global
`__HP_VERSION_OVERRIDE__` behind it is for harnesses only and the product never
sets it.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -421,14 +468,19 @@ an unfinished Validate for the same branch. Gate jobs, matching the actual
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge.
went red after the #113 merge. A stable release additionally waits for Full
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
dispatched on the tag by `release.yml`, #514); betas and the development cycle
never run E2E.
**"Verified" without a named command and its result is not evidence.**
## Environments
**Local Windows checkout** is the day-to-day environment: Node 22 as in CI, Python
3.13 in a venv, `gh` authenticated. `.venv-backend` does **not** exist there — it is
**Local Windows checkout** is the day-to-day environment: Node 22 and Python 3.14
as in CI (`npm run toolchain:check` compares the machine with the pins CI actually
uses — `.nvmrc` and `.python-version` are derived from the same sources, #496),
`gh` authenticated. `.venv-backend` does **not** exist there — it is
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
Playwright Chromium.
+102 -24
View File
@@ -122,9 +122,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
### 2.3 ТЗ в работе — написание ТЗ
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
- **Артефакт:** **тело issue**, раздел `## ТЗ` (решение владельца 2026-09-10,
#517). Файл в `docs/specs/` не создаётся ни на одном треке: каталог — архив
ТЗ до этой даты, и задачи, у которых файл уже есть, доживают по старой схеме.
Доказуемость («вердикт вынесен на этом тексте») держит конвейер: в блок якорей
документа ревью пишется `sha256` нормализованного тела, и правка ТЗ после
зелёного ревью ТЗ приходит ревьюеру кода находкой, а не тишиной.
- **Выход:** полная первая редакция по §7.
### 2.4 ТЗ на ревью
@@ -155,8 +158,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
- влияние на производительность и бюджеты названо (или явно «нет»);
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
документация, golden/скриншоты, performance/security);
- release-артефакты названы: changelog RU+EN, документация, golden/скриншоты,
performance/security — либо явное «нет»;
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
- открытых продуктовых вопросов нет; риски перечислены.
@@ -428,8 +431,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
**Что упрощается:**
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
доказательством · откат. Файл в `docs/specs/` не создаётся;
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
(в теле issue, как и на полном треке с 2026-09-10);
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
- лимит ревью ТЗ — 2 цикла.
@@ -488,7 +491,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
| Роль | Делает | Не имеет права |
|---|---|---|
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
@@ -520,7 +523,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
```
issue #NN
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
↔ ветка issue/NN-slug
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
@@ -593,10 +596,9 @@ issue #NN
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
таблицу «issue ↔ ТЗ».
1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт
в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён
вместе с самой таблицей. Статус задачи — только метка `S*`.
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
@@ -620,13 +622,13 @@ node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый ко
npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/**
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
python -m pytest tests_backend -q # py3.13, если менялся бэкенд
python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд
```
**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
`any` в 49 файлах; перетипизировать это одним заходом — месяц риска ради нуля
пользовательской ценности, поэтому долг снимается при плановом извлечении
подсистем (#34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
подсистем (#425, прежний #34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
**только добавленные строки**: существующий долг на нетронутой строке законен,
правка строки со старым `any` — новая ответственность. Исключение объявляется на
той же строке, `// any-ok: <конкретная причина>`; голый маркер и причины вида
@@ -653,6 +655,13 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
декодирует оба набора в Chromium и при нуле отличающихся пикселей во всех
кадрах обновляет только отпечаток исходников в `screenshots.json`; байты
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
@@ -694,7 +703,9 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA; статусов issue не касается.
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на теге и ждёт его
зелёного (#514); статусов issue не касается.
---
@@ -811,9 +822,11 @@ Performance зелёные на точном SHA; статусов issue не к
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
2. имя ветки `issue/NN-slug` соответствует трейлерам;
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
3. у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1` в теле issue —
либо архивный `docs/specs/NN-*.md` у задачи до 2026-09-10. Офлайн тела нет,
и проверка молчит; с `--issues` — предупреждение (настоящий рубеж — ревью ТЗ).
Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог
заморожен (#517);
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
`Baseline-Reviewed: <ссылка на прогон CI>`;
@@ -897,12 +910,42 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
только по запросу: на кандидате ревью, кандидате слияния (#492), кандидате беты,
в ночном прогоне и на PR; обычный push обходится дешёвыми гейтами (~3 минуты).
За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов
job-минут Validate и в основном отменялись следующим пушем.
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
диффу, который читает ханки `git diff --unified=0`. Сторона гарда осталась
файловой: у гарда якоря нет. Неоднозначный якорь и непрочитанные ханки дают
прежний широкий ответ — незнание не доказательство. Приближение того же класса,
что и сам отбор по диффу; нижняя граница — ночной полный гейт (#513). Шард
считает свой план до установки окружения и при пустом плане не платит за
npm ci, Python и Chromium, оставаясь исполненной job: доказательство гейта
требует успешной job, а не пропущенной.
**Один хендофф — один пуш.** Перед пушем — локальный `node scripts/process-gate.mjs
--issues` при доступном `gh` (хук без `gh` статус issue не проверяет и молчит);
после `S7-code-review` в ветку не пушить, пока не пришёл вердикт или возврат: пуш
поверх идущего ревью отменяет его и стоит 10–20 минут раннера, а после фиксации
материала — ещё и слияние (#312). `S7` ставится один раз на заход, не после
каждого фикса CI: красный Validate конвейер вернёт сам.
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
секунд, не более 30 попыток. Смотреть на метку, а не на комментарий: метка и есть
состояние. При `blocked` не ждать — задача ждёт владельца.
секунд, не более 30 попыток — `node scripts/wait-verdict.mjs --issue NN` делает его
детерминированно и говорит только при смене состояния (#496). Смотреть на метку, а
не на комментарий: метка и есть состояние. При `blocked` не ждать — задача ждёт
владельца.
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
@@ -933,6 +976,29 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
ветки и пушем автор мог запушить коммит, и слепой `--force` потерял бы его молча.
Расхождение lease — падение прогона, а не предупреждение.
**В `dev` уезжает точный кандидат, и только проверенный** (#492,
`scripts/merge-candidate.mjs`). Ревью длится десятки минут, `dev` за это время
двигается; ребейз после вердикта даёт дерево, которого никто не видел, — а чистый
ребейз ничего не доказывает: соседняя правка в `dev` меняет поведение без единого
конфликта. Шаг слияния поэтому:
- сверяет вершину ветки с материалом ревью (#312) — иначе `S6-in-progress`;
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
— новая попытка; после третьей — `S6-in-progress` с комментарием;
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
каждое движение `dev` в двадцатиминутный прогон, а проверяет ровно то, что
проверил бы пуш той же дельты.
Поэтому зелёное код-ревью с неудавшимся слиянием ведёт не в `S8-merged`, а в
`S6-in-progress`: работа действительно вернулась к автору, только осталась не
правка кода, а ребейз. Вердикт при этом в силе, переделывать нечего. После ребейза
@@ -942,6 +1008,18 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
сообщать владельцу, а не продолжать опрос.
**Конвейер — идемпотентный контроллер, а событие лишь будит его** (#499). Guard
читает метки issue текущими, а не из снимка события: прогон мог простоять в очереди,
пока владелец снял метку — отозванный запрос не исполняется, и комментария об этом
нет. Конвейер запускают только `S4-spec-review` и `S7-code-review`; остальные метки
не создают ни одной job и не входят в concurrency-группу issue — прежде любая
посторонняя метка вытесняла ожидающий запуск ревью. Зелёный вердикт применяется
повторно **без вызова модели**, если последний документ этапа несёт записанный
конвейером вердикт `green` с High 0 и дерево материала не изменилось ни в одном
файле вне `docs/reviews/**` (сравнивает `git diff` по содержимому). Ребейз, правка
теста, фикстуры или ТЗ дают отличие дерева и полный разбор — правило §7.2 не
ослабляется, оно просто не касается дерева, которое уже читали.
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
считались все вердикты подряд, и первое код-ревью #89 получило `r2/4`.
@@ -1047,9 +1125,9 @@ Golden, браузерные смоки, performance и полный HA-харн
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
одна `S*`-метка, инварианты чистые.
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
код, которого уже нет.
2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/`
стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых
документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет.
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
+28
View File
@@ -29,3 +29,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Tabler Icons — summary panel outline icons
Source: https://github.com/tabler/tabler-icons
The settings, sidebar-right, eye, eye-off, plus and chevron paths were provided
in the designer reference for #505 and are distributed as inline SVG paths.
MIT License
Copyright (c) 2020-2026 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -29,3 +29,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Tabler Icons — summary panel outline icons
Source: https://github.com/tabler/tabler-icons
The settings, sidebar-right, eye, eye-off, plus and chevron paths were provided
in the designer reference for #505 and are distributed as inline SVG paths.
MIT License
Copyright (c) 2020-2026 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+34 -59
View File
@@ -30,6 +30,7 @@ from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files
from .store import (
HouseplanConfigEntry,
async_resolve_pending_pair,
async_save_config_state,
async_save_layout_state,
create_data,
@@ -83,6 +84,10 @@ 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 .radar import RadarCoordinator
data.radar_coordinator = RadarCoordinator(hass, data)
await data.radar_coordinator.async_setup()
# server-side vacuum trails: the integration records the path itself
from .trails import TrailRecorder
recorder = TrailRecorder(hass, data)
@@ -111,6 +116,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# repairs or housekeeping below.
await async_setup_frontend_registration(hass, entry, card_path)
# Resolve an interrupted whole-plan pair before any other setup-time
# writer reads or mutates either store. This is the same fence used by
# runtime writers, so startup migration cannot build on a half-commit.
optimize_revs: tuple[int, int] | None = None
recovered_import = False
async with data.write_lock:
resolved = await async_resolve_pending_pair(data)
if resolved.recovered_kind is not None:
optimize_revs = (
int(resolved.config_data.get("rev", 0)),
int(resolved.layout_data.get("rev", 0)),
)
recovered_import = resolved.recovered_kind.startswith("import")
_LOGGER.warning(
"House Plan: completed an interrupted %s",
resolved.recovered_kind.replace("_", " "),
)
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
# square and a plan is centred inside it. Nothing about the drawing changes
@@ -154,66 +177,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
# event must never see one migrated half and one old one
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
# Finish an explicit whole-plan optimization/undo interrupted between the
# config and layout store writes. The target was persisted before either
# visible half changed, so setup can always converge on the requested pair.
optimize_revs: tuple[int, int] | None = None
recovered_import = False
async with data.write_lock:
stored = await data.config_store.async_load() or {}
lay_stored = await data.store.async_load() or {}
pending = lay_stored.get("optimize_pending")
if isinstance(pending, dict) and isinstance(pending.get("config"), dict) \
and isinstance(pending.get("layout"), dict):
target_config = pending["config"]
target_layout = pending["layout"]
config_rev = int(stored.get("rev", 0))
layout_rev = int(lay_stored.get("rev", 0))
target_config_rev = int(pending.get(
"config_rev", config_rev + (stored.get("config") != target_config)
))
target_layout_rev = int(pending.get(
"layout_rev", layout_rev + (lay_stored.get("layout", {}) != target_layout)
))
if stored.get("config") != target_config or config_rev < target_config_rev:
previous_config_rev = config_rev
config_rev = max(config_rev, target_config_rev)
await async_save_config_state(
data,
target_config,
config_rev,
previous_rev=previous_config_rev,
)
if lay_stored.get("layout", {}) != target_layout or layout_rev < target_layout_rev:
layout_rev = max(layout_rev, target_layout_rev)
exact_metadata = pending.get("final_metadata")
replace_metadata = isinstance(exact_metadata, dict)
metadata = dict(exact_metadata) if replace_metadata else None
if not replace_metadata and not pending.get("clear_backup") \
and "optimize_backup" in lay_stored:
metadata = {"optimize_backup": lay_stored["optimize_backup"]}
remove_metadata = ["optimize_pending", "optimize_backup"]
if pending.get("clear_backup"):
# A recovered whole-plan undo replaces the complete layout;
# a point-wise repair snapshot from the replaced layout must
# not survive and later restore coordinates into the new pair.
remove_metadata.append("repair_backup")
await async_save_layout_state(
data,
lay_stored,
target_layout,
layout_rev,
metadata=metadata,
remove=tuple(remove_metadata),
replace_metadata=replace_metadata,
)
optimize_revs = (config_rev, layout_rev)
recovered_import = str(pending.get("kind") or "").startswith("import")
_LOGGER.warning(
"House Plan: completed an interrupted %s",
str(pending.get("kind") or "plan optimization").replace("_", " "),
)
if optimize_revs is not None:
# Setup-time geometry migration may have advanced either revision
# after pair recovery. Events always describe the final durable stores.
recovered_config = await data.config_store.async_load() or {}
recovered_layout = await data.store.async_load() or {}
optimize_revs = (
int(recovered_config.get("rev", 0)),
int(recovered_layout.get("rev", 0)),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": optimize_revs[0]})
hass.bus.async_fire("houseplan_layout_updated", {"rev": optimize_revs[1]})
if recovered_import:
@@ -292,6 +264,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
if rec:
rec.teardown()
if entry.runtime_data.radar_coordinator:
entry.runtime_data.radar_coordinator.teardown()
entry.runtime_data.radar_coordinator = None
return True
+2 -1
View File
@@ -57,8 +57,9 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.73.0-beta.5"
VERSION = "1.75.0"
DECOR_ASSETS_API_VERSION = 1
SUMMARY_PANEL_API_VERSION = 1
# #43: the support transport is deliberately not configurable. A user supplied
# URL would turn the integration into an SSRF proxy and make the privacy notice
+50 -16
View File
@@ -13,6 +13,7 @@ import re
import stat
import struct
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -34,6 +35,8 @@ MAX_RASTER_DIMENSION = 16_384
MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4
MAX_SVG_ELEMENTS = 5_000
MAX_SVG_DEPTH = 64
# Rendering follows href/url() chains; a chain this long is not art (#498).
MAX_SVG_REF_DEPTH = 64
MAX_SVG_ATTR_CHARS = 512_000
MAX_SVG_ATTR_VALUE_CHARS = 65_536
_SVG_TAGS = frozenset({
@@ -269,22 +272,7 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
if any(ref not in ids for ref in refs):
raise DecorAssetError("invalid_image", "The SVG contains an unresolved local reference")
visiting: set[str] = set()
visited: set[str] = set()
def _visit(node_id: str) -> None:
if node_id in visiting:
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
if node_id in visited:
return
visiting.add(node_id)
for ref in ref_graph.get(node_id, ()):
_visit(ref)
visiting.remove(node_id)
visited.add(node_id)
for node_id in ids:
_visit(node_id)
_walk_reference_graph(ids, ref_graph)
view_box = root.attrib.get("viewBox")
width = _svg_number(root.attrib.get("width"))
height = _svg_number(root.attrib.get("height"))
@@ -309,6 +297,52 @@ def _validate_svg(data: bytes) -> ValidatedAsset:
return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h)
def _walk_reference_graph(ids: set[str], ref_graph: dict[str, set[str]]) -> None:
"""Reject cycles and chains of local references longer than MAX_SVG_REF_DEPTH.
Iterative on purpose: a flat chain of a few thousand `href`s passes every
element/depth/attribute bound yet used to blow the interpreter's recursion
limit inside a recursive DFS, which the upload view answered with a 500
instead of a refusal (#498).
The limit is the longest chain *through* a node, memoised per node, not the
stack height of whichever traversal happened to reach it first: a chain
cut into short segments by a hostile `id` order must still be measured
end to end (spec review #498 r1).
"""
longest: dict[str, int] = {}
visiting: set[str] = set()
for start in sorted(ids):
if start in longest:
continue
stack: list[tuple[str, Iterator[str], int]] = [
(start, iter(sorted(ref_graph.get(start, ()))), 1),
]
visiting.add(start)
while stack:
node_id, children, chain = stack[-1]
ref = next(children, None)
if ref is None:
stack.pop()
visiting.discard(node_id)
longest[node_id] = chain
if chain > MAX_SVG_REF_DEPTH:
raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit")
if stack:
parent, parent_children, parent_chain = stack[-1]
stack[-1] = (parent, parent_children, max(parent_chain, chain + 1))
continue
if ref in visiting:
raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")
if ref in longest:
stack[-1] = (node_id, children, max(chain, longest[ref] + 1))
continue
if len(stack) > MAX_SVG_REF_DEPTH:
raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit")
visiting.add(ref)
stack.append((ref, iter(sorted(ref_graph.get(ref, ()))), 1))
def validate_asset(
data: bytes, filename: str, declared_mime: str | None = None,
) -> ValidatedAsset:
@@ -1,273 +1,311 @@
{
"schema": 1,
"fingerprint": "8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174",
"fingerprint": "2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DEeKwv6C.js",
"houseplan-assets/houseplan-card-8Ch93AMN.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 289933,
"initialViewGzipBytes": 288244,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-DEeKwv6C.js",
"houseplan-card.js",
"houseplan-assets/houseplan-card-8Ch93AMN.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 291890,
"initialPanelGzipBytes": 289767,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 1957,
"initialPanelOnlyGzipBytes": 2327,
"lazyFiles": [
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
"houseplan-assets/de-B2xCz0Y2.js",
"houseplan-assets/editor-IhKLQpZc.js",
"houseplan-assets/fr-DiZ0MsmF.js",
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
"houseplan-assets/guard-C2lVSPan.js",
"houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
"houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js",
"houseplan-assets/hp-zigbee-topology-overlay-CDt3rR2N.js",
"houseplan-assets/iso-scene-render-B2S8l3JB.js",
"houseplan-assets/live-interaction-runtime-iTa3Xqvq.js",
"houseplan-assets/pdf-export-Dmz9UctW.js",
"houseplan-assets/zigbee-topology-CZaP3VEx.js",
"houseplan-assets/zigbee-topology-runtime-COLrfV4a.js"
"houseplan-assets/backdrop-pick-CciPZUUr.js",
"houseplan-assets/de-uycSOqOJ.js",
"houseplan-assets/editor-daj6fW7q.js",
"houseplan-assets/fr-CMnfhkfR.js",
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
"houseplan-assets/guard-BXhRxwsH.js",
"houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
"houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js",
"houseplan-assets/hp-zigbee-topology-overlay-DnPLAFkF.js",
"houseplan-assets/iso-scene-render-XD6IkUPA.js",
"houseplan-assets/live-interaction-runtime-BCgeh23Y.js",
"houseplan-assets/pdf-export-B5BBwVOF.js",
"houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js",
"houseplan-assets/zigbee-topology-8yx_3zIp.js",
"houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js"
],
"lazyGzipBytes": 383241,
"lazyGzipBytes": 417495,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
"houseplan-assets/editor-IhKLQpZc.js",
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
"houseplan-assets/guard-C2lVSPan.js",
"houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
"houseplan-assets/zigbee-topology-CZaP3VEx.js"
"houseplan-assets/backdrop-pick-CciPZUUr.js",
"houseplan-assets/editor-daj6fW7q.js",
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
"houseplan-assets/guard-BXhRxwsH.js",
"houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
"houseplan-assets/zigbee-topology-8yx_3zIp.js"
],
"lazyEditorGzipBytes": 195232,
"lazyEditorGzipBytes": 204999,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
"houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js"
"houseplan-assets/backdrop-pick-CciPZUUr.js",
"houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js"
],
"lazyOnboardingGzipBytes": 14328,
"lazyOnboardingGzipBytes": 13919,
"lazyLocaleFiles": [
"houseplan-assets/de-B2xCz0Y2.js",
"houseplan-assets/fr-DiZ0MsmF.js"
"houseplan-assets/de-uycSOqOJ.js",
"houseplan-assets/fr-CMnfhkfR.js"
],
"lazyLocaleGzipBytes": 50246,
"lazyLocaleGzipBytes": 54822,
"lazyIsometricFiles": [
"houseplan-assets/guard-C2lVSPan.js",
"houseplan-assets/iso-scene-render-B2S8l3JB.js"
"houseplan-assets/guard-BXhRxwsH.js",
"houseplan-assets/iso-scene-render-XD6IkUPA.js"
],
"lazyIsometricGzipBytes": 13458,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js"
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js"
],
"lazyFurnitureArtGzipBytes": 10252,
"lazyFurnitureArtGzipBytes": 10251,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
"houseplan-assets/pdf-export-Dmz9UctW.js"
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
"houseplan-assets/pdf-export-B5BBwVOF.js"
],
"lazyPdfGzipBytes": 120332,
"lazyPdfGzipBytes": 120122,
"files": [
{
"path": "houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
"sha256": "dda27a953418c018d47cbd64b71af2dda10dd5d036538a2ce621ef58c860131e",
"rawBytes": 21592,
"gzipBytes": 7358,
"path": "houseplan-assets/backdrop-pick-CciPZUUr.js",
"sha256": "e1a28d74ad3b3e8b3537e130469c6011300a67764bc8d4eb8f7af063afd652c8",
"rawBytes": 19956,
"gzipBytes": 6881,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-B2xCz0Y2.js",
"sha256": "bb80b9f8d3b10ffdb1563573657fa892ab726fa9c6795cc010b9bb6e068e489c",
"rawBytes": 84250,
"gzipBytes": 25377,
"path": "houseplan-assets/de-uycSOqOJ.js",
"sha256": "1f1025e042c2beefdb858ed428e0c6873b0711f66411c2cd5c4ad3d62e9553f7",
"rawBytes": 91402,
"gzipBytes": 27694,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-IhKLQpZc.js",
"sha256": "c9c18bc9007007e444f11378e2db99ade47495e5c7c4d4ff4db47347ceb167cd",
"rawBytes": 3837,
"gzipBytes": 1590,
"path": "houseplan-assets/editor-daj6fW7q.js",
"sha256": "1103cb4da4a0bc47c04801487c285e945fe4ca7926adf6e52800bbfbf5ec27ea",
"rawBytes": 3832,
"gzipBytes": 1584,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-DiZ0MsmF.js",
"sha256": "5f7355fbb790735da981ab00499aa311f7faf5aa8f9a941ccc3a2a35da6b0cd1",
"rawBytes": 86269,
"gzipBytes": 24869,
"path": "houseplan-assets/fr-CMnfhkfR.js",
"sha256": "153f722d7983dbe9318280e36bc440ee0aaee395921627e86e6e680f775304b6",
"rawBytes": 93667,
"gzipBytes": 27128,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
"sha256": "31ea8e24963afbf4cc05b2213411c09f2c7c53946637b27f6710cb3eb470d322",
"path": "houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
"sha256": "e218436400a1057aa9593cb1c27e8c8d58b49a2ac8ad1573e5065bddfc6f5214",
"rawBytes": 33655,
"gzipBytes": 10252,
"gzipBytes": 10251,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/guard-C2lVSPan.js",
"sha256": "f57ddc549f9be6eeaa229a640002526fedc539f044900b0e8a10f3594276e531",
"path": "houseplan-assets/guard-BXhRxwsH.js",
"sha256": "c39dc15896a989fa4120e00099d4686e9340cb83d7eba6df26fe28f6db0b6909",
"rawBytes": 518,
"gzipBytes": 371,
"gzipBytes": 368,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DEeKwv6C.js",
"sha256": "15b1ab1f41ad11a6ecb6ce91523657fc930412d2b8f9c4fabe5151baf08133d0",
"rawBytes": 1042320,
"gzipBytes": 289129,
"path": "houseplan-assets/houseplan-card-8Ch93AMN.js",
"sha256": "3c2e105a32596279be0b78199d3d8ef5570e0ef8d17bf30a16174435bc8d12a3",
"rawBytes": 1023948,
"gzipBytes": 287440,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-B2xCz0Y2.js",
"houseplan-assets/editor-IhKLQpZc.js",
"houseplan-assets/fr-DiZ0MsmF.js",
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
"houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
"houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js",
"houseplan-assets/hp-zigbee-topology-overlay-CDt3rR2N.js",
"houseplan-assets/iso-scene-render-B2S8l3JB.js",
"houseplan-assets/live-interaction-runtime-iTa3Xqvq.js",
"houseplan-assets/pdf-export-Dmz9UctW.js"
"houseplan-assets/de-uycSOqOJ.js",
"houseplan-assets/editor-daj6fW7q.js",
"houseplan-assets/fr-CMnfhkfR.js",
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
"houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
"houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js",
"houseplan-assets/hp-zigbee-topology-overlay-DnPLAFkF.js",
"houseplan-assets/iso-scene-render-XD6IkUPA.js",
"houseplan-assets/live-interaction-runtime-BCgeh23Y.js",
"houseplan-assets/pdf-export-B5BBwVOF.js",
"houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-B3A0GoIU.js",
"sha256": "346e6cde25f6863916281d5d9078114d8255d40fea9a48abfe0a260a85f99fa9",
"rawBytes": 631884,
"gzipBytes": 168234,
"path": "houseplan-assets/houseplan-editor-runtime-EZaBVNKm.js",
"sha256": "5d2e22c3b55bf49f4141e7e642e37b5c100b36e6146cb7a1bf57876df4d2dea9",
"rawBytes": 668813,
"gzipBytes": 178483,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
"houseplan-assets/guard-C2lVSPan.js",
"houseplan-assets/houseplan-card-DEeKwv6C.js",
"houseplan-assets/zigbee-topology-CZaP3VEx.js"
"houseplan-assets/backdrop-pick-CciPZUUr.js",
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
"houseplan-assets/guard-BXhRxwsH.js",
"houseplan-assets/houseplan-card-8Ch93AMN.js",
"houseplan-assets/zigbee-topology-8yx_3zIp.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-COLrfV4a.js"
"houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-N3TpXIwN.js",
"sha256": "a275ce721c74bfaf1babe91f40948413d76e6cc132a00ce35f9d38f223b7efba",
"rawBytes": 28071,
"gzipBytes": 6970,
"path": "houseplan-assets/houseplan-onboarding-runtime-beV9FiSd.js",
"sha256": "4345517490d8e0e52e3c5c509502c85785902d78911c0921064e13c017bd03fd",
"rawBytes": 28287,
"gzipBytes": 7038,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-C1Nq4P9Y.js",
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/backdrop-pick-CciPZUUr.js",
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-CDt3rR2N.js",
"sha256": "d92a6d30d93ca7b6fb5ab873a469862893d8b4661c63b680fc38e790c8925b03",
"rawBytes": 11034,
"gzipBytes": 3732,
"path": "houseplan-assets/hp-zigbee-topology-overlay-DnPLAFkF.js",
"sha256": "64115cec8a70d6c12efa33bf2c54c97df34c77aba366d8ee8148a146a9a4c28f",
"rawBytes": 10756,
"gzipBytes": 3683,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js",
"houseplan-assets/zigbee-topology-CZaP3VEx.js",
"houseplan-assets/zigbee-topology-runtime-COLrfV4a.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js",
"houseplan-assets/zigbee-topology-8yx_3zIp.js",
"houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-B2S8l3JB.js",
"sha256": "9ccb3b2fc1f9f0a222f831611815d649981658e8bd41f2672ab3d8e09da35617",
"path": "houseplan-assets/iso-scene-render-XD6IkUPA.js",
"sha256": "efe46a8662594bab6dc4669e9e5820be74e89be95ecbb4892ad7a3383d867e33",
"rawBytes": 38562,
"gzipBytes": 13087,
"gzipBytes": 13090,
"isEntry": false,
"imports": [
"houseplan-assets/guard-C2lVSPan.js",
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/guard-BXhRxwsH.js",
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-iTa3Xqvq.js",
"sha256": "dc6912a8ed3b0b9dee80d3b504728d8fed8733a52ca179af760daaf849d491a8",
"rawBytes": 5196,
"gzipBytes": 2128,
"path": "houseplan-assets/live-interaction-runtime-BCgeh23Y.js",
"sha256": "b22ef9073783f3cbaa4dfd756f4cff06607c27374622dc4b69bf5b02f61e81c9",
"rawBytes": 6233,
"gzipBytes": 2499,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-Dmz9UctW.js",
"sha256": "d0ebeab685fa8d64f4a53c6cf3d0a079a02ba230fc0e68a42cd2a80b8eb658f4",
"rawBytes": 237163,
"gzipBytes": 110080,
"path": "houseplan-assets/pdf-export-B5BBwVOF.js",
"sha256": "b7a28b5e17e70a0a41e78ffae7cf8c99afaae1a7407098c21e1c8334e8162949",
"rawBytes": 236244,
"gzipBytes": 109871,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-C1fq1Jko.js",
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/furniture-plan-art.generated-KT449eLB.js",
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-CZaP3VEx.js",
"sha256": "90ad4577b9b0cbfcc2b7c654c9e2f6c5caea028f4669acf1971cf63108c36534",
"path": "houseplan-assets/summary-panel-editor-BRkGpUh9.js",
"sha256": "2987629d3b00a1c19ecbb16bddf8378080e48eb8424589980102d8f3243236b7",
"rawBytes": 14824,
"gzipBytes": 3401,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-8Ch93AMN.js",
"houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-i2JV6WHE.js",
"sha256": "488946689796491660ab1bdd4678c4b40cc0a313470564d3035c8a0e9589212f",
"rawBytes": 2610,
"gzipBytes": 1306,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-aJMNl6Eh.js",
"sha256": "4065e3510d83c9f90c0d940cabe5eb6559d6c0703ff5604000c5d5f474d48d37",
"rawBytes": 72272,
"gzipBytes": 19729,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-BRkGpUh9.js",
"houseplan-assets/summary-panel-metrics-i2JV6WHE.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-8yx_3zIp.js",
"sha256": "921ec7d673d3002fe9b7ead32f66f2ae51bb282388d560f3d6057bf7ef777251",
"rawBytes": 18961,
"gzipBytes": 7427,
"gzipBytes": 7432,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-COLrfV4a.js",
"sha256": "6aa78fc9472bb0b02d3dc508a261b91e978652a463c29790848283b12f74f18b",
"path": "houseplan-assets/zigbee-topology-runtime-DBp1bXKD.js",
"sha256": "26f39d1e04162a323f1f839cf777d6b8858bc224c14d9a009af18978cd345bc9",
"rawBytes": 3941,
"gzipBytes": 1766,
"gzipBytes": 1764,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js",
"houseplan-assets/zigbee-topology-CZaP3VEx.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js",
"houseplan-assets/zigbee-topology-8yx_3zIp.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "173cf7315cdd654750f812a4e8ac95c1777a725cd4b91b372b02829062267ee6",
"sha256": "c8ffb958eb61db62b5b244addffea9304413311c2ba655db4dd4546595d826da",
"rawBytes": 1205,
"gzipBytes": 804,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DEeKwv6C.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "db260f0fbb15ffa5f12c0ffb4dbbc702882e0d14766142230d782b86d1323d86",
"rawBytes": 5337,
"gzipBytes": 1957,
"sha256": "5c5492c7255657d35e560b6307540464868c3df544ecb48908709f823798211a",
"rawBytes": 6165,
"gzipBytes": 2327,
"isEntry": true,
"imports": [
"houseplan-card.js"
"houseplan-assets/houseplan-card-8Ch93AMN.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";import{t as e,l as o,x as t,dX as s,A as a,dY as i,dZ as l,d_ as n,dM as r,b as c}from"./houseplan-card-DEeKwv6C.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</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);
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{m as e,l as o,t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,dU as r,b as c}from"./houseplan-card-8Ch93AMN.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</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);
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__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{dS as r,dT as t,dU as e}from"./houseplan-card-8Ch93AMN.js";const s={},a=r(class extends t{constructor(){super(...arguments),this.ot=s}render(r,t){return t()}update(r,[t,s]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return e}else if(this.ot===t)return e;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,s)}});export{a as i};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";import{dK as r,dL as t,dM as s}from"./houseplan-card-DEeKwv6C.js";const e={},a=r(class extends t{constructor(){super(...arguments),this.ot=e}render(r,t){return t()}update(r,[t,e]){if(Array.isArray(t)){if(Array.isArray(this.ot)&&this.ot.length===t.length&&t.every((r,t)=>r===this.ot[t]))return s}else if(this.ot===t)return s;return this.ot=Array.isArray(t)?Array.from(t):t,this.render(t,e)}});export{a as i};
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,203 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{b as a,A as e}from"./houseplan-card-8Ch93AMN.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-aJMNl6Eh.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">
<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"
.checked=${d.localShow}
?disabled=${d.busy} aria-label=${b("summary.show_local")}
aria-describedby="summary-local-show-hint"
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
.title=${b("summary.settings")} wide flex-content
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}
<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">
<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}
</div>`}
${g}
${d.localOnly?e: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>`}
</div>
${m.storageUnavailable?a`<div class="summary-problem warning">${b("summary.storage_unavailable")}</div>`:e}
</section>
${d.localOnly?e: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>
<p>${b("summary.blocks_hint")}</p></div>
<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"
@dragover=${a=>a.preventDefault()}
@drop=${a=>m.drop(a,`block:${t}`)}>
<div class="summary-block-head">
<span class="summary-drag" draggable=${String(!d.busy)} title=${b("summary.drag")}
@dragstart=${a=>m.dragStart(a,`block:${t}`)}>${l("grip")}</span>
<div class="summary-order">
<button class="summary-icon-button" type="button" title=${b("summary.up")}
aria-label=${b("summary.up")} ?disabled=${0===t||d.busy}
@click=${()=>m.mutate(a=>{a.blocks=r(a.blocks,t,t-1)})}>${l("up")}</button>
<button class="summary-icon-button" type="button" title=${b("summary.down")}
aria-label=${b("summary.down")}
?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}
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})} />
<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}
<div class="summary-scope">
<select .value=${s.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>
</select>
${"space"===s.scope.type?a`<select .value=${s.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}
</div>
${n?a`<div class="summary-problem summary-scope-problem ${n.kind}">${h(n)}</div>`:e}
</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"
@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")}
@dragstart=${a=>m.dragStart(a,`value:${t}:${c}`)}>${l("grip")}</span>
<div class="summary-order">
<button class="summary-icon-button" type="button" title=${b("summary.up")}
aria-label=${b("summary.up")} ?disabled=${0===c||d.busy}
@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}
@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">
<input type="text" .value=${i.label} ?disabled=${d.busy}
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}
</div>
<div class="summary-source-field">
<button class="summary-source" type="button"
?disabled=${d.busy}
data-summary-source-owner=${S}
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)}}>
<span class="summary-source-caption"><strong>${g}</strong>
${_?a`<small>${_}</small>`:e}</span>
${l("chevron")}
</button>
${k&&f?a`<div class="summary-source-picker"
@click=${a=>a.stopPropagation()}
@keydown=${a=>{"Escape"===a.key&&(a.preventDefault(),a.stopPropagation(),m.closeSource(!0))}}>
<label>${b("summary.search_entities")}</label>
<input type="search" data-summary-picker-search
?disabled=${d.busy}
aria-label=${b("summary.search_entities")} .value=${d.entityFilter}
@input=${a=>m.setDialog({...d,entityFilter:a.target.value})} />
<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`
<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}`)}
</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)}>
${g}
</button>`:e}
<div class="summary-source-group">${b("summary.entities_group")}</div>
${f.entries.map(e=>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>
</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}
</div>
</div>`:e}
${$?a`<div class="summary-problem ${$.kind}">${h($)}</div>`:e}
</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"):""}
@click=${()=>m.mutate(a=>{a.blocks[t].values.push(i())})}>
${l("plus")}${b("summary.add_value")}
</button>
</div>
<footer class="summary-block-footer">
<button class="summary-delete-block" type="button" ?disabled=${d.busy}
@click=${()=>m.deleteBlock(t)}>${b("summary.delete_block")}</button>
</footer>
</div>
</article>`})}
</div>
<button class="summary-add summary-add-block" type="button"
?disabled=${d.draft.blocks.length>=10||d.busy}
title=${d.draft.blocks.length>=10?b("summary.limit_blocks"):""}
@click=${()=>m.mutate(a=>{a.blocks.push(u(b))})}>
${l("plus")}${b("summary.add_block")}
</button>
</section>
${d.error?a`<div class="summary-problem error" role="alert">${d.error}</div>`:e}
${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}
`}
</div>
<div class="row summary-editor-footer" slot="footer">
<button class="btn ghost" data-hp="dialog-cancel"
?disabled=${d.busy} @click=${k}>${b("btn.cancel")}</button>
<button class="btn on" data-hp="dialog-confirm"
?disabled=${d.localOnly?!$||d.busy:!p}
@click=${()=>m.save()}>${b("btn.save")}</button>
</div>
</hp-dialog>`};export{m as renderSummaryPanelEditor};
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{e9 as e,L as t,a2 as n,J as i,an as o,N as r,bo as s,bn as a,a1 as c,a0 as l,ax as u,ea as d,eb as f,bq as m,ec as g}from"./houseplan-card-8Ch93AMN.js";function v(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,u="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;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 y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(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:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,l,u=_){try{let d=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=u(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,o,r,g.roomGeom,g.multiWallNodes)||a;v=y(v,s(c,m.physicalBodies)),yield}const _=m.cellCm/o;d+=a(v)*_*_/1e4}return d}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function b(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:m(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 S(e,t){const n=e?.states?.[t];if(!n)return null;const i=d(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";import{n as t,b as n}from"./zigbee-topology-CZaP3VEx.js";import{F as e}from"./houseplan-card-DEeKwv6C.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 h(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 m(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 g(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,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(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=h(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&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await m(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 m(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,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";import{n as t,b as n}from"./zigbee-topology-8yx_3zIp.js";import{B as e}from"./houseplan-card-8Ch93AMN.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 h(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 m(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,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(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=h(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&&m?.(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,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="8dabbc2e02695c9064d7fdd4b9cdf2a251007d667078458249f55d20402a8174";try{await import("./houseplan-assets/houseplan-card-DEeKwv6C.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__="2f536ea93b74815c8832bf3e5dd53435bbc5e35ca7478733701357f0038c3fce";try{await import("./houseplan-assets/houseplan-card-8Ch93AMN.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
+6 -2
View File
@@ -445,9 +445,13 @@ class HouseplanUploadView(HomeAssistantView):
tmp_path = temps[0]
try:
# The staged file already sits under files_root: hand it to
# the quota as `incoming` only, not as stored usage too (#498).
await hass.async_add_executor_job(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT,
partial(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,
),
)
except QuotaError as err:
_LOGGER.warning("House Plan upload refused: %s", err.detail)
+55 -1
View File
@@ -35,6 +35,7 @@ from .const import (
PLANS_URL,
VERSION,
)
from .radar_validation import validate_marker_radars
from .store import HouseplanData
from .validation import (
CONFIG_SCHEMA,
@@ -96,7 +97,7 @@ _SPACE_DISPLAY_FIELDS = (
)
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")
_ROOM_DISPLAY_FIELDS = (
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
"fill_mode", "custom_fill", "glow", "temp_min", "temp_max", "name_scale", "label_scale",
)
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
_DECOR_KIND_FIELDS = {
@@ -1223,6 +1224,31 @@ def _repair_target_space_refs(
preserve_related(
marker_id, "marker.value_source", old_target, resolve_marker,
)
radar = marker.get("radar")
if isinstance(radar, dict):
if may_rebind_room:
mapped = replace(
marker_id, "marker.radar.room_id", radar.get("room_id"), resolve_room,
)
if mapped is not None:
radar["room_id"] = mapped
allowed = radar.get("allowed_room_ids")
if isinstance(allowed, list):
remapped_allowed: list[Any] = []
for room_id in allowed:
mapped = replace(
marker_id, "marker.radar.allowed_room_ids", room_id, resolve_room,
)
remapped_allowed.append(mapped if mapped is not None else room_id)
radar["allowed_room_ids"] = remapped_allowed
else:
preserve_related(
marker_id, "marker.radar.room_id", radar.get("room_id"), resolve_room,
)
for room_id in radar.get("allowed_room_ids") or []:
preserve_related(
marker_id, "marker.radar.allowed_room_ids", room_id, resolve_room,
)
# Destination wins collisions. Rekey only proven dead plan-owned keys;
# opaque HA owners remain literal.
@@ -1410,6 +1436,22 @@ def build_space_merge(
_report_remap(
reference_report, "incoming", "marker.room_id", new_id, old_room_id,
)
radar = marker.get("radar")
if isinstance(radar, dict):
radar_room = radar.get("room_id")
if radar_room is not None:
old_radar_room = str(radar_room)
radar["room_id"] = old_room_ids.get(old_radar_room, radar_room)
if radar["room_id"] != old_radar_room:
_report_remap(
reference_report, "incoming", "marker.radar.room_id",
new_id, old_radar_room,
)
allowed_rooms = radar.get("allowed_room_ids")
if isinstance(allowed_rooms, list):
radar["allowed_room_ids"] = [
old_room_ids.get(str(room_id), room_id) for room_id in allowed_rooms
]
vacuum = marker.get("vacuum")
if isinstance(vacuum, dict) and isinstance(vacuum.get("segment_map"), dict):
remapped_segments = {}
@@ -1437,6 +1479,11 @@ def build_space_merge(
if isinstance(value_source, dict) \
and value_source.get("kind") == "derived_marker_state":
dropped_marker_links += 1
if isinstance(marker.get("radar"), dict):
_report_reference(
reference_report, "droppedIncomingLinks", "marker.radar",
new_id, old_id,
)
marker["binding"] = "virtual"
marker["display"] = "static_icon"
for field in (
@@ -1444,6 +1491,7 @@ def build_space_merge(
"vacuum", "is_light", "use_climate_temp", "glow_color",
"glow_radius_cm", "light_entity", "toggle_entity", "hidden", "removed",
"value_badge", "value_source",
"radar",
):
marker.pop(field, None)
output_markers.append(marker)
@@ -1574,6 +1622,7 @@ def build_space_merge(
try:
validate_marker_controls(merged_config, current_config)
validate_marker_light_entities(merged_config, current_config)
validate_marker_radars(merged_config, current_config)
validate_marker_value_badges(merged_config, current_config)
validate_marker_vacuum_routes(merged_config, current_config)
validate_opening_passages(merged_config, current_config)
@@ -1583,6 +1632,8 @@ def build_space_merge(
PartitionOpeningJambMarginError,
) as err:
raise ImportFailure(err.code, str(err)) from err
except vol.Invalid as err:
raise ImportFailure("invalid_config", str(err)) from err
try:
merged_layout = LAYOUT_SCHEMA(merged_layout)
except vol.Invalid as err:
@@ -1868,10 +1919,13 @@ def create_preview(
try:
validate_marker_controls(incoming_config, validate_all=True)
validate_marker_light_entities(incoming_config, validate_all=True)
validate_marker_radars(incoming_config, validate_all=True)
validate_marker_value_badges(incoming_config, validate_all=True)
validate_marker_vacuum_routes(incoming_config, validate_all=True)
except MarkerControlError as err:
raise ImportFailure(err.code, str(err)) from err
except vol.Invalid as err:
raise ImportFailure("invalid_config", str(err)) from err
content, confirmation = _content_state(document, same_source, config_root)
target_config, target_layout, details = _materialize_import_candidate(
document, current_config, current_layout,
+1 -1
View File
@@ -20,5 +20,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.73.0-beta.5"
"version": "1.75.0"
}
+15 -5
View File
@@ -198,12 +198,20 @@ class QuotaError(Exception):
self.detail = detail
def dir_usage(path: Path) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read."""
def dir_usage(path: Path, *, exclude: Path | None = None) -> tuple[int, int]:
"""(bytes, files) below `path`, ignoring what we cannot read.
`exclude` is the caller's own staged upload: it already lives under `path`
and its size arrives separately as `incoming`, so counting it here would
charge the same bytes and the same file twice (#498). Any *other* staged
file stays in the count — it is about to become an attachment.
"""
total = count = 0
if not path.is_dir():
return 0, 0
for item in path.rglob("*"):
if exclude is not None and item == exclude:
continue
try:
if item.is_file():
total += item.stat().st_size
@@ -213,8 +221,10 @@ def dir_usage(path: Path) -> tuple[int, int]:
return total, count
def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None:
"""Raise QuotaError unless `incoming` more bytes fit.
def check_quota(
path: Path, incoming: int, max_bytes: int, max_files: int, *, exclude: Path | None = None,
) -> None:
"""Raise QuotaError unless `incoming` more bytes fit (`exclude`: see dir_usage).
Deliberately not an age rule. Files are never removed for getting old — that
cost real plans twice — so the limit sits where a decision is being made
@@ -222,7 +232,7 @@ def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> No
"""
import shutil
used, count = dir_usage(path)
used, count = dir_usage(path, exclude=exclude)
if count + 1 > max_files:
raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}")
if used + incoming > max_bytes:
+718
View File
@@ -0,0 +1,718 @@
"""Authoritative presence-radar coordinator (#485).
The coordinator is deliberately the only component which interprets radar
entities. Browsers, recordings and room outputs consume the same normalized
frame so freshness and coordinate semantics cannot drift between features.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import secrets
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import voluptuous as vol
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_state_report_event,
)
from .radar_geometry import (
clipped_arc_segments,
length_cm,
point_in_polygon,
polar_to_cartesian,
polygon_is_convex,
project_local,
)
from .radar_validation import (
radar_registry_evidence,
radar_source_entity_ids,
validate_radar_draft,
)
from .store import HouseplanData
MAX_FRAME_HZ = 4
PAIR_MAX_AGE_S = 3.0
PAIR_MAX_SKEW_S = 1.5
_EXPLICIT_ABSENT = object()
def _fingerprint(value: Any) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
return hashlib.sha256(payload.encode()).hexdigest()[:24]
def _reported_timestamp(state: Any) -> float:
value = getattr(state, "last_reported", None) or getattr(state, "last_updated", None)
if value is None:
return 0.0
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.timestamp()
def _numeric_state(state: Any) -> float | None:
if state is None or str(state.state).strip().lower() in {"", "unknown", "unavailable"}:
return None
try:
value = float(state.state)
except (TypeError, ValueError):
return None
return value if math.isfinite(value) else None
def _binary_state(state: Any) -> bool | None:
if state is None:
return None
value = str(state.state).lower()
if value == "on":
return True
if value == "off":
return False
return None
class RadarCoordinator:
"""One bounded state-report listener graph for the whole integration."""
def __init__(self, hass: HomeAssistant, runtime: HouseplanData) -> None:
self.hass = hass
self.runtime = runtime
self.server_session_id = secrets.token_hex(12)
self.closed = False
self.config_rev = 0
self.config: dict[str, Any] = {}
self.radars: dict[str, dict[str, Any]] = {}
self._seq: dict[str, int] = {}
self._frames: dict[str, dict[str, Any]] = {}
self._last_signature: dict[str, str] = {}
self._listeners: set[Callable[[str, dict[str, Any]], None]] = set()
self._internal_listeners: set[Callable[[str, dict[str, Any]], None]] = set()
self._external_cleanups: set[Callable[[], None]] = set()
self._unsub_sources: list[Callable[[], None]] = []
self._unsub_config: Callable[[], None] | None = None
self._unsub_tick: Callable[[], None] | None = None
self._pending: asyncio.TimerHandle | None = None
self._last_publish_monotonic = 0.0
self._immediate_source_ids: set[str] = set()
self._last_acl_epoch = -1
self._lock = asyncio.Lock()
async def async_setup(self) -> None:
await self.async_refresh()
if self.closed:
return
@callback
def config_updated(_event: Event) -> None:
self.hass.async_create_task(self.async_refresh())
self._unsub_config = self.hass.bus.async_listen(
"houseplan_config_updated", config_updated
)
async def async_refresh(self) -> None:
async with self._lock:
if self.closed:
return
stored = await self.runtime.config_store.async_load() or {}
if self.closed:
return
config = stored.get("config") or {}
registry = radar_registry_evidence(self.hass)
self.config = config
self.config_rev = int(stored.get("rev", 0))
self.radars = {}
for marker in config.get("markers") or []:
if not isinstance(marker, dict) or not isinstance(marker.get("radar"), dict) \
or marker["radar"].get("version") != 1 \
or marker["radar"].get("enabled") is not True \
or marker.get("removed") is True:
continue
marker_id = str(marker.get("id"))
try:
validate_radar_draft(config, marker_id, marker["radar"], registry)
except vol.Invalid:
# Change-aware config compatibility may retain an old or
# future-invalid block. It stays lossless but never runs.
continue
self.radars[marker_id] = marker
self._resubscribe_sources()
self._publish_all(force=True)
def teardown(self) -> None:
self.closed = True
if self._pending:
self._pending.cancel()
self._pending = None
if self._unsub_tick:
self._unsub_tick()
self._unsub_tick = None
if self._unsub_config:
self._unsub_config()
self._unsub_config = None
for unsub in self._unsub_sources:
unsub()
self._unsub_sources.clear()
# Setup subscriptions own draft-specific HA listeners which are not
# part of the saved source graph. Closing the config entry must tear
# those down too, even if the browser WebSocket stays connected.
for cleanup in tuple(self._external_cleanups):
cleanup()
self._external_cleanups.clear()
self._listeners.clear()
self._internal_listeners.clear()
self._frames.clear()
@callback
def add_external_cleanup(self, cleanup: Callable[[], None]) -> Callable[[], None]:
"""Own a setup listener for the lifetime of this coordinator."""
self._external_cleanups.add(cleanup)
@callback
def unregister() -> None:
self._external_cleanups.discard(cleanup)
return unregister
def _resubscribe_sources(self) -> None:
for unsub in self._unsub_sources:
unsub()
self._unsub_sources.clear()
entity_ids = sorted({
entity_id
for marker in self.radars.values()
for entity_id in radar_source_entity_ids(marker.get("radar"))
})
if not entity_ids:
self._immediate_source_ids.clear()
return
immediate: set[str] = set()
for marker in self.radars.values():
sources = marker.get("radar", {}).get("sources") or {}
immediate.update(
value for key in ("occupancy_entity", "count_entity", "availability_entity")
if isinstance((value := sources.get(key)), str)
)
for group in ("slots", "ranges"):
immediate.update(
item["presence_entity"]
for item in sources.get(group) or []
if isinstance(item, dict) and isinstance(item.get("presence_entity"), str)
)
immediate.update(
item["entity_id"] for item in sources.get("zones") or []
if isinstance(item, dict) and isinstance(item.get("entity_id"), str)
)
self._immediate_source_ids = immediate
@callback
def source_reported(event: Event) -> None:
entity_id = str(event.data.get("entity_id") or "")
state = event.data.get("new_state")
immediate_transition = entity_id in self._immediate_source_ids \
or state is None or str(getattr(state, "state", "")).lower() \
in {"", "unknown", "unavailable"}
self._schedule_publish(immediate=immediate_transition)
# state_reported catches repeated coordinate values. state_changed is
# retained for HA versions/integrations which do not emit a report event
# for every change; one 250 ms coalescer deduplicates the overlap and
# enforces the public four-frames-per-second ceiling.
self._unsub_sources.append(async_track_state_report_event(
self.hass, entity_ids, source_reported
))
self._unsub_sources.append(async_track_state_change_event(
self.hass, entity_ids, source_reported
))
@callback
def _schedule_publish(self, *, immediate: bool = False) -> None:
if self.closed:
return
if self._pending is not None:
if not immediate:
return
# Availability and occupancy edges must not wait behind an already
# queued coordinate batch. Replacing that handle remains bounded:
# the two HA event streams still collapse into this one callback.
self._pending.cancel()
self._pending = None
elapsed = self.hass.loop.time() - self._last_publish_monotonic
delay = 0 if immediate else max(0.1, 1 / MAX_FRAME_HZ - elapsed)
self._pending = self.hass.loop.call_later(delay, self._flush_pending)
@callback
def _flush_pending(self) -> None:
self._pending = None
self._last_publish_monotonic = self.hass.loop.time()
self._publish_all()
def _ensure_tick(self) -> None:
if self._unsub_tick or not (self._listeners or self._internal_listeners):
return
@callback
def tick(_now: datetime) -> None:
self._unsub_tick = None
self._publish_all()
self._ensure_tick()
self._unsub_tick = async_call_later(self.hass, 1.0, tick)
def add_listener(
self,
listener: Callable[[str, dict[str, Any]], None],
*,
publish_initial: bool = True,
) -> Callable[[], None]:
self._listeners.add(listener)
self._ensure_tick()
if publish_initial:
for marker_id, frame in self._frames.items():
listener(marker_id, self.public_frame(frame))
@callback
def remove() -> None:
self._listeners.discard(listener)
self._stop_tick_if_idle()
return remove
def add_internal_listener(self, listener: Callable[[str, dict[str, Any]], None]) -> Callable[[], None]:
self._internal_listeners.add(listener)
self._ensure_tick()
@callback
def remove() -> None:
self._internal_listeners.discard(listener)
self._stop_tick_if_idle()
return remove
def _stop_tick_if_idle(self) -> None:
if not (self._listeners or self._internal_listeners) and self._unsub_tick:
self._unsub_tick()
self._unsub_tick = None
def space_for_marker(self, marker_id: str) -> str | None:
marker = self.radars.get(marker_id)
return str(marker.get("space")) if marker and marker.get("space") else None
def has_space(self, space_id: str) -> bool:
return any(str(space.get("id")) == space_id for space in self.config.get("spaces") or [])
def marker_config(self, marker_id: str) -> dict[str, Any] | None:
return next(
(marker for marker in self.config.get("markers") or []
if isinstance(marker, dict) and str(marker.get("id")) == marker_id),
None,
)
def source_ids(self, marker_id: str) -> set[str]:
marker = self.radars.get(marker_id)
return radar_source_entity_ids(marker.get("radar")) if marker else set()
def frame(self, marker_id: str) -> dict[str, Any] | None:
return self._frames.get(marker_id)
def frames_for_space(self, space_id: str) -> list[dict[str, Any]]:
return [frame for marker_id, frame in self._frames.items()
if self.space_for_marker(marker_id) == space_id][:32]
def _publish_all(self, force: bool = False) -> None:
now = datetime.now(UTC).timestamp()
acl_epoch = int(now // 60)
force = force or acl_epoch != self._last_acl_epoch
self._last_acl_epoch = acl_epoch
live_ids = set(self.radars)
for stale_id in set(self._frames) - live_ids:
self._frames.pop(stale_id, None)
self._last_signature.pop(stale_id, None)
clear = self._base_frame(stale_id, now, "disabled")
self._deliver(stale_id, clear)
for marker_id, marker in self.radars.items():
frame = self._build_frame(marker_id, marker, now)
self._annotate_smoothing(marker_id, marker, frame)
signature = _fingerprint({key: value for key, value in frame.items()
if key not in {"seq", "reported_at", "expires_at"}})
if not force and signature == self._last_signature.get(marker_id):
continue
self._last_signature[marker_id] = signature
self._frames[marker_id] = frame
self._deliver(marker_id, frame)
def _annotate_smoothing(
self, marker_id: str, marker: dict[str, Any], frame: dict[str, Any],
) -> None:
"""Permit a cosmetic transition only where it cannot invent travel."""
previous = self._frames.get(marker_id)
if not previous or previous.get("health") not in {"ok", "stale"} \
or frame.get("health") not in {"ok", "stale"} \
or previous.get("source_generation") != frame.get("source_generation") \
or previous.get("calibration_revision") != frame.get("calibration_revision") \
or previous.get("_filter_revision") != frame.get("_filter_revision"):
return
room_exists, room_poly = self._room_polygon(marker, marker["radar"])
if not room_exists or room_poly is not None and not polygon_is_convex(room_poly):
return
cell_cm = self._space_cell_cm(marker)
if cell_cm is None:
return
old_targets = {
target.get("slot"): target for target in previous.get("targets") or []
if target.get("included") is True
}
for target in frame.get("targets") or []:
old = old_targets.get(target.get("slot"))
if not old or target.get("included") is not True \
or float(target.get("reported_at", 0)) <= float(old.get("reported_at", 0)) \
or float(target.get("reported_at", 0)) > float(old.get("expires_at", 0)):
continue
distance_cm = math.hypot(
float(target["x"]) - float(old["x"]),
float(target["y"]) - float(old["y"]),
) * 240 * cell_cm
if distance_cm <= 100:
target["smooth"] = True
def _deliver(self, marker_id: str, frame: dict[str, Any]) -> None:
for listener in tuple(self._internal_listeners):
listener(marker_id, frame)
public = self.public_frame(frame)
for listener in tuple(self._listeners):
listener(marker_id, public)
def _base_frame(self, marker_id: str, now: float, health: str) -> dict[str, Any]:
self._seq[marker_id] = self._seq.get(marker_id, 0) + 1
return {
"server_session_id": self.server_session_id,
"marker_id": marker_id,
"source_generation": "",
"calibration_revision": "",
"seq": self._seq[marker_id],
"reported_at": now,
"expires_at": now,
"health": health,
"reported_presence": None,
"complete": False,
"targets": [], "ranges": [], "zones": [],
}
def _build_frame(self, marker_id: str, marker: dict[str, Any], now: float) -> dict[str, Any]:
radar = marker["radar"]
frame = self._base_frame(marker_id, now, "ok")
source_material = {
"installation_id": radar.get("mount", {}).get("installation_id"),
"mount": {key: radar.get("mount", {}).get(key) for key in ("x", "y")},
"profile": radar.get("profile"), "sources": radar.get("sources"),
"space": marker.get("space"),
}
calibration_material = {
"heading_deg": radar.get("mount", {}).get("heading_deg"),
"calibration": radar.get("calibration"),
}
frame["source_generation"] = _fingerprint(source_material)
frame["calibration_revision"] = _fingerprint(calibration_material)
profile = radar.get("profile")
room_exists, room_poly = self._room_polygon(marker, radar)
frame["_filter_revision"] = _fingerprint({
"room_id": radar.get("room_id"), "polygon": room_poly,
})
if not room_exists:
frame["health"] = "needs_setup"
return frame
current_cell_cm = self._space_cell_cm(marker)
projects_geometry = profile in {
"esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1",
}
if projects_geometry and (current_cell_cm is None or not math.isclose(
float(radar.get("calibration", {}).get("cell_cm", -1)), current_cell_cm,
rel_tol=0, abs_tol=1e-9,
)):
frame["health"] = "needs_setup"
return frame
sources = radar.get("sources") or {}
availability = _binary_state(self.hass.states.get(sources.get("availability_entity"))) \
if sources.get("availability_entity") else True
occupancy = _binary_state(self.hass.states.get(sources.get("occupancy_entity"))) \
if sources.get("occupancy_entity") else None
frame["reported_presence"] = occupancy
if availability is not True:
frame["health"] = "unavailable" if availability is None else "off"
return frame
if occupancy is False:
frame["complete"] = True
frame["expires_at"] = now + 60
frame["health"] = "off"
return frame
any_stale = False
explicit_slots = 0
for slot in sources.get("slots") or []:
try:
local = self._slot_local(profile, slot, now)
except (TypeError, ValueError):
local = None
if local is _EXPLICIT_ABSENT:
explicit_slots += 1
continue
if local is None:
gate = _binary_state(self.hass.states.get(slot.get("presence_entity"))) \
if slot.get("presence_entity") else None
if gate is False:
explicit_slots += 1
else:
any_stale = True
continue
x_cm, y_cm, t1, t2, quality = local
gate = _binary_state(self.hass.states.get(slot.get("presence_entity"))) \
if slot.get("presence_entity") else True
if gate is not True:
if gate is False:
explicit_slots += 1
else:
any_stale = True
continue
try:
point = project_local(radar["mount"], radar["calibration"], x_cm, y_cm)
except (TypeError, ValueError):
any_stale = True
continue
tolerance = 0.1 / (240 * current_cell_cm) if current_cell_cm else 1e-6
included = room_poly is None or point_in_polygon(point, room_poly, tolerance)
frame["targets"].append({
"slot": str(slot.get("id")), "x": point[0], "y": point[1],
"reported_at": max(t1, t2), "expires_at": min(t1, t2) + PAIR_MAX_AGE_S,
"pair_quality": quality, "included": included,
"reason": None if included else "outside_room",
# Private values are removed by public_frame and consumed only
# by the bounded local recording/analysis services.
"_local_x_cm": x_cm, "_local_y_cm": y_cm,
"_component_times": [t1, t2],
})
explicit_slots += 1
for source in sources.get("ranges") or []:
state = self.hass.states.get(source.get("entity_id"))
value = _numeric_state(state)
reported = _reported_timestamp(state)
gate = _binary_state(self.hass.states.get(source.get("presence_entity"))) \
if source.get("presence_entity") else True
if value is None or now - reported > PAIR_MAX_AGE_S or gate is not True:
any_stale = any_stale or gate is not False
continue
try:
radius_cm = length_cm(value, source.get("unit"))
except (TypeError, ValueError):
any_stale = True
continue
radius = radius_cm / (240 * radar["calibration"]["cell_cm"])
segments = clipped_arc_segments(
(radar["mount"]["x"], radar["mount"]["y"]), radius,
radar["mount"].get("heading_deg", 0), radar["mount"].get("fov_deg"),
room_poly,
)
frame["ranges"].append({
"id": str(source.get("id")), "x": radar["mount"]["x"],
"y": radar["mount"]["y"], "radius": radius,
"heading_deg": radar["mount"].get("heading_deg", 0),
"fov_deg": radar["mount"].get("fov_deg"),
"segments": segments,
"reported_at": reported, "expires_at": reported + PAIR_MAX_AGE_S,
})
for source in sources.get("zones") or []:
state = self.hass.states.get(source.get("entity_id"))
value = _binary_state(state) if source.get("kind") == "occupancy" else _numeric_state(state)
frame["zones"].append({"id": str(source.get("id")), "state": value})
if profile == "presence_v1":
frame["complete"] = occupancy is not None
frame["health"] = "ok" if occupancy is not None else "unknown"
frame["expires_at"] = None
return frame
if profile == "zones_v1":
frame["complete"] = all(zone["state"] is not None for zone in frame["zones"])
frame["health"] = "ok" if frame["complete"] else "unknown"
frame["expires_at"] = None
return frame
if profile == "range_v1":
frame["complete"] = bool(frame["ranges"]) and not any_stale
else:
frame["complete"] = explicit_slots == len(sources.get("slots") or []) and not any_stale
if occupancy is True and not frame["targets"] and not frame["ranges"]:
frame["health"] = "position_unavailable"
elif any_stale:
has_current_evidence = bool(frame["ranges"]) if profile == "range_v1" \
else explicit_slots > 0
frame["health"] = "partial" if has_current_evidence else "stale"
expires = [item["expires_at"] for item in [*frame["targets"], *frame["ranges"]]]
frame["expires_at"] = min(expires) if expires else now
count = _numeric_state(self.hass.states.get(sources.get("count_entity"))) \
if sources.get("count_entity") else None
count_state = self.hass.states.get(sources.get("count_entity")) \
if sources.get("count_entity") else None
count_reported = _reported_timestamp(count_state)
count_fresh = count is not None and now - count_reported <= PAIR_MAX_AGE_S
if count_fresh and count == 0:
if frame["targets"]:
frame["health"] = "inconsistent"
frame["complete"] = False
elif not frame["ranges"]:
frame["health"] = "off"
frame["complete"] = True
frame["expires_at"] = count_reported + PAIR_MAX_AGE_S
return frame
def _slot_local(self, profile: str, slot: dict[str, Any], now: float):
if profile == "polar_v1":
first = self.hass.states.get(slot.get("distance_entity"))
second = self.hass.states.get(slot.get("angle_entity"))
a, b = _numeric_state(first), _numeric_state(second)
t1, t2 = _reported_timestamp(first), _reported_timestamp(second)
if a is None or b is None or now - min(t1, t2) > PAIR_MAX_AGE_S or abs(t1 - t2) > PAIR_MAX_SKEW_S:
return None
x, y = polar_to_cartesian(a, b, unit=slot["unit"],
angle_unit=slot["angle_unit"],
zero=slot["angle_zero"],
clockwise=slot["angle_clockwise"])
return x, y, t1, t2, "bounded_latest"
first = self.hass.states.get(slot.get("x_entity"))
second = self.hass.states.get(slot.get("y_entity"))
a, b = _numeric_state(first), _numeric_state(second)
t1, t2 = _reported_timestamp(first), _reported_timestamp(second)
if a is None or b is None or now - min(t1, t2) > PAIR_MAX_AGE_S or abs(t1 - t2) > PAIR_MAX_SKEW_S:
return None
unit = "mm" if profile == "esphome_ld2450_v1" else slot["unit"]
# The reference ESPHome LD2450 connection reports both axes as zero
# for an unused slot. A single zero remains a legal coordinate; only
# the verified pair is negative evidence. Generic profiles keep (0,0)
# legal because they have no adapter-specific absence contract.
if profile == "esphome_ld2450_v1" and a == 0 and b == 0:
return _EXPLICIT_ABSENT
x, y = length_cm(a, unit), length_cm(b, unit)
if slot.get("swap_xy"):
x, y = y, x
return x * slot.get("x_sign", 1), y * slot.get("y_sign", 1), t1, t2, "bounded_latest"
def _room_polygon(
self, marker: dict[str, Any], radar: dict[str, Any],
) -> tuple[bool, list[Any] | None]:
space_id = str(marker.get("space") or "")
room_id = str(radar.get("room_id") or "")
for space in self.config.get("spaces") or []:
if str(space.get("id")) != space_id:
continue
for room in space.get("rooms") or []:
if str(room.get("id")) == room_id:
poly = room.get("poly")
return True, poly if isinstance(poly, list) and len(poly) >= 3 else None
return False, None
def _space_cell_cm(self, marker: dict[str, Any]) -> float | None:
space_id = str(marker.get("space") or "")
for space in self.config.get("spaces") or []:
if str(space.get("id")) != space_id:
continue
value = space.get("cell_cm", 5)
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) and result > 0 else None
return None
@staticmethod
def public_frame(frame: dict[str, Any]) -> dict[str, Any]:
"""Projection safe for View: no raw coordinates, ids or private times."""
public: dict[str, Any] = {}
for key, value in frame.items():
if key.startswith("_"):
continue
if key not in {"targets", "ranges", "zones"}:
public[key] = value
continue
items = value
if key == "targets":
items = [entry for entry in value if entry.get("included")]
public[key] = [
{item_key: item_value for item_key, item_value in item.items()
if not item_key.startswith("_")}
for item in items
]
return public
def inspect(self, marker_id: str) -> dict[str, Any]:
marker = self.radars.get(marker_id)
if marker is None:
return {"marker_id": marker_id, "health": "not_configured", "config_rev": self.config_rev}
frame = self._frames.get(marker_id) or self._build_frame(
marker_id, marker, datetime.now(UTC).timestamp()
)
radar = marker["radar"]
return {
"marker_id": marker_id, "config_rev": self.config_rev,
"profile": radar.get("profile"),
"capabilities": self._capabilities(radar),
"frame": self.public_frame(frame),
"sources": [
{"entity_id": entity_id,
"state": str(self.hass.states.get(entity_id).state)
if self.hass.states.get(entity_id) else None,
"reported_at": _reported_timestamp(self.hass.states.get(entity_id))}
for entity_id in sorted(radar_source_entity_ids(radar))
],
}
def inspect_draft(self, marker: dict[str, Any], radar: dict[str, Any]) -> dict[str, Any]:
"""Return one permission-gated setup snapshot for an unsaved draft."""
marker_id = str(marker.get("id"))
candidate = {**marker, "radar": radar}
frame = self._build_frame(marker_id, candidate, datetime.now(UTC).timestamp())
result = {
"marker_id": marker_id,
"config_rev": self.config_rev,
"profile": radar.get("profile"),
"capabilities": self._capabilities(radar),
"frame": self.public_frame(frame),
"local_targets": [
{
"slot": target.get("slot"),
"x_cm": target.get("_local_x_cm"),
"y_cm": target.get("_local_y_cm"),
"reported_at": target.get("reported_at"),
"pair_quality": target.get("pair_quality"),
}
for target in frame.get("targets") or []
],
"sources": [
{"entity_id": entity_id,
"state": str(self.hass.states.get(entity_id).state)
if self.hass.states.get(entity_id) else None,
"reported_at": _reported_timestamp(self.hass.states.get(entity_id))}
for entity_id in sorted(radar_source_entity_ids(radar))
],
}
return result
@staticmethod
def _capabilities(radar: dict[str, Any]) -> list[str]:
profile = radar.get("profile")
out = []
if profile in {"esphome_ld2450_v1", "cartesian_v1", "polar_v1"}:
out.append("coordinates")
if profile == "range_v1":
out.append("range")
if profile == "zones_v1":
out.append("zone_state")
if (radar.get("sources") or {}).get("occupancy_entity"):
out.append("reported_presence")
if ((radar.get("zones") or {}).get("hardware") or {}).get("adapter") == "esphome_ld2450_numbers_v1":
out.append("hardware_zones")
return out
@@ -0,0 +1,211 @@
"""Pure geometry shared by radar setup, live projection and analysis (#485)."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any, Iterable
UNIT_TO_CM = {"mm": 0.1, "cm": 1.0, "m": 100.0, "in": 2.54, "ft": 30.48}
MAX_LOCAL_CM = 10_000.0
@dataclass(frozen=True)
class RadarFit:
heading_deg: float
mirror: bool
rms_cm: float
errors_cm: tuple[float, float]
def length_cm(value: Any, unit: str) -> float:
"""Convert one finite length without guessing or clamping its unit."""
number = float(value)
if not math.isfinite(number) or unit not in UNIT_TO_CM:
raise ValueError("invalid_radar")
result = number * UNIT_TO_CM[unit]
if abs(result) > MAX_LOCAL_CM:
raise ValueError("invalid_radar")
return result
def polar_to_cartesian(distance: Any, bearing: Any, *, unit: str,
angle_unit: str, zero: str, clockwise: bool) -> tuple[float, float]:
distance_cm = length_cm(distance, unit)
if distance_cm < 0:
raise ValueError("invalid_radar")
angle = float(bearing)
if not math.isfinite(angle) or angle_unit not in {"degrees", "radians"}:
raise ValueError("invalid_radar")
angle = math.degrees(angle) if angle_unit == "radians" else angle
if not clockwise:
angle = -angle
if zero == "right":
angle += 90.0
elif zero != "forward":
raise ValueError("invalid_radar")
radians = math.radians(angle)
return distance_cm * math.sin(radians), distance_cm * math.cos(radians)
def project_local(mount: dict[str, Any], calibration: dict[str, Any],
x_cm: float, y_cm: float) -> tuple[float, float]:
"""Project canonical local centimetres into the square normalized plan."""
x_cm = float(x_cm)
y_cm = float(y_cm)
if not all(map(math.isfinite, (x_cm, y_cm))) or math.hypot(x_cm, y_cm) > MAX_LOCAL_CM:
raise ValueError("invalid_radar")
mx, my = float(mount["x"]), float(mount["y"])
theta = math.radians(float(mount["heading_deg"]))
cell_cm = float(calibration["cell_cm"])
if not all(map(math.isfinite, (mx, my, theta, cell_cm))) or cell_cm <= 0:
raise ValueError("invalid_radar")
mirror = -1.0 if calibration.get("mirror") else 1.0
scale = 240.0 * cell_cm
return (
mx + (mirror * x_cm * math.cos(theta) + y_cm * math.sin(theta)) / scale,
my + (mirror * x_cm * math.sin(theta) - y_cm * math.cos(theta)) / scale,
)
def inverse_project(mount: dict[str, Any], calibration: dict[str, Any],
x: float, y: float) -> tuple[float, float]:
theta = math.radians(float(mount["heading_deg"]))
dx = (float(x) - float(mount["x"])) * 240.0 * float(calibration["cell_cm"])
dy = (float(y) - float(mount["y"])) * 240.0 * float(calibration["cell_cm"])
mirror = -1.0 if calibration.get("mirror") else 1.0
return (
mirror * (dx * math.cos(theta) + dy * math.sin(theta)),
dx * math.sin(theta) - dy * math.cos(theta),
)
def point_in_polygon(point: tuple[float, float], polygon: Iterable[Any],
tolerance: float = 1e-9) -> bool:
"""Boundary-inclusive even/odd test; no bounding-box fallback."""
points = [(float(p[0] if isinstance(p, (list, tuple)) else p["x"]),
float(p[1] if isinstance(p, (list, tuple)) else p["y"])) for p in polygon]
if len(points) < 3:
return True
px, py = point
inside = False
for index, (ax, ay) in enumerate(points):
bx, by = points[(index + 1) % len(points)]
cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax)
if abs(cross) <= tolerance and min(ax, bx) - tolerance <= px <= max(ax, bx) + tolerance \
and min(ay, by) - tolerance <= py <= max(ay, by) + tolerance:
return True
if (ay > py) != (by > py):
at_x = ax + (py - ay) * (bx - ax) / (by - ay)
if px < at_x:
inside = not inside
return inside
def polygon_is_convex(polygon: Iterable[Any]) -> bool:
"""Whether a straight visual transition stays inside this room.
A chord between valid points can leave a concave room. Smoothing is only
cosmetic, so disabling it for such rooms is safer than drawing presence
through a wall.
"""
points = [(float(p[0] if isinstance(p, (list, tuple)) else p["x"]),
float(p[1] if isinstance(p, (list, tuple)) else p["y"])) for p in polygon]
if len(points) < 3:
return False
sign = 0
for index, (ax, ay) in enumerate(points):
bx, by = points[(index + 1) % len(points)]
cx, cy = points[(index + 2) % len(points)]
cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx)
if abs(cross) <= 1e-12:
continue
current = 1 if cross > 0 else -1
if sign and current != sign:
return False
sign = current
return sign != 0
def clipped_arc_segments(
center: tuple[float, float], radius: float, heading_deg: float,
fov_deg: float | None, polygon: Iterable[Any] | None,
) -> list[list[tuple[float, float]]]:
"""Approximate a range arc and split it at a room boundary.
Range input has no bearing, so the arc is the honest geometry. Returning
only contiguous inside runs prevents the browser from drawing the arc
through walls or across a concave cut-out. The resolution (<=4 degrees)
is considerably finer than the two-CSS-pixel display stroke.
"""
if not all(math.isfinite(value) for value in (*center, radius, heading_deg)) \
or radius <= 0 or fov_deg is None or not math.isfinite(fov_deg) \
or not 0 < fov_deg <= 360:
return []
points = list(polygon) if polygon is not None else None
steps = max(8, min(180, math.ceil(fov_deg / 4)))
start = heading_deg - 90 - fov_deg / 2
samples = [
(
center[0] + math.cos(math.radians(start + fov_deg * index / steps)) * radius,
center[1] + math.sin(math.radians(start + fov_deg * index / steps)) * radius,
)
for index in range(steps + 1)
]
if points is None:
return [samples]
segments: list[list[tuple[float, float]]] = []
current: list[tuple[float, float]] = []
for sample in samples:
if point_in_polygon(sample, points, 1e-6):
current.append(sample)
else:
if len(current) >= 2:
segments.append(current)
current = []
if len(current) >= 2:
segments.append(current)
return segments
def solve_two_point(mount: tuple[float, float], local_points: list[tuple[float, float]],
plan_points: list[tuple[float, float]], cell_cm: float) -> RadarFit:
"""Fit one rigid heading and mirror with the physical scale fixed."""
if len(local_points) != 2 or len(plan_points) != 2 or cell_cm <= 0:
raise ValueError("invalid_selection")
scale = 240.0 * cell_cm
targets = [((x - mount[0]) * scale, -(y - mount[1]) * scale) for x, y in plan_points]
if any(math.hypot(*local) < 50 for local in local_points):
raise ValueError("invalid_selection")
denominator = math.hypot(*local_points[0]) * math.hypot(*local_points[1])
angle = math.degrees(math.acos(max(-1.0, min(1.0,
sum(a * b for a, b in zip(local_points[0], local_points[1], strict=True)) / denominator))))
if angle < 20 or angle > 160:
raise ValueError("invalid_selection")
candidates: list[RadarFit] = []
for mirrored in (False, True):
transformed = [((-x if mirrored else x), y) for x, y in local_points]
dot = sum(sx * tx + sy * ty for (sx, sy), (tx, ty) in zip(transformed, targets, strict=True))
cross = sum(sx * ty - sy * tx for (sx, sy), (tx, ty) in zip(transformed, targets, strict=True))
rotation = math.atan2(cross, dot)
errors = []
for (sx, sy), (tx, ty) in zip(transformed, targets, strict=True):
rx = sx * math.cos(rotation) - sy * math.sin(rotation)
ry = sx * math.sin(rotation) + sy * math.cos(rotation)
errors.append(math.hypot(rx - tx, ry - ty))
radial_ok = all(abs(math.hypot(*local) - math.hypot(*target)) <= max(20, .15 * math.hypot(*target))
for local, target in zip(local_points, targets, strict=True))
rms = math.sqrt(sum(error * error for error in errors) / 2)
if radial_ok and rms <= 20 and max(errors) <= 30:
candidates.append(RadarFit(
(-math.degrees(rotation)) % 360,
mirrored,
rms,
(errors[0], errors[1]),
))
if not candidates:
raise ValueError("invalid_selection")
candidates.sort(key=lambda fit: fit.rms_cm)
if len(candidates) > 1 and abs(candidates[0].rms_cm - candidates[1].rms_cm) < 10:
raise ValueError("ambiguous_sources")
return candidates[0]
@@ -0,0 +1,434 @@
"""Strict, change-aware validation for the optional #485 radar namespace.
Radar data is deliberately kept outside the plan geometry model. Old plans
without this namespace remain byte-for-byte valid, while a client that creates
or changes a known version-1 block must satisfy the complete contract before
the config transaction can commit.
"""
from __future__ import annotations
import copy
import math
import re
from typing import Any
import voluptuous as vol
from .radar_geometry import solve_two_point
RADAR_PROFILES = frozenset({
"esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1",
"zones_v1", "presence_v1",
})
_COMMON_SOURCE_ROLES = (
"occupancy_entity", "count_entity", "availability_entity",
)
_PROFILE_SOURCE_ROLES: dict[str, tuple[str, tuple[str, ...]]] = {
"esphome_ld2450_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
"cartesian_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
"polar_v1": ("slots", ("distance_entity", "angle_entity", "presence_entity")),
"range_v1": ("ranges", ("entity_id", "presence_entity")),
"zones_v1": ("zones", ("entity_id",)),
"presence_v1": ("", ()),
}
LENGTH_UNITS = frozenset({"mm", "cm", "m", "in", "ft"})
ANGLE_UNITS = frozenset({"degrees", "radians"})
ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
ENTITY_RE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
MAX_RADARS = 32
CANVAS_LIMIT = 5000.0
LD2450_MODELS = frozenset({"ld2450", "hlkld2450", "hilinkld2450"})
class RadarValidationError(vol.Invalid):
"""Stable client-visible radar validation failure."""
code = "invalid_radar"
def _invalid(message: str) -> None:
raise RadarValidationError(message)
def _finite(value: Any, name: str, minimum: float | None = None,
maximum: float | None = None) -> float:
if isinstance(value, bool):
_invalid(f"{name} must be finite")
try:
number = float(value)
except (TypeError, ValueError):
_invalid(f"{name} must be finite")
if not math.isfinite(number):
_invalid(f"{name} must be finite")
if minimum is not None and number < minimum:
_invalid(f"{name} is below its minimum")
if maximum is not None and number > maximum:
_invalid(f"{name} is above its maximum")
return number
def _mapping(value: Any, name: str) -> dict[str, Any]:
if not isinstance(value, dict):
_invalid(f"{name} must be an object")
return value
def _array(value: Any, name: str, maximum: int) -> list[Any]:
if not isinstance(value, list) or len(value) > maximum:
_invalid(f"{name} must be an array with at most {maximum} entries")
return value
def _identifier(value: Any, name: str) -> str:
if not isinstance(value, str) or ID_RE.fullmatch(value) is None:
_invalid(f"{name} must be a bounded identifier")
return value
def _entity(value: Any, name: str, domains: set[str]) -> str:
if not isinstance(value, str) or len(value) > 255 or ENTITY_RE.fullmatch(value) is None:
_invalid(f"{name} must be an entity id")
if value.split(".", 1)[0] not in domains:
_invalid(f"{name} has an unsupported entity domain")
return value
def _point(value: Any, name: str) -> tuple[float, float]:
obj = _mapping(value, name)
return (
_finite(obj.get("x"), f"{name}.x", -CANVAS_LIMIT, CANVAS_LIMIT),
_finite(obj.get("y"), f"{name}.y", -CANVAS_LIMIT, CANVAS_LIMIT),
)
def _source_ids(profile: Any, sources: dict[str, Any]) -> set[str]:
profile_roles = _PROFILE_SOURCE_ROLES.get(profile)
if profile_roles is None:
return set()
out: set[str] = set()
for key in _COMMON_SOURCE_ROLES:
value = sources.get(key)
if isinstance(value, str):
out.add(value)
group, roles = profile_roles
if group:
for item in sources.get(group) or []:
if not isinstance(item, dict):
continue
for key in roles:
value = item.get(key)
if isinstance(value, str):
out.add(value)
return out
def radar_source_entity_ids(radar: Any) -> set[str]:
"""Return exact source ids from one already validated radar block."""
if not isinstance(radar, dict):
return set()
sources = radar.get("sources")
out = _source_ids(radar.get("profile"), sources) \
if isinstance(sources, dict) else set()
return out
def radar_registry_evidence(hass: Any) -> dict[str, dict[str, dict[str, Any]]]:
"""Capture the small registry subset needed by verified adapters.
The returned plain mapping is safe to pass into executor-side validation.
Generic/manual profiles deliberately do not depend on registry evidence.
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
entities = {
str(entry.entity_id): {
"device_id": str(entry.device_id) if entry.device_id else None,
"platform": str(getattr(entry, "platform", "") or ""),
}
for entry in er.async_get(hass).entities.values()
}
devices = {
str(entry.id): {
"model": str(getattr(entry, "model", "") or ""),
}
for entry in dr.async_get(hass).devices.values()
}
return {"entities": entities, "devices": devices}
def _canonical_model(value: Any) -> str:
return re.sub(r"[^a-z0-9]", "", str(value or "").lower())
def _validate_verified_adapter(
profile: str,
sources: dict[str, Any],
marker: dict[str, Any],
registry: dict[str, dict[str, dict[str, Any]]] | None,
) -> None:
"""Require structural same-device evidence for the LD2450 adapter."""
if profile != "esphome_ld2450_v1" or registry is None:
return
entities = registry.get("entities") or {}
source_ids = _source_ids(profile, sources)
rows = [entities.get(entity_id) for entity_id in source_ids]
device_ids = {
str(row.get("device_id"))
for row in rows
if isinstance(row, dict) and row.get("device_id")
}
if len(rows) != len(source_ids) or any(not isinstance(row, dict) for row in rows) \
or len(device_ids) != 1 \
or any(str(row.get("platform") or "") != "esphome" for row in rows if row):
_invalid("LD2450 sources must belong to the same ESPHome device")
device_id = next(iter(device_ids))
model = (registry.get("devices") or {}).get(device_id, {}).get("model")
if _canonical_model(model) not in LD2450_MODELS:
_invalid("LD2450 adapter requires verified LD2450 device metadata")
binding = str(marker.get("binding") or "")
owner_device: str | None = None
if binding.startswith("device:"):
owner_device = binding.removeprefix("device:")
elif binding.startswith("entity:"):
owner = entities.get(binding.removeprefix("entity:"))
owner_device = str(owner.get("device_id")) if isinstance(owner, dict) and owner.get("device_id") else None
if owner_device != device_id:
_invalid("LD2450 sources must belong to the marker device")
def _validate_sources(profile: str, sources: Any) -> None:
obj = _mapping(sources, "radar.sources")
slots = _array(obj.get("slots", []), "radar.sources.slots", 8)
ranges = _array(obj.get("ranges", []), "radar.sources.ranges", 2)
zones = _array(obj.get("zones", []), "radar.sources.zones", 32)
seen: set[str] = set()
role_entities: set[str] = set()
def unique_role(entity_id: str, name: str, domains: set[str]) -> str:
value = _entity(entity_id, name, domains)
if value in role_entities:
_invalid("radar coordinate/range role entities must be distinct")
role_entities.add(value)
return value
if profile in {"esphome_ld2450_v1", "cartesian_v1", "polar_v1"} and not slots:
_invalid("coordinate radar requires at least one slot")
if profile == "esphome_ld2450_v1" and len(slots) > 3:
_invalid("LD2450 supports at most three slots")
if profile == "range_v1" and not ranges:
_invalid("range radar requires at least one range")
if profile == "zones_v1" and not zones:
_invalid("zone radar requires at least one zone source")
if profile == "presence_v1" and not obj.get("occupancy_entity"):
_invalid("presence radar requires occupancy_entity")
for index, slot in enumerate(slots):
item = _mapping(slot, f"radar.sources.slots[{index}]")
ident = _identifier(item.get("id"), "slot.id")
if ident in seen:
_invalid("radar source ids must be unique")
seen.add(ident)
if profile == "polar_v1":
unique_role(item.get("distance_entity"), "slot.distance_entity", {"sensor"})
unique_role(item.get("angle_entity"), "slot.angle_entity", {"sensor"})
if item.get("unit") not in LENGTH_UNITS or item.get("angle_unit") not in ANGLE_UNITS:
_invalid("polar units must be explicit")
if item.get("angle_zero") not in {"forward", "right"}:
_invalid("polar angle_zero is invalid")
if not isinstance(item.get("angle_clockwise"), bool):
_invalid("polar angle_clockwise must be boolean")
else:
unique_role(item.get("x_entity"), "slot.x_entity", {"sensor"})
unique_role(item.get("y_entity"), "slot.y_entity", {"sensor"})
if profile == "esphome_ld2450_v1" and item.get("unit") != "mm":
_invalid("LD2450 Cartesian unit must be mm")
if profile != "esphome_ld2450_v1" and item.get("unit") not in LENGTH_UNITS:
_invalid("cartesian unit must be explicit")
if item.get("x_sign", 1) not in {-1, 1} or item.get("y_sign", 1) not in {-1, 1}:
_invalid("axis signs must be +1 or -1")
if "swap_xy" in item and not isinstance(item["swap_xy"], bool):
_invalid("swap_xy must be boolean")
if item.get("presence_entity") is not None:
_entity(item["presence_entity"], "slot.presence_entity", {"binary_sensor"})
for index, source in enumerate(ranges):
item = _mapping(source, f"radar.sources.ranges[{index}]")
ident = _identifier(item.get("id"), "range.id")
if ident in seen:
_invalid("radar source ids must be unique")
seen.add(ident)
unique_role(item.get("entity_id"), "range.entity_id", {"sensor"})
if item.get("unit") not in LENGTH_UNITS:
_invalid("range unit must be explicit")
if item.get("presence_entity") is not None:
_entity(item["presence_entity"], "range.presence_entity", {"binary_sensor"})
for index, source in enumerate(zones):
item = _mapping(source, f"radar.sources.zones[{index}]")
ident = _identifier(item.get("id"), "zone source id")
if ident in seen:
_invalid("radar source ids must be unique")
seen.add(ident)
kind = item.get("kind")
if kind not in {"occupancy", "count"}:
_invalid("zone source kind is invalid")
_entity(item.get("entity_id"), "zone source entity", {"binary_sensor"} if kind == "occupancy" else {"sensor"})
if obj.get("occupancy_entity") is not None:
_entity(obj["occupancy_entity"], "radar.sources.occupancy_entity", {"binary_sensor"})
if obj.get("count_entity") is not None:
_entity(obj["count_entity"], "radar.sources.count_entity", {"sensor"})
if obj.get("availability_entity") is not None:
_entity(obj["availability_entity"], "radar.sources.availability_entity", {"binary_sensor"})
def _validate_radar(
radar: Any,
spaces: dict[str, dict[str, Any]],
marker: dict[str, Any],
registry: dict[str, dict[str, dict[str, Any]]] | None = None,
) -> None:
obj = _mapping(radar, "marker.radar")
if obj.get("version") != 1:
_invalid("unsupported radar version")
if not isinstance(obj.get("enabled"), bool):
_invalid("radar.enabled must be boolean")
if obj.get("show_live") is not None and not isinstance(obj.get("show_live"), bool):
_invalid("radar.show_live must be boolean")
profile = obj.get("profile")
if profile not in RADAR_PROFILES:
_invalid("unsupported radar profile")
_validate_sources(profile, obj.get("sources"))
_validate_verified_adapter(profile, obj["sources"], marker, registry)
room_id = obj.get("room_id")
if not isinstance(room_id, str) or not room_id:
_invalid("radar.room_id is required")
owner_space = str(marker.get("space") or "")
space = spaces.get(owner_space)
if space is None or room_id not in {str(room.get("id")) for room in space.get("rooms") or []}:
_invalid("radar room must exist in the marker space")
mount = _mapping(obj.get("mount"), "radar.mount")
_identifier(mount.get("installation_id"), "radar installation id")
_finite(mount.get("x"), "radar.mount.x", -CANVAS_LIMIT, CANVAS_LIMIT)
_finite(mount.get("y"), "radar.mount.y", -CANVAS_LIMIT, CANVAS_LIMIT)
_finite(mount.get("heading_deg"), "radar.mount.heading_deg", 0, 359.999999999)
if mount.get("range_cm") is not None:
_finite(mount["range_cm"], "radar.mount.range_cm", 0.000001, 10000)
if mount.get("fov_deg") is not None:
_finite(mount["fov_deg"], "radar.mount.fov_deg", 0.000001, 360)
calibration = _mapping(obj.get("calibration"), "radar.calibration")
if calibration.get("method") not in {"manual", "two_point", "not_required"}:
_invalid("radar calibration method is invalid")
if not isinstance(calibration.get("mirror"), bool):
_invalid("radar calibration mirror must be boolean")
calibration_cell_cm = _finite(
calibration.get("cell_cm"), "radar calibration cell_cm", 0.01, 1000,
)
try:
space_cell_cm = float(space.get("cell_cm", 5))
except (TypeError, ValueError):
_invalid("radar owner space scale is invalid")
if not math.isfinite(space_cell_cm) or space_cell_cm <= 0 \
or not math.isclose(calibration_cell_cm, space_cell_cm, abs_tol=1e-9):
_invalid("radar calibration scale does not match the owner space")
refs = calibration.get("refs")
if calibration["method"] == "two_point":
if not isinstance(refs, list) or len(refs) != 2:
_invalid("two-point calibration requires exactly two references")
local_points: list[tuple[float, float]] = []
plan_points: list[tuple[float, float]] = []
for ref in refs:
entry = _mapping(ref, "radar calibration reference")
plan_points.append(_point(entry.get("plan"), "radar calibration plan point"))
local = _point(entry.get("local_cm"), "radar calibration local point")
local_points.append(local)
if math.hypot(*local) < 50:
_invalid("radar calibration reference is too close to the mount")
try:
fit = solve_two_point(
(_finite(mount.get("x"), "radar.mount.x"),
_finite(mount.get("y"), "radar.mount.y")),
local_points, plan_points,
_finite(calibration.get("cell_cm"), "radar calibration cell_cm", 0.01, 1000),
)
except ValueError as err:
_invalid(str(err))
if not math.isclose(float(mount["heading_deg"]), fit.heading_deg, abs_tol=1e-6) \
or calibration["mirror"] is not fit.mirror:
_invalid("radar two-point calibration parameters do not match its references")
if calibration.get("rms_cm") is None \
or not math.isclose(float(calibration["rms_cm"]), fit.rms_cm, abs_tol=1e-6):
_invalid("radar two-point calibration rms does not match its references")
elif refs not in (None, []):
_invalid("manual calibration cannot contain captured references")
if calibration.get("rms_cm") is not None:
_finite(calibration["rms_cm"], "radar calibration rms_cm", 0, 30)
allowed = obj.get("allowed_room_ids")
if allowed is not None:
room_ids = _array(allowed, "radar.allowed_room_ids", 32)
known = {str(room.get("id")) for room in space.get("rooms") or []}
if len(set(room_ids)) != len(room_ids) or any(value not in known for value in room_ids):
_invalid("radar allowed rooms must be unique rooms in the marker space")
def _validate_settings(settings: Any) -> None:
obj = _mapping(settings, "settings.radar")
if obj.get("version") not in (None, 1):
_invalid("unsupported radar settings version")
if obj.get("show_live") is not None and not isinstance(obj.get("show_live"), bool):
_invalid("settings.radar.show_live must be boolean")
def validate_marker_radars(config: dict[str, Any], previous: dict[str, Any] | None = None,
*, validate_all: bool = False,
registry: dict[str, dict[str, dict[str, Any]]] | None = None) -> None:
"""Validate only newly created/changed known radar namespaces.
This mirrors the project's lossless compatibility doctrine: untouched
future or malformed blocks stay inert and round-trip through unrelated
edits, but the editor cannot create or modify invalid known data.
"""
spaces = {str(space.get("id")): space for space in config.get("spaces") or []}
markers = {str(marker.get("id")): marker for marker in config.get("markers") or []}
previous_markers = {
str(marker.get("id")): marker for marker in (previous or {}).get("markers") or []
}
configured = 0
for marker_id, marker in markers.items():
if "radar" not in marker:
continue
radar = marker.get("radar")
if radar is None:
continue
configured += 1
old = previous_markers.get(marker_id, {}).get("radar", object())
if validate_all or radar != old:
_validate_radar(radar, spaces, marker, registry)
if configured > MAX_RADARS:
_invalid("at most 32 radars may be configured")
missing = object()
settings = (config.get("settings") or {}).get("radar", missing)
previous_settings = ((previous or {}).get("settings") or {}).get("radar", missing)
if settings is not missing and (validate_all or settings != previous_settings):
_validate_settings(settings)
def validate_radar_draft(
config: dict[str, Any], marker_id: str, radar: Any,
registry: dict[str, dict[str, dict[str, Any]]] | None = None,
) -> tuple[dict[str, Any], set[str]]:
"""Validate an unsaved setup block against its exact stored marker owner."""
marker = next(
(item for item in config.get("markers") or []
if isinstance(item, dict) and str(item.get("id")) == marker_id),
None,
)
if marker is None or marker.get("removed") is True or marker.get("binding") == "virtual":
_invalid("radar setup marker is invalid")
candidate = copy.deepcopy(marker)
candidate["radar"] = copy.deepcopy(radar)
spaces = {str(space.get("id")): space for space in config.get("spaces") or []}
_validate_radar(candidate["radar"], spaces, candidate, registry)
return candidate, radar_source_entity_ids(candidate["radar"])
@@ -0,0 +1,277 @@
"""Bounded, permission-aware WebSocket surface for presence radars (#485)."""
from __future__ import annotations
import asyncio
import json
import time
from collections import defaultdict, deque
from typing import Any
import voluptuous as vol
from homeassistant.auth.permissions.const import POLICY_READ
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_state_report_event,
)
from .auth import may_write
from .radar import MAX_FRAME_HZ, RadarCoordinator
from .radar_validation import (
RadarValidationError,
radar_registry_evidence,
validate_radar_draft,
)
from .store import get_data
_INSPECT_CALLS: dict[str, deque[float]] = defaultdict(deque)
_ACTIVE_SUBSCRIPTIONS: dict[str, int] = defaultdict(int)
_ACTIVE_SETUP: set[tuple[str, str]] = set()
_MAX_SETUP_TOTAL = 8
def _user_id(connection) -> str:
return str(getattr(getattr(connection, "user", None), "id", ""))
def _validated_draft(
coordinator: RadarCoordinator, message: dict[str, Any], connection,
) -> tuple[dict[str, Any], dict[str, Any], set[str]] | None:
payload = message.get("draft_sources")
radar = payload.get("radar") if isinstance(payload, dict) else None
try:
if len(json.dumps(payload, separators=(",", ":"), allow_nan=False).encode()) > 65536:
raise vol.Invalid("radar draft is too large")
marker, source_ids = validate_radar_draft(
coordinator.config, message["marker_id"], radar,
radar_registry_evidence(coordinator.hass),
)
except RadarValidationError:
connection.send_error(message["id"], "invalid_radar", "invalid_radar")
return None
except (TypeError, ValueError, vol.Invalid):
connection.send_error(message["id"], "invalid_selection", "invalid_selection")
return None
if not _can_read(connection, source_ids):
connection.send_error(message["id"], "source_restricted", "source_restricted")
return None
if any(
coordinator.hass.states.get(entity_id) is None
or str(coordinator.hass.states.get(entity_id).state) in {"unknown", "unavailable"}
for entity_id in source_ids
):
connection.send_error(message["id"], "source_unavailable", "source_unavailable")
return None
return marker, radar, source_ids
def _coordinator(hass: HomeAssistant, connection, msg_id: int) -> RadarCoordinator | None:
runtime = get_data(hass)
coordinator = getattr(runtime, "radar_coordinator", None) if runtime else None
if runtime is not None and coordinator is None:
connection.send_error(msg_id, "unsupported_capability", "unsupported_capability")
return None
if not isinstance(coordinator, RadarCoordinator) or coordinator.closed:
connection.send_error(msg_id, "not_ready", "not_ready")
return None
return coordinator
def _can_read(connection, entity_ids: set[str]) -> bool:
user = getattr(connection, "user", None)
permissions = getattr(user, "permissions", None)
return bool(permissions) and all(
permissions.check_entity(entity_id, POLICY_READ) for entity_id in entity_ids
)
def _rate_limit(user_id: str) -> bool:
now = time.monotonic()
calls = _INSPECT_CALLS[user_id]
while calls and calls[0] <= now - 60:
calls.popleft()
if len(calls) >= 10:
return False
calls.append(now)
return True
@callback
def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_radar_subscribe)
websocket_api.async_register_command(hass, ws_radar_setup_inspect)
websocket_api.async_register_command(hass, ws_radar_setup_subscribe)
@websocket_api.websocket_command({
vol.Required("type"): "houseplan/radar/subscribe",
vol.Required("space_id"): vol.All(str, vol.Length(min=1, max=64)),
})
def ws_radar_subscribe(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
coordinator = _coordinator(hass, connection, msg["id"])
if coordinator is None:
return
connection_key = _user_id(connection)
if _ACTIVE_SUBSCRIPTIONS[connection_key] >= 4:
connection.send_error(msg["id"], "rate_limited", "rate_limited")
return
space_id = msg["space_id"]
if not coordinator.has_space(space_id):
connection.send_error(msg["id"], "invalid_selection", "invalid_selection")
return
@callback
def publish(marker_id: str, frame: dict[str, Any]) -> None:
if coordinator.space_for_marker(marker_id) != space_id:
return
if not _can_read(connection, coordinator.source_ids(marker_id)):
connection.send_event(msg["id"], {
"marker_id": marker_id,
"server_session_id": coordinator.server_session_id,
"seq": frame.get("seq", 0), "health": "restricted",
"reported_presence": None, "complete": False,
"targets": [], "ranges": [], "zones": [],
})
return
connection.send_event(msg["id"], frame)
unsub = coordinator.add_listener(publish, publish_initial=False)
@callback
def remove() -> None:
unsub()
_ACTIVE_SUBSCRIPTIONS[connection_key] = max(
0, _ACTIVE_SUBSCRIPTIONS[connection_key] - 1
)
if not _ACTIVE_SUBSCRIPTIONS[connection_key]:
_ACTIVE_SUBSCRIPTIONS.pop(connection_key, None)
_ACTIVE_SUBSCRIPTIONS[connection_key] += 1
connection.subscriptions[msg["id"]] = remove
connection.send_result(msg["id"], {
"server_session_id": coordinator.server_session_id,
"config_rev": coordinator.config_rev,
})
for frame in coordinator.frames_for_space(space_id):
publish(str(frame.get("marker_id", "")), coordinator.public_frame(frame))
@websocket_api.websocket_command({
vol.Required("type"): "houseplan/radar/setup/inspect",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=128)),
vol.Optional("draft_sources"): vol.All(dict, vol.Length(max=128)),
})
def ws_radar_setup_inspect(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
if not may_write(hass, getattr(connection, "user", None)):
connection.send_error(msg["id"], "unauthorized", "unauthorized")
return
coordinator = _coordinator(hass, connection, msg["id"])
if coordinator is None:
return
user_id = _user_id(connection)
if not _rate_limit(user_id):
connection.send_error(msg["id"], "rate_limited", "rate_limited")
return
if "draft_sources" in msg:
validated = _validated_draft(coordinator, msg, connection)
if validated is None:
return
marker, radar, _source_ids = validated
connection.send_result(msg["id"], coordinator.inspect_draft(marker, radar))
return
marker_id = msg["marker_id"]
if not _can_read(connection, coordinator.source_ids(marker_id)):
connection.send_error(msg["id"], "source_restricted", "source_restricted")
return
connection.send_result(msg["id"], coordinator.inspect(marker_id))
@websocket_api.websocket_command({
vol.Required("type"): "houseplan/radar/setup/subscribe",
vol.Required("marker_id"): vol.All(str, vol.Length(min=1, max=128)),
vol.Required("expected_config_rev"): int,
vol.Optional("draft_sources"): vol.All(dict, vol.Length(max=128)),
})
def ws_radar_setup_subscribe(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
if not may_write(hass, getattr(connection, "user", None)):
connection.send_error(msg["id"], "unauthorized", "unauthorized")
return
coordinator = _coordinator(hass, connection, msg["id"])
if coordinator is None:
return
if msg["expected_config_rev"] != coordinator.config_rev:
connection.send_error(msg["id"], "conflict", "conflict")
return
marker_id = msg["marker_id"]
user_id = _user_id(connection)
key = (user_id, marker_id)
if key in _ACTIVE_SETUP or len(_ACTIVE_SETUP) >= _MAX_SETUP_TOTAL:
connection.send_error(msg["id"], "rate_limited", "rate_limited")
return
draft: tuple[dict[str, Any], dict[str, Any], set[str]] | None = None
if "draft_sources" in msg:
draft = _validated_draft(coordinator, msg, connection)
if draft is None:
return
marker, radar, source_ids = draft
else:
marker = coordinator.marker_config(marker_id)
if marker_id not in coordinator.radars or marker is None:
connection.send_error(msg["id"], "invalid_selection", "invalid_selection")
return
radar = marker["radar"]
source_ids = coordinator.source_ids(marker_id)
if not _can_read(connection, source_ids):
connection.send_error(msg["id"], "source_restricted", "source_restricted")
return
pending: asyncio.TimerHandle | None = None
unsubs: list[Any] = []
unregister_cleanup: Any = None
removed = False
@callback
def send_snapshot() -> None:
nonlocal pending
pending = None
if not _can_read(connection, source_ids):
connection.send_event(msg["id"], {"marker_id": marker_id, "health": "restricted"})
return
connection.send_event(msg["id"], coordinator.inspect_draft(marker, radar))
@callback
def source_reported(_event) -> None:
nonlocal pending
if pending is None:
pending = hass.loop.call_later(1 / MAX_FRAME_HZ, send_snapshot)
if source_ids:
unsubs.append(async_track_state_report_event(hass, sorted(source_ids), source_reported))
unsubs.append(async_track_state_change_event(hass, sorted(source_ids), source_reported))
@callback
def remove() -> None:
nonlocal pending, unregister_cleanup, removed
if removed:
return
removed = True
if pending is not None:
pending.cancel()
pending = None
for unsub in unsubs:
unsub()
unsubs.clear()
_ACTIVE_SETUP.discard(key)
if unregister_cleanup is not None:
unregister_cleanup()
unregister_cleanup = None
_ACTIVE_SETUP.add(key)
unregister_cleanup = coordinator.add_external_cleanup(remove)
connection.subscriptions[msg["id"]] = remove
connection.send_result(msg["id"], {
"server_session_id": coordinator.server_session_id,
"config_rev": coordinator.config_rev,
})
send_snapshot()
+153
View File
@@ -109,6 +109,8 @@ class HouseplanData:
# memory-only, invalidated by a rev mismatch; the previous document is
# not re-judged on every write. Counts only — documents are never held.
junction_baseline: tuple[int, dict[str, dict[str, int]]] | None = None
# Runtime-only #485 authority; never serialized by HouseplanStore.
radar_coordinator: Any | None = None
HouseplanConfigEntry = ConfigEntry[HouseplanData]
@@ -236,3 +238,154 @@ async def async_save_config_state(
except Exception: # noqa: BLE001 - config commit already stands
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
return payload
@dataclass(frozen=True)
class ResolvedStorePair:
"""One coherent config/layout read, optionally after pending recovery."""
config_data: dict[str, Any]
layout_data: dict[str, Any]
recovered_kind: str | None = None
def _pending_target(
layout_data: dict[str, Any],
) -> dict[str, Any] | None:
"""Return a structurally usable paired-write intent, if one is present."""
pending = layout_data.get(OPTIMIZE_PENDING)
if not isinstance(pending, dict):
return None
if not isinstance(pending.get("config"), dict):
return None
if not isinstance(pending.get("layout"), dict):
return None
return pending
async def async_converge_store_pair(
runtime: HouseplanData,
pending: dict[str, Any],
*,
config_data: dict[str, Any] | None = None,
layout_data: dict[str, Any] | None = None,
) -> ResolvedStorePair:
"""Converge both stores on one durable paired-write intent.
Callers hold ``runtime.write_lock``. Store may raise after bytes reached
disk, so each failed half is reloaded and compared with the exact payload
before the exception is allowed to escape.
"""
if config_data is None:
config_data = await runtime.config_store.async_load() or {}
if layout_data is None:
layout_data = await runtime.store.async_load() or {}
target_config = canonicalize_config_geometry(pending["config"])
target_layout = canonicalize_layout_geometry(pending["layout"])
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
target_config_rev = int(pending.get(
"config_rev", config_rev + (config_data.get("config") != target_config)
))
target_layout_rev = int(pending.get(
"layout_rev", layout_rev + (layout_data.get("layout", {}) != target_layout)
))
exact_metadata = pending.get("final_metadata")
replace_metadata = isinstance(exact_metadata, dict)
config_needs_write = (
config_data.get("config") != target_config
or (
config_rev != target_config_rev
if replace_metadata
else config_rev < target_config_rev
)
)
if config_needs_write:
previous_config_rev = config_rev
config_rev = (
target_config_rev
if replace_metadata
else max(config_rev, target_config_rev)
)
expected_config = {"config": target_config, "rev": config_rev}
try:
config_data = await async_save_config_state(
runtime,
target_config,
config_rev,
previous_rev=previous_config_rev,
)
except Exception:
# A Store write is allowed to fail after its atomic replacement.
# Exact reload distinguishes that case from a half that never
# reached disk; no inference is made from the exception itself.
config_data = await runtime.config_store.async_load() or {}
if config_data != expected_config:
raise
layout_rev = (
target_layout_rev
if replace_metadata
else max(layout_rev, target_layout_rev)
)
metadata = dict(exact_metadata) if replace_metadata else None
if (
not replace_metadata
and not pending.get("clear_backup")
and OPTIMIZE_BACKUP in layout_data
):
metadata = {OPTIMIZE_BACKUP: layout_data[OPTIMIZE_BACKUP]}
remove_metadata = [OPTIMIZE_PENDING, OPTIMIZE_BACKUP]
if pending.get("clear_backup"):
# A recovered whole-plan undo replaces the complete layout; a nested
# repair snapshot from that replaced layout must not survive it.
remove_metadata.append("repair_backup")
expected_layout = layout_store_payload(
layout_data,
target_layout,
layout_rev,
metadata=metadata,
remove=tuple(remove_metadata),
replace_metadata=replace_metadata,
)
try:
layout_data = await async_save_layout_state(
runtime,
layout_data,
target_layout,
layout_rev,
metadata=metadata,
remove=tuple(remove_metadata),
replace_metadata=replace_metadata,
)
except Exception:
layout_data = await runtime.store.async_load() or {}
if layout_data != expected_layout:
raise
# Return fresh durable documents. A following writer must never continue
# with pre-recovery revisions or a pre-recovery layout snapshot.
config_data = await runtime.config_store.async_load() or {}
layout_data = await runtime.store.async_load() or {}
return ResolvedStorePair(
config_data=config_data,
layout_data=layout_data,
recovered_kind=str(pending.get("kind") or "plan optimization"),
)
async def async_resolve_pending_pair(runtime: HouseplanData) -> ResolvedStorePair:
"""Load a coherent pair, finishing a valid durable intent first."""
config_data = await runtime.config_store.async_load() or {}
layout_data = await runtime.store.async_load() or {}
pending = _pending_target(layout_data)
if pending is None:
return ResolvedStorePair(config_data=config_data, layout_data=layout_data)
return await async_converge_store_pair(
runtime,
pending,
config_data=config_data,
layout_data=layout_data,
)
+15 -4
View File
@@ -136,6 +136,15 @@ def _custom_fill(value: object) -> dict[str, Any] | None:
return _copy_keys(value, ("c", "a"))
# The eleven palette slots the card reads (src/logic.ts DEFAULT_FILL_COLORS).
# The config schema stays open on purpose so older or extended configs keep
# loading; a package must not carry a key the product never defined (#498).
SUPPORT_FILL_COLOR_KEYS = (
"light_on", "light_off", "light_none", "temp_cold", "temp_ok", "temp_hot",
"lqi_low", "lqi_high", "glow_base", "glow_light", "wall_fill",
)
def _global_settings(value: object) -> dict[str, Any]:
out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays"))
if not isinstance(value, dict):
@@ -145,11 +154,13 @@ def _global_settings(value: object) -> dict[str, Any]:
out["show_room_tooltip"] = show_room_tooltip
fill_colors = value.get("fill_colors")
if isinstance(fill_colors, dict):
out["fill_colors"] = {
str(key): _copy_keys(item, ("c", "a"))
for key, item in fill_colors.items()
if isinstance(key, str) and isinstance(item, dict)
palette = {
key: _copy_keys(fill_colors[key], ("c", "a"))
for key in SUPPORT_FILL_COLOR_KEYS
if isinstance(fill_colors.get(key), dict)
}
if palette:
out["fill_colors"] = palette
style = value.get("decor_default_style")
if isinstance(style, dict):
out["decor_default_style"] = _copy_keys(
+105 -48
View File
@@ -317,15 +317,53 @@ class TrailRecorder:
A tombstone deliberately stays in config so discovery cannot resurrect
a deleted device. For live tracking and trail ownership it is absent:
this is the same boundary used by ``async_refresh`` above.
Returns the number of erased markers. Runs dropped because their route
vanished (#162) are not counted, but they are just as durable (#495):
they leave with the orphan transaction, or with a write of their own.
"""
live_marker_ids = {
str(marker.get("id"))
for marker in config.get("markers") or []
if marker.get("id") is not None and marker.get("removed") is not True
}
# #162: a route that vanished (deleted, or re-targeted to another
# space, which is a new identity) takes its own runs with it, while the
# marker and its other routes stay untouched.
fire = False
try:
async with self._refresh_lock:
# #162: a route that vanished (deleted, or re-targeted to
# another space, which is a new identity) takes its own runs
# with it, while the marker and its other routes stay untouched.
dropped = self._drop_unknown_routes(config)
orphan_ids = set(self.book.data) - live_marker_ids
if not orphan_ids and not dropped:
return 0
try:
if orphan_ids:
removed = await self._delete_many_locked(orphan_ids)
else:
removed = 0
await self._save_now_locked()
except Exception:
# The store is the durable authority (#335): a drop that
# never reached it must not survive only in memory, or a
# restart brings the runs back while the card believed
# them gone. Put them back so the next commit retries.
self._restore_dropped(dropped)
raise
fire = True
return removed
except Exception: # noqa: BLE001 — config commit already succeeded
_LOGGER.exception(
"House Plan: reconciling vacuum trails after a config commit failed",
)
return 0
finally:
if fire:
self.hass.bus.async_fire("houseplan_trail_updated", {})
def _drop_unknown_routes(self, config: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Forget runs of routes the config no longer has; return what was dropped."""
dropped: dict[str, dict[str, Any]] = {}
for marker in config.get("markers") or []:
marker_id = str(marker.get("id"))
if marker.get("removed") is True or marker_id not in self.book.data:
@@ -333,56 +371,75 @@ class TrailRecorder:
vacuum = marker.get("vacuum") or {}
routes = effective_routes(
marker_id, vacuum, str(marker.get("space") or ""), vacuum.get("source"))
self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes})
orphan_ids = set(self.book.data) - live_marker_ids
if not orphan_ids:
return 0
before = {
slot: run for slot, run in (self.book.data.get(marker_id) or {}).items()
if slot in ("current", "previous")
}
if self.book.drop_unknown_routes(marker_id, {str(r.get("id")) for r in routes}):
after = self.book.data.get(marker_id) or {}
dropped[marker_id] = {
slot: run for slot, run in before.items() if slot not in after
}
return dropped
def _restore_dropped(self, dropped: dict[str, dict[str, Any]]) -> None:
for marker_id, runs in dropped.items():
self.book.data.setdefault(marker_id, {}).update(runs)
async def _save_now_locked(self) -> None:
"""Write the book immediately, superseding a pending debounced write.
A failed write puts the debounce back: the points it was going to
persist are still only in memory.
"""
had_pending_save = self._unsub_save is not None
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
try:
return await self._async_delete_many(orphan_ids)
except Exception: # noqa: BLE001 — config commit already succeeded
_LOGGER.exception(
"House Plan: removing orphan vacuum trails failed: markers=%s",
sorted(orphan_ids),
)
return 0
await self.store.async_save(self.book.data)
except Exception:
if had_pending_save:
self._schedule_save()
raise
async def _async_delete_many(self, markers: set[str]) -> int:
"""Delete one or more books with one subscription/store transaction."""
async with self._refresh_lock:
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = {
marker: self.book.data.pop(marker)
for marker in markers
if marker in self.book.data
}
if not removed:
return 0
previous_pairs = {src: list(pairs) for src, pairs in self.pairs.items()}
had_pending_save = self._unsub_save is not None
try:
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] not in removed]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
self._resubscribe()
if self._unsub_save:
self._unsub_save()
self._unsub_save = None
await self.store.async_save(self.book.data)
except Exception:
# The store is the durable authority. Restore the in-memory
# owner graph so the next successful config sync can retry
# instead of leaving an orphan on disk forever (#335).
self.book.data.update(removed)
self.pairs = previous_pairs
self._resubscribe()
if had_pending_save:
self._schedule_save()
raise
self.hass.bus.async_fire("houseplan_trail_updated", {})
removed = await self._delete_many_locked(markers)
if removed:
self.hass.bus.async_fire("houseplan_trail_updated", {})
return removed
async def _delete_many_locked(self, markers: set[str]) -> int:
"""The transaction of ``_async_delete_many``; the caller holds the lock."""
# The trail book owns deletion. When it has no such marker, this
# is a no-op and must not silently damage the live tracking graph.
removed = {
marker: self.book.data.pop(marker)
for marker in markers
if marker in self.book.data
}
if not removed:
return 0
previous_pairs = {src: list(pairs) for src, pairs in self.pairs.items()}
try:
for src in list(self.pairs):
kept = [pair for pair in self.pairs[src] if pair[0] not in removed]
if kept:
self.pairs[src] = kept
else:
del self.pairs[src]
self._resubscribe()
await self._save_now_locked()
except Exception:
# The store is the durable authority. Restore the in-memory
# owner graph so the next successful config sync can retry
# instead of leaving an orphan on disk forever (#335).
self.book.data.update(removed)
self.pairs = previous_pairs
self._resubscribe()
raise
return len(removed)
def _resubscribe(self) -> None:
+189 -2
View File
@@ -180,7 +180,11 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
if old_model >= 10 and new_model < 10 and any(
stored_drafts = any(
"room_drafts" in space for space in previous.get("spaces") or []
if isinstance(space, dict)
)
if old_model >= 10 and not stored_drafts and any(
"room_drafts" in space for space in config.get("spaces") or []
if isinstance(space, dict)
):
@@ -188,6 +192,15 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
# projection at all. The old catalogue guard therefore cannot see the
# destructive downgrade, but v10 must never persist the removed
# carrier again (#478 AC2).
#
# #529: the submitted model number is not the tell. A stale card
# echoes back the `model_version` it was given — the current one — so
# the old `new_model < 10` condition let exactly the outdated client
# through and the user met an unrelated "conflicting wall identifiers"
# instead of "update the card and reload the page". What identifies the
# stale writer is the carrier itself appearing over a stored plan that
# does not have it; legacy left in storage is not a stale writer and is
# healed by the migration instead (#529 K4).
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
)
@@ -1101,6 +1114,15 @@ def _finite(value):
return f
def _finite_number_or_none(value):
"""A persisted optional number: do not coerce strings or booleans."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise vol.Invalid("temperature threshold must be a finite number or null")
return _finite(value)
# Persisted colours deliberately use one small, browser-independent format.
# Keep this exact contract in sync with src/color.ts.
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
@@ -1280,6 +1302,8 @@ ROOM_SCHEMA = vol.All(
vol.Optional("glow"): vol.Any(bool, None),
vol.Optional("temp_source"): vol.Any(str, None),
vol.Optional("hum_source"): vol.Any(str, None),
vol.Optional("temp_min"): _finite_number_or_none,
vol.Optional("temp_max"): _finite_number_or_none,
vol.Optional("name_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
vol.Optional("label_scale"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0.5, max=3))),
},
@@ -1803,6 +1827,10 @@ MARKER_SCHEMA = vol.Schema(
),
}),
),
# #485: kept structurally open here so untouched future versions and
# unknown siblings survive. validate_marker_radars performs the strict,
# change-aware semantic validation at every write/import seam.
vol.Optional("radar"): vol.Any(None, vol.Schema({}, extra=vol.ALLOW_EXTRA)),
vol.Optional("controls"): vol.Any(None, vol.All([_TEXT], vol.Length(max=MAX_CONTROLS))),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("glow_color"): vol.Any(
@@ -1888,7 +1916,11 @@ def _config_wall_segment_invariants(value: dict) -> dict:
if model >= 9:
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
raise vol.Invalid("v9 config must not contain legacy open boundaries")
if model >= 10 and "room_drafts" in space:
# #529: пустой ключ данных не несёт и записи не стоит. Непустой
# остаётся отказом — это последняя линия против устаревшего клиента,
# и к ней человек приходит уже с понятным сообщением от сторожа
# переходов.
if model >= 10 and space.get("room_drafts"):
raise vol.Invalid("v10 config must not contain room_drafts")
segments = space.get("wall_segments")
if segments is None:
@@ -1981,6 +2013,159 @@ def _config_wall_segment_invariants(value: dict) -> dict:
return value
# #437: the summary is shared configuration, so its wire shape is validated by
# the integration rather than trusted to whichever browser happened to save it.
def _summary_text(max_length: int):
"""Trim user text and reject whitespace-only/raw oversized WS values."""
def validate(value: object) -> str:
if not isinstance(value, str):
raise vol.Invalid("summary text must be a string")
text = value.strip()
if not text or len(text) > max_length:
raise vol.Invalid("summary text length is invalid")
return text
return validate
_SUMMARY_ID = _summary_text(64)
_SUMMARY_TITLE = _summary_text(48)
_SUMMARY_LABEL = _summary_text(64)
_SUMMARY_SCOPE = vol.Any(
vol.Schema({vol.Required("type"): vol.Equal("all")}, extra=vol.ALLOW_EXTRA),
vol.Schema({
vol.Required("type"): vol.Equal("space"),
vol.Required("space_id"): _SUMMARY_ID,
}, extra=vol.ALLOW_EXTRA),
)
_SUMMARY_SOURCE = vol.Any(
vol.Schema({
vol.Required("type"): vol.Equal("system"),
vol.Required("key"): vol.In(["device_count", "total_area", "datetime"]),
}, extra=vol.ALLOW_EXTRA),
vol.Schema({
vol.Required("type"): vol.Equal("entity"),
vol.Required("entity_id"): _summary_text(500),
}, extra=vol.ALLOW_EXTRA),
)
_SUMMARY_VALUE_SCHEMA = vol.Schema({
vol.Required("id"): _SUMMARY_ID,
vol.Required("label"): _SUMMARY_LABEL,
vol.Required("source"): _SUMMARY_SOURCE,
}, extra=vol.ALLOW_EXTRA)
_SUMMARY_BLOCK_SCHEMA = vol.Schema({
vol.Required("id"): _SUMMARY_ID,
vol.Required("title"): _SUMMARY_TITLE,
vol.Required("visible"): bool,
vol.Required("scope"): _SUMMARY_SCOPE,
vol.Required("values"): vol.All([_SUMMARY_VALUE_SCHEMA], vol.Length(max=20)),
}, extra=vol.ALLOW_EXTRA)
def _summary_panel_invariants(value: dict) -> dict:
"""Stable block/value ids are the reference identity used by the editor."""
block_ids: set[str] = set()
value_ids: set[str] = set()
for block in value.get("blocks", []):
block_id = block["id"]
if block_id in block_ids:
raise vol.Invalid("summary block ids must be unique")
block_ids.add(block_id)
for item in block.get("values", []):
value_id = item["id"]
if value_id in value_ids:
raise vol.Invalid("summary value ids must be unique")
value_ids.add(value_id)
return value
SUMMARY_PANEL_SCHEMA = vol.All(vol.Schema({
vol.Required("version"): vol.Equal(1),
vol.Required("title"): _SUMMARY_TITLE,
vol.Required("show_on_mobile"): bool,
vol.Required("blocks"): vol.All([_SUMMARY_BLOCK_SCHEMA], vol.Length(max=10)),
}, extra=vol.ALLOW_EXTRA), _summary_panel_invariants)
def _future_summary_panel(value: object) -> dict:
"""Keep a newer opaque namespace losslessly, with a bounded data envelope."""
if not isinstance(value, dict):
raise vol.Invalid("summary panel must be an object")
version = value.get("version")
if not isinstance(version, int) or isinstance(version, bool) or version <= 1:
raise vol.Invalid("unsupported summary panel version")
if len(json.dumps(value, separators=(",", ":"), ensure_ascii=False)) > 128 * 1024:
raise vol.Invalid("summary panel is too large")
return value
SUMMARY_PANEL_WIRE_SCHEMA = vol.Any(SUMMARY_PANEL_SCHEMA, _future_summary_panel)
def preserve_summary_panel_namespace(config: dict, previous: dict | None) -> None:
"""A stale client that does not know #437 cannot erase the shared panel."""
old_settings = (previous or {}).get("settings")
new_settings = config.get("settings")
if not isinstance(old_settings, dict) or "summary_panel" not in old_settings:
return
if not isinstance(new_settings, dict):
new_settings = {}
config["settings"] = new_settings
if "summary_panel" not in new_settings:
new_settings["summary_panel"] = copy.deepcopy(old_settings["summary_panel"])
def validate_summary_panel_references(
config: dict, previous: dict | None, readable_entity_ids: set[str]
) -> None:
"""Only newly introduced broken refs fail; stable-id legacy refs warn in UI."""
panel = (config.get("settings") or {}).get("summary_panel")
if not isinstance(panel, dict) or panel.get("version") != 1:
return
old_panel = ((previous or {}).get("settings") or {}).get("summary_panel")
old_blocks = {
block.get("id"): block for block in (old_panel or {}).get("blocks", [])
if isinstance(block, dict) and block.get("id")
} if isinstance(old_panel, dict) and old_panel.get("version") == 1 else {}
old_values = {
item.get("id"): item
for block in old_blocks.values()
for item in block.get("values", [])
if isinstance(item, dict) and item.get("id")
}
spaces = {space.get("id") for space in config.get("spaces", []) if isinstance(space, dict)}
for block in panel.get("blocks", []):
scope = block.get("scope") or {}
if scope.get("type") == "space" and scope.get("space_id") not in spaces:
old_scope = (old_blocks.get(block.get("id"), {}).get("scope") or {})
if old_scope.get("type") != "space" or old_scope.get("space_id") != scope.get("space_id"):
raise vol.Invalid("new summary space reference must exist")
for item in block.get("values", []):
source = item.get("source") or {}
entity_id = source.get("entity_id") if source.get("type") == "entity" else None
if entity_id and entity_id not in readable_entity_ids:
old_source = (old_values.get(item.get("id"), {}).get("source") or {})
if old_source.get("type") != "entity" or old_source.get("entity_id") != entity_id:
raise vol.Invalid("new summary entity reference must be readable")
def prepare_ordinary_summary_candidate(
candidate: dict,
previous: dict | None,
readable_entity_ids: set[str],
normalize,
) -> dict:
"""Apply the one summary-panel 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.
"""
preserve_summary_panel_namespace(candidate, previous)
checked = normalize(candidate)
validate_summary_panel_references(checked, previous, readable_entity_ids)
return checked
CONFIG_SCHEMA = vol.All(
vol.Schema(
{
@@ -2011,6 +2196,8 @@ CONFIG_SCHEMA = vol.All(
vol.Optional("bg_mode"): _BG_MODE,
vol.Optional("sun_rays"): bool,
vol.Optional("show_room_tooltip"): bool,
vol.Optional("summary_panel"): SUMMARY_PANEL_WIRE_SCHEMA,
vol.Optional("radar"): vol.Schema({}, extra=vol.ALLOW_EXTRA),
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
# legacy field so an existing stored config can still load; the
# frontend ignores it and removes it on the next settings save.
@@ -644,14 +644,21 @@ def _host_openings(
opening.pop("host", None)
def _migrate_room_drafts_to_partitions(
space: dict[str, Any], initial_migration: bool,
) -> tuple[int, int]:
def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> tuple[int, int]:
if "room_drafts" not in space:
return 0, 0
if not initial_migration:
raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")
drafts = space.get("room_drafts") or []
# #529: наследие снимается, а не запирает план. Отказ здесь стоил
# пользователю всего: карточка считает ту же миграцию своим зеркалом, так
# что структурная правка отбивалась ещё до отправки, «Optimize plans» на
# эту ветку не влияет, а экспорт зовёт эту же функцию — забрать бэкап и
# починить файл руками тоже было нельзя. Защита от устаревшего клиента
# (#478) переехала на свой слой: `validate_wall_model_transition` ловит
# заявку с черновиками поверх чистого сохранённого конфига и отвечает
# человеку «обновите карточку», а не загадкой про идентификаторы.
if not drafts:
space.pop("room_drafts", None)
return 0, 0
used = {
str(item["id"])
for name in ("rooms", "openings", "decor", "partitions",
@@ -709,9 +716,7 @@ def _migrate_room_drafts_to_partitions(
def _migrate_space(
space: dict[str, Any], initial_migration: bool,
) -> tuple[int, int, int]:
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(
space, initial_migration,
)
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(space)
old: dict[str, dict[str, Any]] = {}
for segment in space.get("wall_segments") or []:
segment_id = str(segment.get("id", ""))
+261 -98
View File
@@ -42,6 +42,7 @@ from .const import (
PLAN_MODEL_VERSION,
PLANS_DIR,
PLANS_URL,
SUMMARY_PANEL_API_VERSION,
SUPPORT_API_VERSION,
SUPPORT_PREVIEW_TTL_S,
VERSION,
@@ -80,10 +81,18 @@ from .plans import (
reserve_filename,
)
from .projection import project_config, project_layout
from .radar_validation import (
RadarValidationError,
radar_registry_evidence,
validate_marker_radars,
)
from .registry_snapshot import import_registry_snapshot
from .store import (
LAYOUT_STORE_CORE_KEYS,
HouseplanData,
ResolvedStorePair,
async_converge_store_pair,
async_resolve_pending_pair,
async_save_config_state,
async_save_layout_state,
get_data,
@@ -109,6 +118,7 @@ from .validation import (
PartitionOpeningHostError,
PartitionOpeningJambMarginError,
WallModelClientOutdatedError,
prepare_ordinary_summary_candidate,
sanitize_filename,
valid_space_id,
validate_marker_controls,
@@ -188,9 +198,51 @@ async def _discard_optimizer_snapshot(rt: HouseplanData) -> None:
)
class PairCommitFailure(Exception):
"""A paired write failed after restoring, or while recovery stayed durable."""
def __init__(self, *, recovery_pending: bool) -> None:
super().__init__("paired store commit failed")
self.recovery_pending = recovery_pending
async def _resolved_write_pair(
hass: HomeAssistant,
connection,
msg_id: int,
rt: HouseplanData,
) -> ResolvedStorePair | None:
"""Resolve an older pair before a writer reads revisions or state."""
try:
resolved = await async_resolve_pending_pair(rt)
except Exception: # noqa: BLE001 - the pending intent must remain authoritative
_LOGGER.exception("House Plan: an interrupted paired write is still pending recovery")
connection.send_error(
msg_id,
"commit_failed",
"A previous House Plan save is pending recovery; retry or restart Home Assistant",
)
return None
if resolved.recovered_kind is not None:
# Both durable halves are complete before either event is observable.
hass.bus.async_fire(
"houseplan_config_updated", {"rev": int(resolved.config_data.get("rev", 0))}
)
hass.bus.async_fire(
"houseplan_layout_updated", {"rev": int(resolved.layout_data.get("rev", 0))}
)
_LOGGER.warning(
"House Plan: completed an interrupted %s before the next write",
resolved.recovered_kind.replace("_", " "),
)
return resolved
@callback
def async_register(hass: HomeAssistant) -> None:
"""Register the WS commands."""
from .radar_websocket import async_register as async_register_radar
websocket_api.async_register_command(hass, ws_layout_get)
websocket_api.async_register_command(hass, ws_trail_get)
websocket_api.async_register_command(hass, ws_trail_delete)
@@ -219,6 +271,7 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_support_preview)
websocket_api.async_register_command(hass, ws_support_preview_discard)
websocket_api.async_register_command(hass, ws_support_submit)
async_register_radar(hass)
def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | None:
@@ -325,23 +378,10 @@ async def _persist_pair_intent(
async def _converge_pair(rt: HouseplanData, pending: dict[str, Any]) -> None:
"""Write both target halves and remove the durable intent last."""
await async_save_config_state(
rt,
pending["config"],
int(pending["config_rev"]),
)
stored = await rt.store.async_load() or {}
await async_save_layout_state(
rt,
stored,
pending["layout"],
int(pending["layout_rev"]),
metadata=dict(pending.get("final_metadata") or {}),
replace_metadata=True,
)
await async_converge_store_pair(rt, pending)
async def _commit_import_pair(
async def _commit_pair(
rt: HouseplanData,
pending: dict[str, Any],
rollback: dict[str, Any],
@@ -359,24 +399,22 @@ async def _commit_import_pair(
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - one retry handles fail-after-write too
_LOGGER.warning("House Plan import pair write failed; retrying target", exc_info=True)
_LOGGER.warning("House Plan pair write failed; retrying target", exc_info=True)
try:
await _persist_pair_intent(rt, pending)
await _converge_pair(rt, pending)
return
except Exception: # noqa: BLE001 - target is no longer the recovery policy
_LOGGER.exception("House Plan import target retry failed; restoring previous pair")
_LOGGER.exception("House Plan target retry failed; restoring previous pair")
try:
await _persist_pair_intent(rt, rollback)
await _converge_pair(rt, rollback)
except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error
_LOGGER.exception(
"House Plan import rollback could not finish; rollback intent remains for setup"
"House Plan rollback could not finish; rollback intent remains for recovery"
)
raise ImportFailure(
"commit_failed", "Import failed; the previous plan is pending recovery"
) from rollback_error
raise ImportFailure("commit_failed", "Import failed and the previous plan was restored")
raise PairCommitFailure(recovery_pending=True) from rollback_error
raise PairCommitFailure(recovery_pending=False)
# ---------------- portable backup / transfer ----------------
@@ -498,8 +536,11 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
try:
async with rt.write_lock:
candidate = get_candidate(rt, msg["token"], _connection_user_id(connection))
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
config_data = resolved.config_data
layout_data = resolved.layout_data
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (
@@ -588,12 +629,24 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
# A token becomes single-use only after both durable halves land.
get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)
await _commit_pair(rt, pending, rollback)
# Both halves are durable: the token is spent no matter what
# happened to the preview registry meanwhile. Re-validating it
# here (TTL lapsed during the write, eviction by a newer preview
# of the same user) would report a failure for a plan that is
# already replaced — the outcome follows the commit (#495).
rt.import_previews.pop(msg["token"], None)
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
except PairCommitFailure as err:
message = (
"Import failed; the previous plan is pending recovery"
if err.recovery_pending
else "Import failed and the previous plan was restored"
)
connection.send_error(msg["id"], "commit_failed", message)
return
except Exception: # noqa: BLE001 - defensive: a listener must never break the write path
_LOGGER.exception("House Plan import commit failed")
connection.send_error(msg["id"], "commit_failed", "Import commit failed")
@@ -687,8 +740,11 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
if rt is None:
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
data = await rt.store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
config_data = resolved.config_data
data = resolved.layout_data
current_rev = int(data.get("rev", 0))
if "expected_rev" not in msg and current_rev:
# #356: without the revision the client read, a wholesale write is
@@ -746,7 +802,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
# the marker. Its tombstone is the server-side authority: acknowledge
# but ignore the late point so re-adding starts without a zombie
# position.
config_data = await rt.config_store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
config_data = resolved.config_data
config = config_data.get("config") or {}
markers = config.get("markers") or []
deleted = any(
@@ -768,14 +827,14 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
and not live_explicit
)
if deleted or orphan_virtual:
data = await rt.store.async_load() or {}
data = resolved.layout_data
connection.send_result(msg["id"], {
"ok": True,
"ignored": "removed" if deleted else "missing_virtual",
"rev": int(data.get("rev", 0)),
})
return
data = await rt.store.async_load() or {}
data = resolved.layout_data
layout = data.get("layout", {})
if layout.get(msg["device_id"]) == msg["pos"]:
connection.send_result(msg["id"], {"ok": True, "rev": int(data.get("rev", 0))})
@@ -830,7 +889,10 @@ async def ws_geometry_repair(hass: HomeAssistant, connection, msg: dict[str, Any
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
return
async with rt.write_lock:
data = await rt.store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
data = resolved.layout_data
layout = data.get("layout") or {}
current_rev = int(data.get("rev", 0))
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
@@ -1311,7 +1373,10 @@ async def ws_layout_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
return
new_rev: int | None = None
async with rt.write_lock:
data = await rt.store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
data = resolved.layout_data
layout = data.get("layout", {})
if msg["device_id"] in layout:
del layout[msg["device_id"]]
@@ -1394,6 +1459,8 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# #423: protocol capability is independent from release skew.
"support_api": SUPPORT_API_VERSION,
"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 {}),
},
)
@@ -1499,6 +1566,22 @@ def _missing_internal_attachments(
}
def _readable_entity_ids(hass: HomeAssistant, connection) -> set[str]:
"""Snapshot entities readable by this writer on the event loop."""
user = getattr(connection, "user", None)
readable: set[str] = set()
for state in hass.states.async_all():
try:
if user and (
getattr(user, "is_admin", False)
or user.permissions.check_entity(state.entity_id, "read")
):
readable.add(state.entity_id)
except Exception: # noqa: BLE001 - denied/unknown is unreadable
continue
return readable
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/config/set",
@@ -1533,7 +1616,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
)
return
async with rt.write_lock:
data = await rt.config_store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
data = resolved.config_data
current_rev = data.get("rev", 0)
if "expected_rev" not in msg and current_rev:
# #340: a request without the revision it read is indistinguishable
@@ -1570,14 +1656,25 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
# cached by rev, so a repeated write does not re-judge `previous`.
baseline = rt.junction_baseline
baseline_counts = baseline[1] if baseline and baseline[0] == current_rev else None
readable_entity_ids = _readable_entity_ids(hass, connection)
radar_registry = radar_registry_evidence(hass)
def _validate_config_cpu():
validate_wall_model_transition(msg["config"], data.get("config"))
checked = CONFIG_SCHEMA(msg["config"])
def _normalize(candidate):
validate_wall_model_transition(candidate, data.get("config"))
return CONFIG_SCHEMA(candidate)
checked = prepare_ordinary_summary_candidate(
msg["config"], data.get("config"), readable_entity_ids, _normalize,
)
msg["config"].clear()
msg["config"].update(checked)
validate_marker_controls(msg["config"], data.get("config"))
validate_marker_light_entities(msg["config"], data.get("config"))
validate_marker_radars(
msg["config"], data.get("config"), registry=radar_registry,
)
validate_marker_value_badges(msg["config"], data.get("config"))
validate_marker_vacuum_routes(msg["config"], data.get("config"))
validate_opening_passages(msg["config"], data.get("config"))
@@ -1595,6 +1692,9 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except RadarValidationError as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
@@ -1784,8 +1884,11 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
try:
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
config_data = resolved.config_data
layout_data = resolved.layout_data
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if (msg["expected_config_rev"] != config_rev
@@ -1837,7 +1940,15 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
await _commit_import_pair(rt, pending, rollback)
await _commit_pair(rt, pending, rollback)
except PairCommitFailure as err:
message = (
"Space delete failed; the previous plan is pending recovery"
if err.recovery_pending
else "Space delete failed and the previous plan was restored"
)
connection.send_error(msg["id"], "commit_failed", message)
return
except ImportFailure as err:
_send_import_error(connection, msg["id"], err)
return
@@ -1893,8 +2004,11 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
return
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
config_data = resolved.config_data
layout_data = resolved.layout_data
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
@@ -1904,32 +2018,41 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
)
return
readable_entity_ids = _readable_entity_ids(hass, connection)
# Optimization is a normal configuration write with an additional
# layout transaction. It must enforce the same marker-link semantics
# as config/set; otherwise a crafted client can persist a new cycle.
# #330 §4.1: the same executor treatment as config/set — Optimize
# carries schema + a possible full migration, the costliest CPU path
# of all writers, and it used to run on the event loop.
optimize_radar_registry = radar_registry_evidence(hass)
def _validate_optimize_cpu():
validate_wall_model_transition(msg["config"], config_data.get("config"))
try:
submitted_model = int(msg["config"].get("model_version", 0) or 0)
except (TypeError, ValueError):
submitted_model = WALL_SEGMENT_MODEL_VERSION
# Optimize is an explicit structural writer and therefore the
# server-side v7 -> v8 barrier as well. Current v8 candidates are
# validated verbatim: the backend must never invent lineage for a
# graph already authored by a current client.
candidate_config = CONFIG_SCHEMA(msg["config"])
# Keep the established public validation contract ahead of the
# structural migration barrier. A malformed passage can also make
# wall-host materialisation impossible, but callers must still get
# the actionable `invalid_passage_fields` code rather than the
# generic wall-model blocker.
validate_opening_passages(candidate_config, config_data.get("config"))
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
candidate_config, _ = commit_wall_segment_model(candidate_config)
checked = CONFIG_SCHEMA(candidate_config)
def _normalize(candidate):
validate_wall_model_transition(candidate, config_data.get("config"))
try:
submitted_model = int(candidate.get("model_version", 0) or 0)
except (TypeError, ValueError):
submitted_model = WALL_SEGMENT_MODEL_VERSION
# Optimize is an explicit structural writer and therefore the
# server-side v7 -> v8 barrier as well. Current v8 candidates are
# validated verbatim: the backend must never invent lineage for a
# graph already authored by a current client.
candidate_config = CONFIG_SCHEMA(candidate)
# Keep the established public validation contract ahead of the
# structural migration barrier. A malformed passage can also make
# wall-host materialisation impossible, but callers must still get
# the actionable `invalid_passage_fields` code rather than the
# generic wall-model blocker.
validate_opening_passages(candidate_config, config_data.get("config"))
if submitted_model < WALL_SEGMENT_MODEL_VERSION:
candidate_config, _ = commit_wall_segment_model(candidate_config)
return CONFIG_SCHEMA(candidate_config)
checked = prepare_ordinary_summary_candidate(
msg["config"], config_data.get("config"), readable_entity_ids, _normalize,
)
migrated_size = len(json.dumps(checked, separators=(",", ":")))
if migrated_size > MAX_CONFIG_BYTES:
return None, migrated_size
@@ -1937,6 +2060,10 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
msg["config"].update(checked)
validate_marker_controls(msg["config"], config_data.get("config"))
validate_marker_light_entities(msg["config"], config_data.get("config"))
validate_marker_radars(
msg["config"], config_data.get("config"),
registry=optimize_radar_registry,
)
validate_marker_value_badges(msg["config"], config_data.get("config"))
validate_marker_vacuum_routes(msg["config"], config_data.get("config"))
validate_opening_passages(msg["config"], config_data.get("config"))
@@ -1973,6 +2100,9 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
) as err:
connection.send_error(msg["id"], err.code, str(err))
return
except RadarValidationError as err:
connection.send_error(msg["id"], err.code, str(err))
return
except vol.Invalid as err:
connection.send_error(msg["id"], "invalid_format", str(err))
return
@@ -2004,36 +2134,50 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
"after_config_rev": new_config_rev,
"after_layout_rev": new_layout_rev,
}
original_metadata = _layout_metadata(layout_data)
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {
_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING,
"repair_backup", "geom_pending",
}
}
final_metadata[_OPTIMIZE_BACKUP] = backup
pending = {
"kind": "optimize",
"config": canonicalize_config_geometry(msg["config"]),
"layout": canonicalize_layout_geometry(msg["layout"]),
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": False,
"final_metadata": final_metadata,
}
# Intent first. A setup-time finisher completes whichever half a crash
# interrupted; until then the visible layout/revision remain unchanged.
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await async_save_config_state(
rt,
msg["config"],
new_config_rev,
previous_rev=config_rev,
)
rollback = {
"kind": "optimize_rollback",
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
try:
await _commit_pair(rt, pending, rollback)
except PairCommitFailure as err:
message = (
"Plan optimization failed; the previous plan is pending recovery"
if err.recovery_pending
else "Plan optimization failed and the previous plan was restored"
)
connection.send_error(msg["id"], "commit_failed", message)
return
# The optimized candidate is now the stored document: its junction
# counts are the next write's baseline (#333 AC3, symmetric with
# config/set — otherwise the next save re-judges `previous` for
# nothing and the #330 cache loses its point).
rt.junction_baseline = (int(new_config_rev), optimize_counts or {})
await async_save_layout_state(
rt, layout_data, msg["layout"], new_layout_rev,
metadata={_OPTIMIZE_BACKUP: backup},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup", "geom_pending"),
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
@@ -2065,8 +2209,11 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
restored_kind = "optimize"
async with rt.write_lock:
config_data = await rt.config_store.async_load() or {}
layout_data = await rt.store.async_load() or {}
resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)
if resolved is None:
return
config_data = resolved.config_data
layout_data = resolved.layout_data
config_rev = int(config_data.get("rev", 0))
layout_rev = int(layout_data.get("rev", 0))
if msg["expected_config_rev"] != config_rev or msg["expected_layout_rev"] != layout_rev:
@@ -2089,29 +2236,45 @@ async def ws_plan_optimize_undo(hass: HomeAssistant, connection, msg: dict[str,
)
new_config_rev = config_rev + 1
new_layout_rev = layout_rev + 1
original_metadata = _layout_metadata(layout_data)
final_metadata = {
key: value for key, value in original_metadata.items()
if key not in {_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"}
}
pending = {
"kind": "import_undo" if restored_kind == "import" else "optimize_undo",
"config": restored_config,
"layout": restored_layout,
"config_rev": new_config_rev,
"layout_rev": new_layout_rev,
"clear_backup": True,
"final_metadata": final_metadata,
}
await async_save_layout_state(
rt, layout_data, layout_data.get("layout", {}), layout_rev,
metadata={_OPTIMIZE_BACKUP: backup, _OPTIMIZE_PENDING: pending},
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING),
)
await async_save_config_state(
rt,
restored_config,
new_config_rev,
previous_rev=config_rev,
)
await async_save_layout_state(
rt, layout_data, restored_layout, new_layout_rev,
remove=(_OPTIMIZE_BACKUP, _OPTIMIZE_PENDING, "repair_backup"),
)
rollback = {
"kind": (
"import_undo_rollback"
if restored_kind == "import"
else "optimize_undo_rollback"
),
"config": canonicalize_config_geometry(
config_data.get("config") or DEFAULT_CONFIG
),
"layout": canonicalize_layout_geometry(
layout_data.get("layout") or {}
),
"config_rev": config_rev,
"layout_rev": layout_rev,
"final_metadata": original_metadata,
}
try:
await _commit_pair(rt, pending, rollback)
except PairCommitFailure as err:
message = (
"Plan undo failed; the previous plan is pending recovery"
if err.recovery_pending
else "Plan undo failed and the previous plan was restored"
)
connection.send_error(msg["id"], "commit_failed", message)
return
hass.bus.async_fire("houseplan_config_updated", {"rev": new_config_rev})
hass.bus.async_fire("houseplan_layout_updated", {"rev": new_layout_rev})
+54 -1
View File
@@ -294,6 +294,41 @@ try {
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
// #520 diagnostics: where the extra half second of model readiness goes.
// Counts Lit update cycles and model builds; printed, never budgeted.
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
// what rebuilds the model a second time (#520).
let diagEpoch = 0;
Object.defineProperty(card, '_cfgEpoch', {
configurable: true,
get: () => diagEpoch,
set: (next) => {
if (next !== diagEpoch) {
const frames = (new Error().stack || '').split('\n').slice(1, 4)
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
}
diagEpoch = next;
},
});
const diagPerform = card.performUpdate.bind(card);
card.performUpdate = function () {
const started = performance.now();
const result = diagPerform();
card.__diag.updates += 1;
card.__diag.updateMs += performance.now() - started;
return result;
};
const diagBuild = card._buildModel.bind(card);
card._buildModel = function () { card.__diag.models += 1; return diagBuild(); };
if (typeof card._adoptStructuralResponses === 'function') {
const legacy = card._adoptStructuralResponses.bind(card);
card._adoptStructuralResponses = function (...args) { card.__diag.adopts += 1; return legacy(...args); };
} else if (typeof card._adoptAuthoritative === 'function') {
const gated = card._adoptAuthoritative.bind(card);
card._adoptAuthoritative = function (...args) { card.__diag.adopts += 1; return gated(...args); };
}
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
@@ -363,6 +398,14 @@ try {
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const bootDiag = {
updates: card.__diag.updates,
updateMs: Number(card.__diag.updateMs.toFixed(1)),
models: card.__diag.models,
adopts: card.__diag.adopts,
cfgEpoch: card._cfgEpoch,
epochs: card.__diag.epochs.slice(0, 8),
};
const initialProjection = typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null;
if (requiresIsometric && initialProjection !== 'iso')
@@ -1096,6 +1139,7 @@ try {
const result = {
sample,
bootDiag,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
@@ -1165,7 +1209,16 @@ try {
interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, profile,
});
if (measuredSample >= 0) rows.push(row);
// #520: диагностика печатается в лог прогона и в запись не попадает.
const { bootDiag, ...measured } = row;
if (bootDiag) {
console.log(`#520 diag sample ${row.sample}: updates=${bootDiag.updates}`
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
}
if (measuredSample >= 0) rows.push(measured);
}
} finally {
await browser.close();
+317
View File
@@ -0,0 +1,317 @@
/** Explicit #505 diagnostic evidence, not a golden-baseline producer or smoke. */
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { HA_DIALOG_PIN, prepareHaDialogAssets } from './helpers/ha-dialog-assets.mjs';
import { launchHaDialogFixture } from './helpers/ha-dialog-fixture.mjs';
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const options = { output: join(repo, 'artifacts/summary-panel-505') };
for (let index = 2; index < process.argv.length; index++) {
const arg = process.argv[index];
if (arg === '--probe-only') options.probeOnly = true;
else if (['--output', '--ha-cache', '--ha-wheel', '--only'].includes(arg)) {
const value = process.argv[++index];
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
if (arg === '--only') options.only = value.split(',');
else options[{ '--output': 'output', '--ha-cache': 'cacheDir', '--ha-wheel': 'wheelPath' }[arg]] = resolve(value);
} else throw new Error(`Unknown option ${arg}`);
}
// Fail before the optional 124 MB download. Captures never bypass freshness.
const fingerprint = sourceFingerprint(repo);
if (!options.probeOnly) {
const manifest = JSON.parse(readFileSync(join(repo, 'demo/srv/assets/houseplan-assets.json')));
assert.equal(manifest.fingerprint, fingerprint, 'Stale demo bundle: run npm run bundle:sync before capturing');
}
mkdirSync(options.output, { recursive: true });
const report = {
issue: 505, diagnosticOnly: true, platform: process.platform,
sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(),
sourceFingerprint: fingerprint, probeOnly: Boolean(options.probeOnly),
selectedCases: options.only || 'full matrix',
haFrontend: HA_DIALOG_PIN.version, wheelSha256: HA_DIALOG_PIN.sha256,
boundaries: 'Loopback synthetic demo only. No core/auth/HA server. Official app loader exposed at runtime; component factories/CSS unmodified.',
comparison: 'Equivalent one block, three default system rows and one entity row. Reference row text is normalized to observed product values; reference CSS/files are unchanged. Floor-plan/fake HA chrome pixels are not parity targets.',
captures: [],
};
const saveReport = () => writeFileSync(join(options.output, 'report.json'), JSON.stringify(report, null, 2));
const assets = await prepareHaDialogAssets(options);
async function probe() {
const fixture = await launchHaDialogFixture({ assets });
try {
const { page } = fixture;
report.chromium = fixture.browser.version();
await page.goto(`${fixture.url}/ha505-probe.html`);
await page.waitForFunction(() => document.querySelector('ha-dialog')?.shadowRoot?.querySelector('wa-dialog')?.shadowRoot?.querySelector('dialog')?.matches(':modal'));
await page.waitForTimeout(300);
const read = () => page.evaluate(() => {
const ha = document.querySelector('ha-dialog');
const wa = ha.shadowRoot.querySelector('wa-dialog');
const surface = wa.shadowRoot.querySelector('dialog');
return { width: surface.getBoundingClientRect().width, modal: surface.matches(':modal'),
radius: getComputedStyle(surface).borderRadius, preset: ha.width,
inheritedMd: getComputedStyle(ha).getPropertyValue('--ha-dialog-width-md') };
});
const small = await read();
await page.evaluate(async () => { const ha = document.querySelector('ha-dialog'); ha.width = 'medium'; await ha.updateComplete; });
await page.waitForTimeout(50);
const medium = await read();
await page.evaluate(() => document.querySelector('#parent').style.setProperty('--ha-dialog-width-md', '920px'));
await page.waitForTimeout(50);
const inherited = await read();
await page.screenshot({ path: join(options.output, 'authentic-ha-width-920.png') });
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(100);
const mobile = await read();
await page.screenshot({ path: join(options.output, 'authentic-ha-mobile-390.png') });
assert.equal(small.width, 320); assert.equal(medium.width, 580);
assert.equal(inherited.width, 920); assert.equal(mobile.width, 390);
await fixture.assertClean();
report.probe = { small, medium, inherited, mobile, provenance: fixture.provenance,
pageErrors: fixture.errors, externalRequests: fixture.externalRequests, websocketAttempts: fixture.websocketAttempts };
saveReport();
} finally { await fixture.close(); }
}
const rowLabels = ['Количество устройств', 'Общая площадь комнат', 'Текущие дата и время', 'Температура кухни'];
const productConfig = {
version: 1, title: 'Сводная информация', show_on_mobile: true,
blocks: [{ id: 'block-1', title: 'Общее', visible: true, scope: { type: 'all' },
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
source: index < 3 ? { type: 'system', key: ['device_count', 'total_area', 'datetime'][index] }
: { type: 'entity', entity_id: 'sensor.kitchen_temperature' } })) }],
};
const referenceConfig = { title: productConfig.title, showOnMobile: true, blocks: [{
...productConfig.blocks[0], scope: { type: 'all', spaceId: null },
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
source: index < 3 ? { type: 'system', key: ['device_count', 'room_area', 'date_time'][index], name: label }
: { type: 'entity', entityId: 'sensor.kitchen_temperature' } })),
}] };
const panelCases = [
{ name: 'desktop-light', width: 1600, height: 1000, theme: 'light' },
{ name: 'desktop-dark', width: 1600, height: 1000, theme: 'dark' },
{ name: 'bottom-portrait', width: 800, height: 1100, theme: 'light' },
{ name: 'kiosk', width: 1600, height: 1000, theme: 'light', kiosk: true },
{ name: 'mobile-view', width: 390, height: 844, theme: 'light' },
];
const settingCases = ['native', 'real-ha'].flatMap((shell) => [
{ name: 'desktop', width: 1600, height: 1000 },
{ name: 'narrow-320', width: 320, height: 844 },
{ name: 'narrow-390', width: 390, height: 844 },
].flatMap((size) => ['light', 'dark'].map((theme) => ({ ...size,
name: `settings-${shell}-${size.name}-${theme}`, theme, shell, settings: true }))));
const edgeCases = ['native', 'real-ha'].flatMap((shell) => [
{ name: `edge-${shell}-effective-zoom-200`, width: 800, height: 500,
adaptation: '800×500 effective viewport proxy for 1600×1000 at 200%; not actual browser zoom' },
{ name: `edge-${shell}-text-200-long-de`, width: 1600, height: 1000, textSize: 32, language: 'de',
adaptation: 'Root font-size 32px enlarged-text stress, long German entity name/id; not browser zoom' },
{ name: `edge-${shell}-text-200-long-de-390`, width: 390, height: 844, textSize: 32, language: 'de',
adaptation: '390px plus root font-size32px and long German source' },
].map((scenario) => ({ ...scenario, shell, settings: true, theme: 'light' })));
function scenarioConfig(scenario, reference = false) {
const config = structuredClone(reference ? referenceConfig : productConfig);
if (scenario.language === 'de') {
config.title = 'Zusammenfassung'; config.blocks[0].title = 'Allgemein';
const row = config.blocks[0].values[3];
row.label = 'Küchentemperatur und langfristiger Messwert';
row.source = { type: 'entity', [reference ? 'entityId' : 'entity_id']:
'sensor.kueche_erdgeschoss_langfristige_temperaturmessung_mit_besonders_langer_quellenkennung_505' };
}
return config;
}
async function configureProduct(page, scenario) {
await page.waitForFunction(() => window.__card?._summary && window.__card?._model?.length > 0);
await assertFreshDemoBundle(page, repo);
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading);
await page.evaluate(async ({ scenario, config }) => {
const card = window.__card;
Object.assign(document.querySelector('#host').style, { width: '100%', height: '100vh', padding: '0', margin: '0', maxWidth: 'none' });
Object.assign(document.body.style, { margin: '0', padding: '0' });
card.panelHost = true;
card.setAttribute('panel-host', '');
Object.assign(card.style, { width: '100%', height: '100%' });
card.setConfig({ ...card._config, language: scenario.language || 'ru', kiosk: Boolean(scenario.kiosk) });
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, summary_panel: config } };
card._cfgEpoch++;
card._haSummaryPanelApi = 1;
card._serverCanWrite = true;
card.narrow = scenario.width <= 450;
const dark = scenario.theme === 'dark';
const colors = dark ? ['#3ea6ff', '#e6e7eb', '#9aa4ad', '#202126', '#3a3d45', '#11151b', '#2b2d33']
: ['#0b73b8', '#202124', '#5f6368', '#ffffff', '#d7d9de', '#eef1f4', '#e7eaee'];
['primary-color', 'primary-text-color', 'secondary-text-color', 'card-background-color', 'divider-color', 'primary-background-color', 'secondary-background-color']
.forEach((key, index) => document.documentElement.style.setProperty(`--${key}`, colors[index]));
document.documentElement.style.setProperty('--ha-card-background', colors[3]);
document.documentElement.style.setProperty('--app-header-background-color', colors[3]);
document.documentElement.style.setProperty('--app-header-text-color', colors[1]);
document.documentElement.style.setProperty('--text-primary-color', '#ffffff');
document.documentElement.style.colorScheme = scenario.theme;
if (scenario.textSize) document.documentElement.style.fontSize = `${scenario.textSize}px`;
document.body.style.background = colors[5];
const entityId = config.blocks[0].values[3].source.entity_id;
card.hass = { ...card.hass, language: scenario.language || 'ru', locale: { ...card.hass.locale, language: scenario.language || 'ru' },
config: { ...card.hass.config, time_zone: 'Europe/Moscow', unit_system: { ...card.hass.config?.unit_system, length: 'm' } },
themes: { ...card.hass.themes, darkMode: dark },
states: { ...card.hass.states, [entityId]: { entity_id: entityId, state: '20.6', attributes: {
friendly_name: scenario.language === 'de' ? 'Küchentemperatur im Erdgeschoss mit langfristiger Messwertüberwachung und besonders langer Quellenbezeichnung' : 'Kitchen temperature', unit_of_measurement: '°C' } } },
};
card._summary.updated();
card._summary.saveLocal({ show: true });
card.requestUpdate();
await card.updateComplete;
card._summary.resized();
}, { scenario, config: scenarioConfig(scenario) });
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading && window.__card._devices.length > 0);
if (!scenario.settings) await page.locator('.summary-overlay').waitFor({ state: 'visible' });
await page.waitForTimeout(350);
if (scenario.settings) {
await page.evaluate(async () => {
const card = window.__card;
if (card._ensureEditorRuntime) assertRuntime(await card._ensureEditorRuntime());
function assertRuntime(ready) { if (!ready) throw new Error('Editor runtime failed to load'); }
await card._summary.openDialog();
await card.updateComplete;
});
await page.locator('hp-dialog[data-kind="summary"] .summary-editor').waitFor({ state: 'visible' });
await page.waitForTimeout(300);
}
}
async function capture(scenario) {
console.log(`Capturing ${scenario.name}`);
const fixture = await launchHaDialogFixture({ assets, authentic: scenario.shell === 'real-ha',
viewport: { width: scenario.width, height: scenario.height }, colorScheme: scenario.theme });
const record = { ...scenario, files: {} };
try {
const { page } = fixture;
// Let Date.now advance: the real card boot-veil quiescence uses it.
await page.clock.setSystemTime(new Date('2026-09-09T09:00:00Z'));
await page.goto(`${fixture.url}/product.html`);
await configureProduct(page, scenario);
const values = await page.evaluate((config) => config.blocks[0].values.map((value) => window.__card._summary.value(value)), scenarioConfig(scenario));
assert.equal(values.length, 4, 'Equivalent four-row product fixture');
record.product = await page.evaluate((settings) => {
const root = window.__card.shadowRoot;
const box = (node) => node ? { ...node.getBoundingClientRect().toJSON(), scrollWidth: node.scrollWidth, clientWidth: node.clientWidth } : null;
const panel = root.querySelector('.summary-overlay');
const hp = root.querySelector('hp-dialog[data-kind="summary"]');
const ha = hp?.shadowRoot.querySelector('ha-dialog');
const wa = ha?.shadowRoot.querySelector('wa-dialog');
const surface = wa?.shadowRoot.querySelector('dialog') || hp?.shadowRoot.querySelector('.surface');
const footer = hp?.querySelector('[slot="footer"]');
const editor = hp?.querySelector('.summary-editor');
const insideViewport = (node) => { const r = node?.getBoundingClientRect(); return !!r && r.left >= -1 && r.right <= innerWidth + 1 && r.top >= -1 && r.bottom <= innerHeight + 1; };
return { panel: box(panel), control: box(root.querySelector('.summary-control')), stage: box(root.querySelector('.stage')),
side: panel?.classList.contains('bottom') ? 'bottom' : 'right', rows: panel?.querySelectorAll('.summary-value').length,
settings: settings ? { surface: box(surface), editor: box(editor), footer: box(footer),
actualHA: Boolean(wa), modal: surface?.matches(':modal'), radius: getComputedStyle(surface).borderRadius,
border: getComputedStyle(surface).border, shadow: getComputedStyle(surface).boxShadow,
rootFont: getComputedStyle(document.documentElement).fontSize,
titleFont: getComputedStyle(hp.shadowRoot.querySelector('.title')).fontSize,
sourceRows: [...editor.querySelectorAll('.summary-source')].map((node) => ({ box: box(node), text: node.textContent.trim() })),
checks: { surfaceInsideViewport: insideViewport(surface), footerVisible: insideViewport(footer),
editorNoHorizontalOverflow: editor.scrollWidth <= editor.clientWidth + 1,
surfaceNoHorizontalOverflow: surface.scrollWidth <= surface.clientWidth + 1,
footerNoHorizontalOverflow: footer.scrollWidth <= footer.clientWidth + 1 },
close: box(ha?.shadowRoot.querySelector('ha-icon-button') || hp.shadowRoot.querySelector('.close')) } : null };
}, Boolean(scenario.settings));
const shot = async (side, area, locator) => {
const name = `${scenario.name}-${side}-${area}.png`;
if (locator) await locator.screenshot({ path: join(options.output, name) });
else await page.screenshot({ path: join(options.output, name) });
record.files[`${side}-${area}`] = name;
};
await shot('product', 'viewport');
if (scenario.settings) await shot('product', 'dialog', page.locator(scenario.shell === 'real-ha'
? 'hp-dialog[data-kind="summary"] ha-dialog wa-dialog dialog' : 'hp-dialog[data-kind="summary"] .surface'));
else {
await shot('product', 'panel', page.locator('.summary-overlay'));
await shot('product', 'control', page.locator('.summary-control'));
}
if (scenario.language === 'de') {
// The genuine HA owns scrolling in its shadow .body; the native wrapper
// owns it in the editor. Follow the composed tree instead of assuming.
const measureSource = async (align) => page.locator('.summary-source').last().evaluate((source, align) => {
const label = source.querySelector('strong');
(align === 'start' ? label : source).scrollIntoView({ block: align, inline: 'nearest', behavior: 'instant' });
const hp = source.closest('hp-dialog');
const ha = hp.shadowRoot.querySelector('ha-dialog');
const header = ha?.shadowRoot.querySelector('ha-dialog-header') || hp.shadowRoot.querySelector('.header');
const footer = hp.querySelector('[slot="footer"]');
let bandTop = Math.max(0, header?.getBoundingClientRect().bottom || 0);
let bandBottom = Math.min(innerHeight, footer.getBoundingClientRect().top);
const visible = (node) => { const box = node.getBoundingClientRect().toJSON();
const visibleHeight = Math.max(0, Math.min(box.bottom, bandBottom) - Math.max(box.top, bandTop));
return { box, visibleHeight, visibleFraction: visibleHeight / box.height }; };
const scrollOwners = [];
for (let node = source; node; node = node.assignedSlot || node.parentElement || node.getRootNode()?.host) {
if (node.scrollHeight > node.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(node).overflowY)) {
const clip = node.getBoundingClientRect();
bandTop = Math.max(bandTop, clip.top); bandBottom = Math.min(bandBottom, clip.bottom);
scrollOwners.push({ tag: node.localName, class: node.className, scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight, clientHeight: node.clientHeight });
}
}
return { align, bandTop, bandBottom, label: visible(label), source: visible(source), scrollOwners };
}, align);
record.product.longSourceStart = await measureSource('start');
assert.ok(record.product.longSourceStart.label.visibleHeight > 0, 'Long source label must actually appear between header and footer');
await shot('product', 'long-source-scrolled');
record.product.longSourceEnd = await measureSource('end');
assert.ok(record.product.longSourceEnd.source.visibleHeight > 0, 'Long source end must actually be visible');
await shot('product', 'long-source-end');
}
await fixture.assertClean();
await page.addInitScript(({ config, theme, kiosk, textSize }) => {
localStorage.setItem('houseplan-dashboard5-config', JSON.stringify(config));
localStorage.setItem('houseplan-dashboard5-enabled', 'true');
localStorage.setItem('houseplan-dashboard5-theme', theme);
localStorage.setItem('houseplan-dashboard5-kiosk', String(Boolean(kiosk)));
if (textSize) document.addEventListener('DOMContentLoaded', () => { document.documentElement.style.fontSize = `${textSize}px`; });
}, { config: scenarioConfig(scenario, true), theme: scenario.theme, kiosk: scenario.kiosk, textSize: scenario.textSize });
await page.goto(`${fixture.url}/reference/index.html`);
await page.locator('#dashboard3-panel').waitFor({ state: 'visible' });
await page.waitForTimeout(350);
await page.locator('.dashboard3-value dd').evaluateAll((nodes, text) => {
if (nodes.length !== text.length) throw new Error('Equivalent reference row count mismatch');
nodes.forEach((node, index) => { node.textContent = text[index]; });
}, values);
if (scenario.settings) {
await page.locator('.dashboard3-settings-toggle').click();
await page.locator('.dashboard3-settings-dialog__window').waitFor({ state: 'visible' });
}
record.reference = await page.evaluate(() => {
const box = (selector) => document.querySelector(selector)?.getBoundingClientRect().toJSON();
return { panel: box('#dashboard3-panel'), control: box('.dashboard5-summary-control'),
dialog: box('.dashboard3-settings-dialog__window'), normalizedValueText: [...document.querySelectorAll('.dashboard3-value dd')].map((node) => node.textContent) };
});
await shot('reference', 'viewport');
if (scenario.settings) await shot('reference', 'dialog', page.locator('.dashboard3-settings-dialog__window'));
else {
await shot('reference', 'panel', page.locator('#dashboard3-panel'));
await shot('reference', 'control', page.locator('.dashboard5-summary-control'));
}
await fixture.assertClean();
record.pageErrors = fixture.errors; record.externalRequests = fixture.externalRequests;
record.websocketAttempts = fixture.websocketAttempts;
report.captures.push(record); saveReport();
} finally { await fixture.close(); }
}
try {
await probe();
const cases = [...panelCases, ...settingCases, ...edgeCases];
if (options.only) for (const name of options.only) assert.ok(cases.some((scenario) => scenario.name === name), `Unknown case ${name}`);
if (!options.probeOnly) for (const scenario of cases.filter((scenario) => !options.only || options.only.includes(scenario.name))) await capture(scenario);
report.status = 'complete'; saveReport();
console.log(`Diagnostic evidence: ${join(options.output, 'report.json')}`);
} catch (error) {
report.status = 'failed'; report.error = error.stack || String(error); saveReport(); throw error;
}
+11
View File
@@ -43,6 +43,17 @@ radial spokes visible instead of hiding them under a translucent room fill.
CI turns green"; the second stops an empty or clipped frame from becoming the
contract unseen (#350).
## Displayed version (#512)
Frames never show the real `CARD_VERSION`. The harness sets the test seam
`window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before any card is created and
feeds the same constant wherever it plays the backend (`integration_version`,
support facts), so the about dialog, the version-recovery banner and the support
and backup previews print `0.0.0-golden` in every baseline. A beta bump therefore
changes no golden frame; the version-mismatch scenarios keep their own
`0.0.0-golden-backend` and still exercise the frontend≠backend relation. The
product never sets the seam (`src/card-version.ts`).
## Workflow
Build and copy the exact current source first:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+141 -139
View File
@@ -1,59 +1,59 @@
{
"schema": 1,
"matrixVersion": 60,
"acceptedAt": "2026-09-07T22:36:44.292Z",
"sourceFingerprint": "8e7650990368dac8b2651e684ac8ae9c578ba2a166396aa678bc3bf1ad9519ea",
"matrixVersion": 61,
"acceptedAt": "2026-09-11T20:05:33.184Z",
"sourceFingerprint": "b3e08d50a47faaba7562dc080f267ca13de845113735892a9115a6cdd991392a",
"chromium": "151.0.7922.34",
"platform": "linux",
"witnesses": {
"count": 139,
"count": 129,
"floor": 10
},
"scenarios": {
"panel-wide-view-light-en": "5d81a6c410c4289f82b994e7b6c9af729b56ceea59bf5823e35f638f54b5bc30",
"panel-wide-view-light-en": "2c93497871bc40f6401f6cb7e65e73bf98d0064bd2755ca2026135975bd2d9f3",
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
"version-mismatch-desktop-dark-en": "ad08f293c728913549b0a71a1a514a60e3030d885acfb0c9951f1d9a1125a1d3",
"version-mismatch-touch-light-ru": "bf511c2e7e8714e30a12126c0c0b7f7ea7f5885e8c8ee0c475b364f6510c8702",
"version-mismatch-kiosk-attempted-dark-de": "d3d0863fe814d20f5b081de6ac14f8bbf7d559be019808e62cd6dcdc7abcaa70",
"version-mismatch-desktop-dark-en": "b8059b768284208f605329618799d13491655e488e9d739a40b85cdc0eba5888",
"version-mismatch-touch-light-ru": "df42d0158025371b82eabd522e0dd7d0b9121a9ba685970db3d7839d97fce50f",
"version-mismatch-kiosk-attempted-dark-de": "2e3eaf2fb13d1cca77abe97938374b3e25235fc7db87bb6f7a2e72f1cafe4a7e",
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
"coincident-partition-before-dark": "daacbc763c81ad037b305fc34572729c063ec542d031821660b8d3d096a35d19",
"coincident-partition-thin-dark": "36f506ed50b92df11ab36b022a98d8fd8f3526b081bd634e6a8a38269f661051",
"coincident-partition-thick-dark": "4beacb2fdf3ab4cfdf8bb50c9a96bbee2de88d72ba7bec8924862f9a707461b1",
"coincident-partition-virtual-dark": "f37e7b03d99afdc0c588e256053965f404f31c14ea4c53bb95db5a0f9d2d465f",
"span-over-door-migrated-dark": "5e37e627eaecb455baa5e279d23f8c6d09f9cbdddfb58976c3b26383fce6df64",
"split-corner-wall-before-dark": "14407cf23f6c29c3ba7161be5445acb3606a59116fdfd03e33acb87155b3518f",
"split-corner-wall-thin-dark": "9a509f0fa7ee8503c54a508c313b47e10f8044f0a9931791d1cab83467dc3f90",
"split-corner-wall-thick-dark": "51ac5e7594386e306f5ebc94e50d6c6d428fd6a4f26a447b28e1cbdd429afda7",
"split-zero-divider-taper-dark": "eb43a7ba81ebd8b1f7c7f76306bb6205ea77a5cef3c21739d40ca540f0f7a1bf",
"isometric-geometry-view-dark": "52b01bf23b31353900ea9174fdb4b9d27a586f8370fb9b00c4db6b9bbe7f73f2",
"isometric-geometry-view-light": "6ee98e56782feaabf784eea7d927ea35bdde054d8605484f4a5e92ea93f7c82a",
"isometric-live-layers-dark": "0d123ab0285fe3ad8487fb5263c3d8074fe2d2490091868ed648009039c04a3b",
"isometric-no-borders-dark": "7cd9e7d9797c920e7f3564a24b1fd6143b4da94a342af3b34d5a1cd8d584f11b",
"isometric-touch-kiosk-dark": "a6515349431d53688415e42a7711e8adf052eacf5e63a74f892fed60002c6d11",
"isometric-large-warm-remount-dark": "91f24287e8c3148fd6470ab4abc4f856f2863c2ae7372cd90a3770ddee48f209",
"isometric-stage3-overlays-light": "efb39c86776c507b25a8f1955f7371086cdca92e0df0ecb4c4127577fb925ba9",
"isometric-stage3-overlays-dark": "af2af4520240ebec87d84727fccc249c5a008b0ce4403bb9c2d37aa317d8a3e8",
"isometric-stage3-openings-dark": "a387ad33e2d238cd5f5088f9de2be8797f8b724d40599ef21eaae4ad0ff27b33",
"isometric-stage3-forced-colors-dark": "36bd5a28a336b2be2d33640dc57d8eb3992198c812c6cfb5c20bf7daa6a611f2",
"isometric-stage3-no-filter-dark": "277e79c005736ed81135f70517902ccea4d4300ee886e0cf05eb027e7b46d687",
"geometry-view-dark-fit": "1ea111a2f68d71c2d2269ee65ac70b586b7d8a3a76f550bb1caf8d154bf42b26",
"geometry-view-light-fit": "80bbb618619731dcb9dbd5e872785f32f1e52ff71ce989a2154c5f1218812eda",
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
"vacuum-trail-smoothing-dark": "b33ddbfaed72f51e601576bbf87664a08578fbce5c22c69a11d83cc2644ff312",
"room-label-parity-view-dark": "eea967d733bea8c08242d9265e572dc13ee90894e0df71a4ca93cee7631f7fef",
"room-label-parity-plan-dark": "dfc804fa1aa5bc0222759463195bae0751513e0e428c8eae0bbc9d49fbc5a1b9",
"room-label-parity-view-light": "bb15fe24a803b1a3e07b778ccdda7c187bf8b85cd0d2f0c85e65bd77029d500e",
"room-label-parity-plan-light": "8ca4ce563f87d15c70b680ea61fcbf19ba0881123f9bb4bf76e6ea6f4e58c76d",
"washer-active-cycle-dark": "0f38b85f2f912059a6380a0642a4d0314ea5c6fa6acb9568e1c4abbf4a7731a2",
"washer-idle-cycle-dark": "08d5f10dc14b548e6a149c8d9c93f9b635854604cebd681644f30a1ca0b4ee4d",
"day-cycle-dawn-dark": "cee9dea7017ee192dc69771822e53583df973dceecd46279c93079ba6bc730c5",
"day-cycle-day-dark": "96fb83defa72f78d9e58413505f9250fae9cc766e21bdecb4b23374e04f15d43",
"day-cycle-dusk-dark": "eca235de3abf21776b160d1b79cde3309952f0030bc2031a0ada7bdf2847d2be",
"day-cycle-night-dark": "feec7492db1187b84e59cb627de9dd8d6d412f1dfc2643c3eab5cafe8f198f7c",
"coincident-partition-before-dark": "32d6d7e32db1bb579b33c783833ce986d10a81589e0783c5e7fae4b8c1e2755e",
"coincident-partition-thin-dark": "a5bd00dfd6a085b81cf5e213b34669d60edf5c03451ec2943ce31c735497cf8c",
"coincident-partition-thick-dark": "848021570e9cdf98bbfa3e9035ac09fe70695ab6b48964f8f947e30ae5695141",
"coincident-partition-virtual-dark": "9ec5a77dbc5f7d21225e7c51ac5acf2ad844b439532c44999e33f0d3a808d657",
"span-over-door-migrated-dark": "5915557d0c9c5a90e78e36bf08ca30f3319efc41e067f175f44b359faef0c968",
"split-corner-wall-before-dark": "5efad39d25a94b204302058853a70702b2afbb53a41d6f0fb55d0d7f5ca2d9af",
"split-corner-wall-thin-dark": "0a8da611ecb6061852cc26ee53c00d12433cf8539681bd91dac3adad1d7e9e67",
"split-corner-wall-thick-dark": "2efc65ddaa8777ebffe63205e2a961fa7053c91d3cd8c0af59810bef01ecd498",
"split-zero-divider-taper-dark": "f7f9ee435da00dcae043c693127561a5e050675ca4d6f6f0d916ac5dbe24a594",
"isometric-geometry-view-dark": "b061d186ffa614b97e9b0a2ba5f4b5378df54d75e168e5734d94c90289b4cfb0",
"isometric-geometry-view-light": "585fffbb49dab8de8ca2c2af7d4d290c5355b9060029ea9bf38c72af594f2d5f",
"isometric-live-layers-dark": "bf5dbe438ba74fada375966757620a6f8fc4641901f69000862145ca806085ed",
"isometric-no-borders-dark": "792c221f9412825b80c56f5a195f896e3459298caf1623caebb3ad448463fe8d",
"isometric-touch-kiosk-dark": "e609a2abba29e5441d57452b4f9d16a582f8f466f1a6f08cf81ef9f2a5391203",
"isometric-large-warm-remount-dark": "fadfe8985736d3fdec62c971caceb34a91f9ba5698e27380e014dcd93f54a29b",
"isometric-stage3-overlays-light": "9461cb2030234125b73beecc222a6119fdbd7f9d22b8dcec2bad6712210982bd",
"isometric-stage3-overlays-dark": "ef589656803ebbb6436228b068df1ed99fc46c4313f15dc75940835506714f4f",
"isometric-stage3-openings-dark": "7816adef15f9ea83a70572274baa6083875851fdeb17d9a8a77a4fe4feb92faa",
"isometric-stage3-forced-colors-dark": "dd52ebc54962541c934b94dd7f436183ed7a65445bc90f201a2f9d2eefa93fa1",
"isometric-stage3-no-filter-dark": "b2eeda001fa1d72c74aa0a06a996df205a7398dfc9f729406939a49a4c009a21",
"geometry-view-dark-fit": "454da1248f1fe13b8f60c589ae9ebee5e852995e9bc5175d3c5692c445f7cb94",
"geometry-view-light-fit": "de5f6a41552447ca909d20471ed6665f558fb6e111c1f333eeba55866517d5a5",
"pdf-export-geometry-light": "c403a141cefb73e4653f2ffb9c346b4cfaa3de339b6c39c227ef1552ebde0048",
"pdf-export-polish-light": "ed6685900355e20a4618e931d42d2e45a39ad44e4a2ab044fa5ee64ff56f12f4",
"pdf-export-stepped-dimensions-light": "b9732b8b9c38153c564d0c93fbfbf44a8dd348c8667d2f8c2d14492e08e04f3e",
"vacuum-trail-smoothing-dark": "f8eecc726a3e8bdaba414584ad6afd48908ee9d96ea969a1e37758f4e76843e2",
"room-label-parity-view-dark": "a188d4db84533d605cb6fe822d20c34d164e3bfc8458a55beaad564854eef169",
"room-label-parity-plan-dark": "07e896d4f6dad384bd52dc60f786f042e1cfcd26ddb1f7b12b72d40bb4824324",
"room-label-parity-view-light": "dd96a224c4d951720031ff1ba793a1ba560951e0441cb4f61ee82215aff1d8cc",
"room-label-parity-plan-light": "91b7aed42c3a677e387b26c405a04dcc327957f9368625c7c3ad3cca758f1fb1",
"washer-active-cycle-dark": "0d683619c27747b3dd6a1e6c7cdc5e15dd886f4f2db4b85b7ca5a5c529e9ec52",
"washer-idle-cycle-dark": "0138902daa8dabb0588904507ef5a44f5257f7859b4f1198191bfca3dd4130b7",
"day-cycle-dawn-dark": "96bd5ef84345976bb83d35d418e7d247503629eceb732b6e926f2217f3b9faa4",
"day-cycle-day-dark": "605849bbada8d3c3a02a2c88c7b21c81c675a7a89b72c4c560df635d63005e67",
"day-cycle-dusk-dark": "d19cc97b75014d9f0661705df50d5f30a3a574661957c04c5dab96e26c5e3364",
"day-cycle-night-dark": "39a6a959676295eb4ead02703533641f57901ad0ce1359933e1d47f48867196a",
"geometry-plan-editor-dark": "6fa29c51ac37449e53ff76afe17760a86325e160f53040c737619aae1095599d",
"safe-resize-handles-clamp-light": "98db89eb1ec29aa6297ac0bc10653c8efce16175833091d0e76dd033ad9f4a8e",
"safe-resize-handles-clamp-dark": "581f85ca93197ba796e72daa67402eebb7c64f13d4722b5d33943577837c8980",
@@ -63,27 +63,27 @@
"plan-snap-line-gaps-dark": "e4e5e15a98e35ac26a5b0fe7ef674341fb55d8445587ccc25a81603e1ef92774",
"wall-junctions-plan-preview-light": "f6f450768912f37b3d91730cc38f43b399eab781e585bdbe6c9bb29458b56f4c",
"wall-junctions-plan-t-dark": "6c3748e931407079e3aad3ade77c46069ef75444c27405549d1610670a33ad95",
"wall-junctions-view-dark": "ad23e5fb0ca9ba23f58ee4839cec489e7d05a28ef193d424b451a4020ef8ce0b",
"wall-junctions-view-dark": "b3385dd544e87aeacbc85020621de96ea20feecccf907bc5de3d2c045860a1cd",
"junction-patch-resilience-plan-dark": "8ad87deb245097cd090f301d259710323cf4ea577cd8edaf16607d6ad8a3d29d",
"junction-patch-resilience-view-dark": "4ba710fd6859f958ad6c576a01d0b240a3b1723fe9df54f8cabd971414a943a6",
"wall-union-isolation-view-light": "426ef2ada0e69febd73ab305aa99b600a95e69db68dc30dae4c5288ee1151604",
"wall-union-isolation-view-dark": "215ed9d8cba31c5de4cb9d0e2b347bf0c96c8d1bba4d35feca57d6f66fc25322",
"multiwall-junction-bevel-view-dark": "d64a9549ecb9bc166f99283901411f08651389ebe5ae3420c935a677c02d4b5c",
"orthogonal-strip-cell-5-view-dark": "05cdd744d09025719eda4c5e68cf3ebc5d7d201afd3028da91262d0f4874c126",
"orthogonal-strip-cell-1-view-dark": "9c0f91b11d47f1c5df4476774ae6028db21dc15cc6fb81d9f80009e0fa86bffe",
"wall-key-roundtrip-view-dark": "cc6f755c03b1a586684627fc1dc27eb1aa28d2673a6c79bcb687da94a6bf9b3b",
"isometric-wall-junctions-dark": "af692b2c01293c59cc74e5731309e6612c913ca1c8384572fd1cb67a19d0f902",
"junction-patch-resilience-view-dark": "db72b14098dd26132521b71adc87cf5798e5d48faa1f449c294ca2df81077849",
"wall-union-isolation-view-light": "2fffbcef2001b24b4ce7d104f0457231044be7ee0718e7f31b6fc13b024f2bc9",
"wall-union-isolation-view-dark": "7dce51897ce87e96b7f43d62d68ac859aa662a2aa1227cc853bc86e5bbf321e4",
"multiwall-junction-bevel-view-dark": "3f6d192bc734ecbb4cc5273c99dc3353e39207276cf9ebb8d595bbc0d8f4d8ec",
"orthogonal-strip-cell-5-view-dark": "267a3eab1f4ad7d8d484eb2a58b3e38031b0516ca8c46c93e272bfaf9ac409b8",
"orthogonal-strip-cell-1-view-dark": "7c9bab0789f9ec9019edf3c49176415af83e8a325e833f98a0e6324e7f06fe4c",
"wall-key-roundtrip-view-dark": "74fd46cffefd32d805296d844c162dbe6f15b13e86c4d6c7f6974293b253e1b4",
"isometric-wall-junctions-dark": "2249ee0effc2c96c90add934071ccab302f9233920b43496a48c8d8ecad298d1",
"opening-placement-door-thick-wall-dark": "9d587eff0c2561ef5f84e9280cd812fa6e9af7cf6ff00c922d275796774ba513",
"opening-placement-passage-thick-wall-dark": "6ed48b39c0e11fbf4b0df7d3e4dad8078e5b17694f1a780936a864bebd0c1c57",
"opening-placement-passage-thick-wall-light": "3d4e3a5d9b67f2450fab9ccc0f5dc91f4485080582377de904f16ea1d62fbafa",
"opening-symbol-room-wall-light": "c487c11f12b2bba1473559a82af3320b7995fa9c8d1b379274b942e431930beb",
"opening-symbol-diagonal-partition-dark": "77101350b15845207644b6df86a3bc5f48b4dc873b9cfd7443c0d23507412de3",
"opening-symbol-flip-pairs-light": "c2d9102921483da1d0216ed9bc55fc9112d67762f159df8af64a277f0db0acf4",
"isometric-opening-symbol-parity-dark": "e2ead596818f6182bd2478dc3823f31312d361080c99afbb9c7f7d1862cf781c",
"opening-symbol-room-wall-light": "dc3f6513a08b809a77df6822f59fdb30c912f320e55d47d749963678fbb8f417",
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
"isometric-opening-symbol-parity-dark": "4f745055f3de80c01d7144cdc1610be73c78a98f69506c8d6e62bc3fef509471",
"geometry-devices-editor-dark": "e273c4fe49619a5856a9212ff6261803640e5afa79b841edfccf5a4056e0264d",
"device-inbox-desktop-en-light": "a73761da0594ba2138f098d971d74566f9915f59a563fd55295add241145ba36",
"device-inbox-desktop-ru-dark": "5a9eb32ffb7f408c6bf8adca9d76b0d01d7b0b514f8b33a01e500f9230541f1a",
"device-inbox-narrow-ru-dark": "65c46b7311f850f305147d75a24ddeded67c9e8b18ea362c88b9964c50a5b727",
"device-inbox-desktop-en-light": "2556b2ca3916c016bec58767acc3eab445e32ef29ae59e8ef3580f27cdee9f21",
"device-inbox-desktop-ru-dark": "f6da7aca353c904e37f2aae9bda9ef79b0c102926f9b9dbab5f6d9b7a14bbfb3",
"device-inbox-narrow-ru-dark": "48336c685b2c6a25cae422912973b1e0876e38c2d2ddc92a643e6d121410af35",
"geometry-decor-editor-dark": "2f8a3742166c10c0a5aa1239c9b6b7d908e7e578dc43229274b17d015c47503c",
"tray-wide-selection-en": "5a535b457b8b274ff25085ee992aebd95d756415afe1321d0aadee70610d5801",
"tray-wide-tool-ru": "99abefe9c5b0f21356e87b46fc6d6d615cf9b799614c14bf8fffa0261fd9059a",
@@ -94,87 +94,89 @@
"furniture-variants-dark": "aa15801cc56ab488e9563e05d15694db48294dd4f3d7e5bc238b4f79ec8d9b7b",
"furniture-variants-light": "286269d08ddcc086747d0703a59cd03bac29b11b69839364c01cb8492a00602d",
"furniture-placement-preview-light": "f983eda1bf42e27d17afb78eada8b96b192a7e91bebb0c70e6766994000aabe3",
"furniture-plan-art-dark": "e83a8c40add2bf33af304f86ad29fc77d8d47bd7228f17c152e962f3431e0c50",
"furniture-plan-art-dark": "777c85db2db6a50f190629b92b394fd34a31e442be5c67f2db298444d2c580eb",
"furniture-transform-light": "3e37e03457bac87c9d7eaf6bbeb0f4054fb6e6a66a7da5953714446a92ccf296",
"furniture-transform-dark": "15d7d719a2de7b8b142c8745f262da198a57ec92e3bdeecd39339414118cb367",
"furniture-transform-dark": "7d988e7f7efb294ff86a3585642a09b2d2b5b5cf1d41f5d7f1d6bc9d24ff8628",
"tray-narrow-tool-ru": "fa8f7125ea9f5c43f47ad090cf78ab970950a03dc5a9fa1c348c880ced7c0c4c",
"geometry-diagonal-45-opening-dark": "0ddd027a7c899b5772c80555ac8de37c33e8539cc5d911ba454f562e787f6853",
"openings-thick-wall-dark": "5dfbd731752515ece69b39d39e6b0dcf43eb5c5d8eadc1bb5f55d43f94b82992",
"openings-filled-tunnel-dark": "362802a9b810e506b81eb4bd26b883592c0d1d5aa491823d6b81888deb96fb3f",
"decor-over-opaque-hover-light": "23cf9d8aaa544fea547b189bac77e8e8f41521ac98bd0508f96737311857d3c0",
"decor-over-glow-base-dark": "49f09a452295d8e281fbe3a4ed56517ac4039454fcc680bc76ead7c222095c42",
"openings-hidden-view-dark": "bd66bea4249806cc4fb02ae8813d97e8373a12063b734d9e32a538adf086e9da",
"lighting-glow-sun-dark": "7cc688674888ff726f5e834b37d100e065afaa6c7c1ee6b83600de93172bd8bc",
"device-value-badge-positions-dark": "0e94d408ea27669d0b227504bf5ce5a637e19e4d16bbd8314b9b49acad32c271",
"device-value-face-cover-source-dark": "df67d07b96fad02d62b6a7354e31c4be7c8fcf965de1709011f08d7c9d094c36",
"device-icon-state-table-light": "588daaee4d3747aae9f026a728d2769fdbb89f7a1a4b045dd8295dbe8dfdd680",
"device-icon-state-table-dark": "9b2e03ce937f92e63a87e215f6c3a7663c199a6f9d8f33ea438fecbd6466e18a",
"device-text-shell-long-light": "5b254c2c9c2fc1d977e7417cfcaae146e0dab30b6ee29ae6860650e8ee27b61c",
"device-text-shell-long-dark": "bad52d0c18e2d0255566c6a144d6267d2ccaf06739e11e9f7a3071ed2d04f889",
"lighting-sun-window-state-only-dark": "df3d1f55b9036edd9787327e705f4218157bc9a954ac36742809aa74ffbf7e97",
"lighting-fill-light-axis-split-dark": "62757164bb514acd4cda3a51166136d2199480a5341b75b2f7df5324276f094a",
"lighting-fill-temp-axis-split-dark": "bf0ec890e47087f117f14509954e3a2cf1a0d093014580f6e3bce6833c00c8f4",
"lighting-fill-lqi-axis-split-dark": "58f9d537812ff3cddafab305478e76ae1adee6368d735eb0ebb56be7ee287cc0",
"lighting-temp-glow-dark": "9a4d24c2a2a11fd08083aba0de97fe43ff53698b8893ab13f76910315b4f1119",
"lighting-temp-glow-light": "3fa32baec780312465ebb2237c97c486e65b19a1d93caff0e34a5a9f6c1f731e",
"lighting-custom-glow-dark": "ac97ac02b8c2de217cf0ddb81e91ec0c3ce9f2572b79d89e10e8c3077e22b60f",
"lighting-opaque-glow-two-doorways-dark": "9a8e3495c474019693c8e8eece4aa78a51889c66ca2ef1de882a1ecf1963282c",
"lighting-custom-glow-light": "99700dfc1ed1f29e72977e033d54814b41aa1f36ec57fd2b0d586dadd685d3df",
"lighting-temp-glow-no-sources-dark": "9828ecb9ecf6368b9e3b394cbcb0c87bf2185b79ad2ed2021543bd2a4ff39016",
"lighting-temp-glow-room-override-dark": "929af750e3d6fc09942fde9cfa8234e73953c6dfd287136af3d471d7bd7ce5ac",
"lighting-manual-auto-spill-overlap-dark": "992de7a2bc21955ce8bf79ec84506463094d5d88392e7f2dd06bdd10dced31b1",
"hover-over-glow-dark": "32cb8ee5a15eb6a1eb65e3b89429976c553e75e628a49308911ff5aefd00e76f",
"hover-nested-room-dark": "5733d93d50b2e8a715174c8138ddd88739db12f8ef2cbaa913501612c8ceb0b2",
"junction-t-90-equal15-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
"junction-t-90-bar50-leg15-dark": "8f9e9f8d73106918c2d85782ede967bb1db554a4d09ed57cb1b062497fb7d502",
"junction-t-90-bar70-leg15-dark": "9b9110cebdb141b326c9a4863306f0026d574191e28a9642b238008cfecea38d",
"junction-x-90-equal15-dark": "e973bbedfb3816a14ee26469187345bed73cfcaa4249d152a638ab9135e007f4",
"junction-x-90-mixed-dark": "c06ca8cf7f54b05278769945477834fb97a1bc679ab821a6b3a2543c9dfaccae",
"junction-x-45-alternating-dark": "2f407f8ae20f83b1fbf394a58ec75bbaf8c18136b08998e593c5b06512e1db96",
"junction-star5-equal15-dark": "0ee20d4710260ff0dc186fa6dace9232affc9df77f1ef9debbf70066ccf2cd87",
"junction-y-60-equal50-dark": "dd34fb276b09413e63769f17e4b9e11ad4635f5af5ebe28530ed65f27c871418",
"junction-acute30-mixed-dark": "314d1b732259f68f06b68b0c0ea84325962e29083c136202b3d785902003300f",
"junction-acute15-equal50-dark": "d3cd930fecd30d71b6cf2feb993de0ebe05fe8028bf8a4351bcc9d58e99f50fd",
"junction-splay10-170-dark": "bc5b5ce280135badf598d5b2995ab40b55689284af20e429abfe7e24bf198fc7",
"junction-t-virtual-arm-dark": "3e56730afa1b74c17a15007e46fd9d5bcd2b63643421db317a54cba6cd43ef2c",
"junction-x-virtual-through-dark": "4f03f100ab2f25059629fe3724f5612d2a91988360c6eaa1092dacc14d4ddfe9",
"junction-column-node-dark": "cad34f8c81afe7fa4c44f69892a49e6f6e48afb4e079f636c5769b9d02868ae7",
"junction-draft-end-node-dark": "e9b60c1a4a62b0ac27068a5980ef17fab37eb98f6ee310f239a54d6c9b752f3f",
"junction-309-step-dark": "ede9b8f9c75504b677f2c60a8ab5c72db9e989e86d347b685a676f6d225592a2",
"junction-309-spike-dark": "9e4df39fa724ad63c87f49fa221c8c4f3011246c69a6aafc648e5864acb26b58",
"junction-309-hump-dark": "b5cad52f701423f421d8561efb542e0bb1f2352570de36f6ccd1a6e9838121f8",
"sharp-apex-legacy-dark": "8112eda510a11993bbfda5b1febbbd8bf3a8ea0786afc276b3fbe85e6963bf00",
"junction-owner-repro-dark": "a4744295587d362ff58a57a68be202e69be67adc3d0504c9fd7c4d9e767494f8",
"large-house-zoom-040-dark": "5601d5ce3b1fe79365984301872a851d6c2e81878ec6f6cc3b88aab811d8609a",
"large-house-zoom-250-dark": "7abb72baba00ed035d9f7519b99e1d9f872a6cef81861ed096a511fe9320471f",
"large-house-warm-remount-dark": "441c32f130b8f291582482fa6445f9c85f6f13c61b956020d4fa1d6153094efa",
"device-dialog-desktop-en": "f3a3a45dad2cc22e692366114a4f87272b39ba261c1509e8a501dbfb82b6584e",
"device-dialog-mobile-ru": "7ac796286ab4e65275838b4095ea3f16491311f99fd98cad3bbb475c6061e31c",
"device-dialog-desktop-de": "9d39d3e73a3f36171e8cc7c930bcb43bfbfd788e31db0f584962300a2b00ca93",
"german-view-mobile-light": "dc252c1a0ced7c636dfbd5cc3113abe546d568b9c62e4812326ab604dfd4e5cd",
"toggle-entity-dialog-desktop-en": "3ea2bb828b554b6fb544b77fdef2af947e45af632769f4deab03c6adf195fa68",
"toggle-entity-dialog-mobile-ru": "2d433f3f1fab9e9d4c3d413c302ac0d3b44a2d46ec9ecbf098c3063da6f1df04",
"device-help-popover-light-ru": "1905d843c715b5d1f1dbfe06ca5cbcb8f0fea27f26301bfc1b826227334d10b2",
"decor-color-popover-mobile-ru": "dbe9b01e848d3f2776ac74c6bad0134c978b04c07c37b674ca7c823aff5fcd33",
"decor-color-popover-desktop-en": "034cf59419f2e940e5fd05be10f441714291a90a2043a7cfcd6e1cda249a1292",
"general-color-popover-desktop-en": "16c7b7c206970a6f36c5c70cf493d3e04ea6509a89c65751c83842096c1550d6",
"settings-help-zoom-200-en-light": "a6f49b4d0a171dc5f74d409c6e7df0e1c57bc52086b3a66dd794efde1236eef8",
"settings-help-zoom-200-ru-dark": "90e9052fa339ada6a8c05b1732a5786bee9b61266d1a9305f316c4fc35c03df2",
"support-desktop-empty-light-en": "87d6db3b1d5a5d58034ddba0612c59bc1d3ce42ad43e812a6c28ce42a38ff499",
"support-desktop-preview-dark-en": "4ab4a46a6f06064df60f3bb7ba62229398061c9407380050269d7523b502a2d4",
"support-phone-validation-light-ru": "37059c99431d6890d1f1a66444c7e853e51387655cd1ee6474063299281f2b1f",
"support-phone-success-dark-en": "8471706c17e14d199e6d8ca329d4cfe15cb7910f9d0aec28bfd839f49e002612",
"support-relay-error-light-en": "ea6cfd6385561562eea3300b802e5036ac63dfb21bad7e9b7bb57e4dbb5e605e",
"support-tablet-preview-dark-ru": "549310cc299c346f5495abbd386e5e56b011c812d815cbbe39871165cf9d7981",
"device-ripple-color-popover-mobile-ru": "93d04a498e16ac7e98bd420bef566645c2215488138568c5b150110380aa782e",
"space-room-color-popover-desktop-ru": "c4cb215714ce98728c2e7b9d3f083055650b639302578874d28067c741ba0cf8",
"backup-full-preview-desktop-en": "c8fbd39e63e6ff3f8158ae36ae4ef299ad7635bf41dc490547cd6ad56aeffdfc",
"backup-plan-only-export-desktop-en": "cf00fa1784b7a4c4aa3153802ca6a79afbe988ae1b70a9c5ba1983aba0863b4f",
"backup-space-preview-mobile-ru": "4575ba8783647518abbbe9903fd915ec0d3caceaa08a70fa70cf8bf8e7b08356",
"optimize-preflight-dialog-dark-en": "fa16f7caa6ab75db6a76ada7d21450cee488900a7dc70505867bed7c6f5325d5",
"optimize-preflight-dialog-light-ru": "14ff81985b7564e23f9d1124c2be44a31b017186691745a6092fe918e79328e1",
"optimize-orphan-references-dark-en": "d6a59ae1f7accef832ac6458c618ab4719d110c0154a4d607454fc7dab34d8a1",
"optimize-orphan-references-light-ru": "88455ebb34a052abcc75cd4454f1256cebb11ee0a8b880b8507235c332f8ae14",
"geometry-diagonal-45-opening-dark": "a083c9a5b39124f8f6fa8123562f1a5e24c884fa47c54c99786b24164e8bfa78",
"openings-thick-wall-dark": "10b672a9965a994928111911760c987b45c7166c60e6bb0cbe02b2c11c59c211",
"openings-filled-tunnel-dark": "87edc3afb2720f8b75deb02c2b2817bee9534b7908e4dab8e8fee26cc6daf99d",
"decor-over-opaque-hover-light": "90317c690ff3dfb6fbd5cdc9345d0069571982ba8f916cc1ef677a31b439378a",
"decor-over-glow-base-dark": "021013905dcb9e4841e76898200671822e7e06a8196d962e643b579dc4fd38df",
"openings-hidden-view-dark": "209a6a52993a6840d85126f59f408fac68dcb9b7117e489161de96d63c834fae",
"lighting-glow-sun-dark": "9021fa10f6606d2307f75f8024473be3e2d6e94d1c48ab149551c2bbdf129578",
"device-value-badge-positions-dark": "f4ae3548d65e8fd0377b7c2b446065d922473c40f85537a8ce99d3219978223c",
"device-value-face-cover-source-dark": "18aa90881fe7794c98c52142b73fb1db33bf57d390c24b6286b7b7db6312bcfa",
"device-icon-state-table-light": "7967b87a5179837988708884418d914b446c6ebf5c61d5834f299c0662ce1eb5",
"device-icon-state-table-dark": "4f42ac7b3e7ea5a5ee17bdf9ceebfd2520603e30662d6f9ac4d4d116951dff32",
"device-text-shell-long-light": "ceca1de08ae34f610bc532ab7851855582bfd00b987922941a3253ea61aac44c",
"device-text-shell-long-dark": "0a8ed12b7167744eebb59adbd0a4c81efb9259c64305e9977b059c05db497cf5",
"lighting-sun-window-state-only-dark": "1ed25dcc1b2efa7c2622b16c16ec8b551cf67c5c12202fd483b2377dcc47ab50",
"lighting-fill-light-axis-split-dark": "9061d4cb28884190cb116f9fdec6028af7f2a3758a171fcd3404fbc65aee3a34",
"lighting-fill-temp-axis-split-dark": "4338d916728ab8ea44e7c7686378034cfb04d96f8bb05acfdcb452dd176edb7a",
"lighting-fill-lqi-axis-split-dark": "8486a5b02a74ef820c70651bb4bce2ab2f00277f7c6d01dc7689fd561d111727",
"lighting-temp-glow-dark": "f8ab48f73ae7fcb3920515adc7919e1c08132ba564869d861aeca5d67a297025",
"lighting-temp-glow-light": "3e44c5ab7aa279dcd18a8b4a2df6f2f225d8f4f1aeacb2961519ab789894f5ae",
"lighting-custom-glow-dark": "4b536f66660e78f40b1512c03ef05b8b41863c86cf13b79fb98f5b4fa2860af3",
"lighting-opaque-glow-two-doorways-dark": "dd63d3e3772fbbf1e27a18d44f8e1fb8646b53f357fcced97b7988abaa472444",
"lighting-custom-glow-light": "61270a5369921b2122075ef7fb8b4f633032a0e0ca67f53db79db060761718bf",
"lighting-temp-glow-no-sources-dark": "362b4650a22f2cf4eae91c3f426aaf2b132ec220c1a4b0f1114fbb651156cc82",
"lighting-temp-glow-room-override-dark": "8edd138b859669e1faf60964fd801306991cfae0f77c98d795c9929cb104e897",
"lighting-manual-auto-spill-overlap-dark": "6ebc10970e48f208ecda1e7b2bb86a26c90c903dade43e57c46cca720b41075d",
"hover-over-glow-dark": "eb4126af72e86b7a0b3ea0ea11a666fef3438b37ca574882f036ea5d44e6e938",
"hover-nested-room-dark": "b5337cb4c505f3b4983869bd7d4efd0a5be0d01e34dfa9f8619aba46b805dbef",
"junction-t-90-equal15-dark": "5930af307f64db4105b0bcfbabb454643f5a21ce7095c3f20c629d09c20cd212",
"junction-t-90-bar50-leg15-dark": "bc87ae653f8d249be5cec9c35d157c4420529d0dfc94e4b2716293c140828d58",
"junction-t-90-bar70-leg15-dark": "f73508baaab86915e4f667477116ac3b1ef68040caa6aaa932ffcd0e007b20e3",
"junction-x-90-equal15-dark": "f01ecc2b28d457fbff0581681dc8c1a1555558baf4c0ac8e2bd4bdd3637f51b4",
"junction-x-90-mixed-dark": "1218a50ad968eae651e61637eeaed8238d0598d77749d3c9cdb7bdd7c0d6a063",
"junction-x-45-alternating-dark": "08de6b9d3a0e6d0164efc014fb1845cdd70cccb95b05b0d55175f2bca89c378b",
"junction-star5-equal15-dark": "4bc9337b4bd2ac4bcb8d168166330faad20184c8b3e51b153ef91d0ad76124c1",
"junction-y-60-equal50-dark": "89994b12fd168b3e35ee47520fafa502f904391f98cec909fccac19b7b0a90e5",
"junction-acute30-mixed-dark": "bc4f61f377711df71950ea6a8bea7d185c94b5e03bfc2e8fc4930fa9adc9ca02",
"junction-acute15-equal50-dark": "3883df35644df7cc169cacb96a5ee8391d64878930c69a0d511cd0cca0f2a04e",
"junction-splay10-170-dark": "4dd7a3947d11b1dcbd1b922058ac18d08bc5b19949925b8e19373d71429d81f3",
"junction-t-virtual-arm-dark": "e0accff2bb3c39354c9aa139921f31fa08d311f57dcc2d6e9bacdbba3b45e671",
"junction-x-virtual-through-dark": "a7ace42d135f24e49a504072fdc2bc360c2a23f7e95e4c47b1d9df0727af32d5",
"junction-column-node-dark": "5930af307f64db4105b0bcfbabb454643f5a21ce7095c3f20c629d09c20cd212",
"junction-draft-end-node-dark": "7652ec1a96a399b4985c256c62369d325894024710b47e0f1271a4a43a82467b",
"junction-309-step-dark": "86c837457d3774c8d16a9ad673156e5d758e8805c321d8d6366a0656febab35e",
"junction-309-spike-dark": "17b80d7b8f976e01d7ce4b366c2ef57f1b6e88fc4c23e915667e444a7fc9b164",
"junction-309-hump-dark": "2402ad1af472be47c2834452047e8f7ca703814990f5c1d59c1462c50905ce91",
"sharp-apex-legacy-dark": "a7d3694789f0c981c33f600b1773fc55c49289b03fdf1d3d4ebcd79c6f4c6f1e",
"junction-owner-repro-dark": "4d8dd271c2ae6ca6dbdd00e1d0917209b693c94f3c48b2d91de752d541c18cec",
"large-house-zoom-040-dark": "84f9e93b5fd25caaf031dc7758553c29459d4dda31e086b7d79e904c9b3d6230",
"large-house-zoom-250-dark": "fa901d097023320a599c1c80be9f9c65a0ce6245bb7182bc0f3d90d679668c17",
"large-house-warm-remount-dark": "6ed51a1c76cd7c62875eadafc169f7c6fc25604e64edabd9594a3664c886987d",
"device-dialog-desktop-en": "b748923af66c4bc69785189f3f394961e4b7afb6528a538f0be845bb1261a3fd",
"device-dialog-mobile-ru": "3a27e161cd2a2b791c38e74c24606d2a8deb47c45d8dd64aa14a0311b05b1dd3",
"device-dialog-desktop-de": "385f8f36de1112862c8b9b10a627155a2c6c9b24f76aea45eb2a79e00397a07c",
"room-temperature-dialog-desktop-en": "f047924f25f7b05edf76e3acdd8e76af5b14928fd9273fa0643cda815af9fe7b",
"room-temperature-dialog-mobile-ru": "722ad71b8ebc482a3c11a088baa54aefdfa36f5d61aaf7e8c2551cdb7bddb3b9",
"german-view-mobile-light": "8c079d1574a6fbec478eacc872a46800f4348a9ce6d4dc1b1a11f88df8688e19",
"toggle-entity-dialog-desktop-en": "799c95295a5f321fb512f30f6787c3057c059cd790337f46985736a16f4f0943",
"toggle-entity-dialog-mobile-ru": "d293db2db8773d72c78936c0c82a57791811d8c2bfc9cb3859743d5e20d5ab35",
"device-help-popover-light-ru": "dac475aa03f90a084ff558c8a187e2bd8c7ca01743bc401a511efb665ad8e815",
"decor-color-popover-mobile-ru": "de167d5a363758b26108f08f7e3f0ba7a44c8daf7461d1e54d719698d20ab208",
"decor-color-popover-desktop-en": "ce6e85df6417dc8912ff7119c9851a7e15c4f28ed4097aa21e973e9da8d1ca0e",
"general-color-popover-desktop-en": "8dd1aac5beb37d942325c566dd1fedd6303a74d97882202cca92516fc4bd7982",
"settings-help-zoom-200-en-light": "d4c0ca1d0a6807707bc5f3366f4da8d8bba13297d09339d36917fd77f9fbe923",
"settings-help-zoom-200-ru-dark": "5128090ce7ed3196b3cf81e95634c11c9a42160959cac682792920353f8405c7",
"support-desktop-empty-light-en": "ec60104cb98b646db22089c93f70f78c3a13cdb7001782b2981ce19800ce3cee",
"support-desktop-preview-dark-en": "37707b251c117eb381f7ddd6ad13a235be82c139a4d92dc07789a76cc425e946",
"support-phone-validation-light-ru": "1699dde634aa583ce6aaa30252f6cc01c9d822ff2718d0d77b179c7c38216ea2",
"support-phone-success-dark-en": "21146b3a221010cdc45e1a83c299395772a9f214550a296b78c5ecb72ddab332",
"support-relay-error-light-en": "e63e4e3aec111aa8044f6c99a8d3331801505a0024399293d8d5d53776346b76",
"support-tablet-preview-dark-ru": "969398341e722f54d56a14084d18c4943f644a98b7772222ee7b601d46ddd866",
"device-ripple-color-popover-mobile-ru": "efd4b19a2a516f46998a0824e0ab018f9d6d146a2f351c99eb8eb781a4bb3a6a",
"space-room-color-popover-desktop-ru": "2576975a4959e04a52dbf1887f55593d3f34e47872f3ea0cf4562e29703783fc",
"backup-full-preview-desktop-en": "935adb062c28385a89d34716616406e2434d8cdf3daf9cb7abead13df3c6cb81",
"backup-plan-only-export-desktop-en": "b017b78e75968123011777c27550bf82da6e50267086339b963d8571e8dca679",
"backup-space-preview-mobile-ru": "5ee8c2651d9814be9d7c76b7692fcf65f4ce341fb4cde1fc3e854df4568994dc",
"optimize-preflight-dialog-dark-en": "be8c9ddaf4eb07d83594d836ea20f5fb0c142d3c0f53a5a5b32e69814df27ae8",
"optimize-preflight-dialog-light-ru": "75a42a6dfb8134d7030466a730bd32ed08b7f50a72d2e9e081f9a3d853898856",
"optimize-orphan-references-dark-en": "d8a33ed94935d3b09fd9317729dc014c38c9baf4ef425947365d8aab186c45c2",
"optimize-orphan-references-light-ru": "d68e22a3cd9b2f047dc017217593a9e1a9df79fb9014070890d4f2df52f38cc2",
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 415 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: 83 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

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