fix(zigbee): restore observers after integration reload (#800)

Issue: #800
User-Visible: yes
This commit is contained in:
Matysh
2026-10-05 20:08:37 +03:00
parent 379de706d2
commit 4a7abf531b
19 changed files with 442 additions and 24 deletions
@@ -424,6 +424,11 @@ class ZigbeeScanCoordinator:
if self.closed:
return
self.closed = True
# Reload does not disconnect HA WebSockets. Tell their observers that
# this session and its volatile cache are gone before dropping them.
self.revision += 1
self._emit({"kind": "closed", "session_id": self.session_id,
"revision": self.revision})
tasks = [job.task for job in self._jobs.values() if job.task is not None]
for job in self._jobs.values():
self._release(job)
+5
View File
@@ -84,6 +84,11 @@ const result = await page.evaluate(async () => {
await wait(() => !!settings?.shadowRoot?.querySelector('button'), 'settings button');
await settings._readZha();
await wait(() => settings._snapshot?.states?.zha?.phase === 'ready', 'ZHA ready');
// #800: this fixture intentionally has the old integration's config/get.
// Let its independent capability reply settle before injecting the age probe;
// otherwise that real runtime notification replaces the synthetic timestamp.
await wait(() => settings._snapshot?.backendError === 'backend_required', 'old backend detected');
out.oldBackendStillAllowsZha = settings._snapshot.states.zha.phase === 'ready';
out.explicitZhaRead = zhaCalls === 1;
const settingsSnapshot = settings._snapshot;
const renderedProviderStatus = () => [...settings.shadowRoot.querySelectorAll('.status,.hpf-actions .hpf-hint')]
+18 -3
View File
@@ -10,10 +10,10 @@ const output = {};
const record = (name, actual) => { output[name] = actual; check(name, actual); };
const artifacts = new URL('../artifacts/zigbee-topology-800/', import.meta.url);
mkdirSync(artifacts, { recursive: true });
const server = { revision: 0, sequence: 0, publishes: 0, cancels: 0, provider: null };
const state = () => ({ kind: 'state', session_id: 'server-800', revision: server.revision,
const server = { session: 'server-800', revision: 0, sequence: 0, publishes: 0, cancels: 0, provider: null };
const state = () => ({ kind: 'state', session_id: server.session, revision: server.revision,
provider: structuredClone(server.provider) });
const initial = () => [{ kind: 'reset', session_id: 'server-800', revision: server.revision,
const initial = () => [{ kind: 'reset', session_id: server.session, revision: server.revision,
topics: server.provider ? [server.provider.topic] : [] }, ...(server.provider ? [state()] : [])];
await page.exposeFunction('__scanRpc', async (message) => {
if (message.type === 'houseplan/zigbee/subscribe') return initial();
@@ -185,6 +185,21 @@ try {
document.getElementById('scan-settings')._snapshot.topologies.length === 1));
await page.setViewportSize({ width: 1000, height: 850 });
await page.screenshot({ path: fileURLToPath(new URL('desktop-ready.png', artifacts)) });
const obsolete = state();
await page.evaluate((event) => {
for (const callback of window.__scanCallbacks) callback(event);
}, { kind: 'closed', session_id: server.session, revision: ++server.revision });
server.session = 'server-800-reloaded'; server.revision = 0; server.provider = null;
await page.waitForFunction(() => !document.getElementById('scan-settings')._snapshot.states['z2m:zigbee2mqtt']);
record('integrationUnloadClearsUiWithoutClosingHaSocket', await refresh().isEnabled()
&& await page.evaluate(() => window.__scanStats.active === 0));
await refresh().click(); await waitState('loading');
await page.evaluate((event) => {
for (const callback of window.__scanCallbacks) callback(event);
}, obsolete);
record('explicitScanAfterIntegrationReloadReobservesNewSession', server.publishes === 3
&& await page.evaluate(() => window.__scanStats.active === 1
&& document.getElementById('scan-settings')._snapshot.states['z2m:zigbee2mqtt'].jobId === 'job-3'));
await page.evaluate(async () => {
const element = document.getElementById('scan-settings');
element.hass = { ...element.hass, user: { id: 'viewer-800', is_admin: false } };
+2
View File
@@ -616,6 +616,8 @@ cancel becomes available; it cancels HP's wait, not Z2M radio work. Matching
provider errors, invalid maps and MQTT disconnect terminate the job, release
listeners and retain last-good data as stale. Neither reconnect nor reopening
settings republishes. Integration unload/restart clears all runtime jobs/cache.
Before unload, a `closed` session event invalidates observers even when their HA
WebSocket stays connected; the next explicit scan reattaches to the new coordinator.
The frontend derives elapsed time from server elapsed plus local monotonic time,
without per-second map events or invented progress percentages. Mounted visible
settings alone tick their timer; ordinary hass updates do not resubscribe.
+2
View File
@@ -7,6 +7,8 @@
after 10 minutes you can cancel House Plan's wait without stopping Zigbee2MQTT
or losing the last successful map. Update and restart the integration too
([#800](https://github.com/Matysh/houseplan-card/issues/800)).
- Reloading the integration also clears an old scan from an already-open page,
so its next explicit scan can attach to the new session ([#800](https://github.com/Matysh/houseplan-card/issues/800)).
## v1.80.0-beta.2 — 2026-10-05
+2
View File
@@ -13,6 +13,8 @@
после 10 минут можно отменить ожидание House Plan, не останавливая Zigbee2MQTT
и не теряя последнюю полученную карту. Обновите и перезапустите также интеграцию
([#800](https://github.com/Matysh/houseplan-card/issues/800)).
- После перезагрузки интеграции старое ожидание сбрасывается и на уже открытой
странице: следующий явный запуск подключается к новой сессии ([#800](https://github.com/Matysh/houseplan-card/issues/800)).
## v1.80.0-beta.2 — 2026-10-05
+4 -4
View File
@@ -19,18 +19,18 @@ Everything computable from the tree and git; regenerate, never edit by hand
| Generated | 2026-10-05 — rerun `node scripts/status-snapshot.mjs` for the current tree |
| Version | **1.80.0-beta.2** in all 7 version sources (`scripts/release-contract.mjs`) |
| Latest stable tag | `v1.79.0` |
| Latest prerelease tag | `v1.80.0-beta.1` |
| Tests | Node unit 3607 · pure backend 423 · HA-harness backend 311 · browser smokes 295 (`npm run inventory`) |
| Latest prerelease tag | `v1.80.0-beta.2` |
| Tests | Node unit 3610 · pure backend 423 · HA-harness backend 330 · browser smokes 296 (`npm run inventory`) |
<!-- status-snapshot:end -->
## Current cycle and standing decisions
| Item | State |
|---|---|
| Current local cycle | **Beta v1.80.0-beta.2 candidate** — #799 fixes premature timeout of long Zigbee2MQTT route scans. Owner-authorized express local checks; full exact-SHA Validate and artifact verification precede publication. `main` remains on stable v1.79.0. |
| Current local cycle | **#800 implementation and independent local review** — owner approved background Zigbee2MQTT scans and the next beta after completion. Full exact-SHA Validate and artifact verification precede publication. `main` remains on stable v1.79.0. |
| Branches | `main` carries stable releases only; pre-release tags point at `dev`. Work lands on `dev`, which is equal to or ahead of `main`, never behind. |
| Zigbee routes | #798 replaces inferred neighbour trees with integration-reported end-parent and active coordinator next-hop evidence. Unknown/conflicting routes are not guessed; stale/partial snapshots remain labelled. Solid arrows have a separate 0–255 palette; ordinary device LQI colours are unchanged. |
| Zigbee scan deadline | #799 gives route-table scans one 10-minute total budget, with separate 10-second MQTT setup/publish caps and the existing 4-second retained-info check. Fake-clock regressions cover late success, hard expiry, stale-cache retention and cleanup. |
| Zigbee scan lifecycle | #800 replaces #799's 10-minute total budget with integration-owned background jobs: closing the browser does not stop waiting; after 10 minutes an exact-job cancel becomes available. MQTT setup/publish caps and the retained-info check remain bounded. Tests cover 15-minute success, cross-client restore, cancel races, stale-cache retention and reload on a live HA connection. |
| LED regression acceptance | #788 covers mixed face/free and fractional-coordinate joins, winding-safe field unions, retained endpoints/acute vertices and circle–wall transition events. The actual owner export is exercised locally; public fixtures are synthetic. Pixel oracles, not just SVG counts, protect the visible falloff. |
| LED core colour | #790 makes the on core follow the resolved light colour with or without Glow. Off, unavailable and non-live static strips keep their neutral presentation; geometry and field brightness are unchanged. |
| LED performance work | #789 batches source entry, reuses render-local barriers and temporarily paints 24 of 48 retained gradient bands during actual zoom; full quality returns after 160 ms of scale inactivity. Emitters, clips and idle pixels are unchanged. Exact-SHA 50×50 acceptance includes restoration and quick restart; implementation alone does not certify the camera budget. |
+10 -2
View File
@@ -344,8 +344,16 @@ For an occasional Zigbee placement check, an administrator can enable
is off by default. Load the provider snapshot there: **Read ZHA data** reads
ZHA's existing cache, while **Update map** starts an explicit Zigbee2MQTT raw
network-map scan for each entered base topic (default `zigbee2mqtt`). The latter
may take several minutes when collecting routes. House Plan waits up to 10 minutes;
do not start another scan while it is loading. Scanning can temporarily slow the Zigbee network.
may take many minutes when collecting routes. The scan continues in Home Assistant
after you close settings or the browser. Reopening settings restores its stage and
elapsed timer, including on another administrator's device. Progress is not a
percentage: Zigbee2MQTT does not provide one. After 10 minutes, waiting continues
and **Cancel request** becomes available. It only stops House Plan waiting for this
result, not the radio scan in Zigbee2MQTT. Do not start another scan until that scan
has finished; scanning can temporarily slow the Zigbee network. The last successful
map stays available, marked stale after an error or cancellation. Restarting Home
Assistant or reloading the integration clears these temporary jobs and cached maps.
Update and restart the House Plan integration as well as updating the card.
After data is loaded, moving a real mouse over a mapped Zigbee marker shows
its parent (end device) or active next hop towards the coordinator (router),
@@ -1,4 +1,4 @@
# Локальное предварительное ревью ТЗ #800
# Локальная предварительная проверка ТЗ #800 (до канонического r1)
Итог второго предварительного прохода: зелёный.
High: 0 · Medium: 0 · Low: 0.
+30
View File
@@ -234,6 +234,36 @@
"since": "1.60.2-beta.1",
"audience": ["styling", "test"]
},
"zigbee-scan-background": {
"elements": ["span"],
"since": "1.80.0-beta.3",
"audience": ["test"]
},
"zigbee-scan-cancel": {
"elements": ["button"],
"since": "1.80.0-beta.3",
"audience": ["test"]
},
"zigbee-scan-cancel-hint": {
"elements": ["span"],
"since": "1.80.0-beta.3",
"audience": ["test"]
},
"zigbee-scan-elapsed": {
"elements": ["span"],
"since": "1.80.0-beta.3",
"audience": ["test"]
},
"zigbee-scan-long-wait": {
"elements": ["span"],
"since": "1.80.0-beta.3",
"audience": ["test"]
},
"zigbee-scan-stage": {
"elements": ["span"],
"since": "1.80.0-beta.3",
"audience": ["test"]
},
"zoom-fit": {
"elements": ["button"],
"since": "1.73.0-beta.7",
+199
View File
@@ -0,0 +1,199 @@
# Независимое локальное код-ревью #800 — r1
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 3 → в задаче · Low: 1 · маршрут: fix.
## Материал раунда
- Дата: 2026-10-05. Ревьюер: отдельный агент `/root/code_review_800`, не автор
ТЗ, не ревьюер ТЗ и не исполнитель реализации.
- Issue: [#800](https://github.com/Matysh/houseplan-card/issues/800), `track:ask`.
- Ветка: `issue/800-zigbee-background-scan`.
- База: `1faed2af7d89f768e7fd15a55489c5f8317f7032`.
- Продуктовый материал: `379de706d29f4b1547c3b162c1c205654487c6b8`.
26 файлов, 2294 добавленных и 784 удалённых строки. SHA повторно проверен
перед составлением вердикта. Трейлеры `Issue: #800`, `User-Visible: yes`
и оба changelog находятся в продуктовом коммите.
- ТЗ: полное тело issue, сверенное с переданным `hp800-spec.md`; SHA-256
нормализованного тела из принятого ТЗ:
`de6f7a8816ce796cb3f53713838505a66a5b098d986cf39d56f9e8ec74a55d09`.
Локальный независимый порядок разрешён владельцем
[только для #800](https://github.com/Matysh/houseplan-card/issues/800#issuecomment-5998860326).
Это не результат штатной модели и не подтверждение CI/слияния/выпуска.
Ревьюер не менял продукт, метки, GitHub, коммиты или ветки.
Во время разбора автор отдельно поправил только гонку фикстуры в
`demo/smoke_zigbee_topology_hover.mjs`: ожидание `backend_required` перед
искусственной подстановкой возраста ZHA. Прочитан незакоммиченный diff;
SHA-256 файла `332f80111ee4c3f06e2a18643cbee11eaaf08460102aa2e6444daf9364d13d96`.
Он не подменяет материал `379de706d`. Generated `dist/` после гейта также
не входит в рассматриваемый продуктовый коммит. Последующие исправления
находок этого отчёта требуют r2 по дельте.
## Скоуп и способ проверки
Работа обслуживает J7: пространственную диагностику Zigbee. Прочитаны новый
coordinator/WS, lifecycle интеграции, frontend runtime/settings/overlay,
тесты, локали, изменения документации и декларации мутантов. Алгоритмы
маршрутов/LQI и сохранённая геометрия не меняются.
| Проверка | Результат и происхождение |
| --- | --- |
| `git diff --check 1faed2af..379de706d` | PASS, исполнено ревьюером |
| `node --test test/zigbee-topology.test.mjs test/zigbee-topology-runtime-routes.test.mjs test/zigbee-provider-routes.test.mjs test/zigbee-topology-style.test.mjs` | 52/52 PASS, исполнено ревьюером на подготовленном `test-build`; полный typecheck/build — ниже |
| `node scripts/mutation-gate.mjs --check` | PASS, исполнено ревьюером; предупреждения существующего browser inventory. Мутанты не исполнялись по PROCESS §2.7 |
| `npm run gate:small` | FAIL, полный лог автора `C:/Temp/hp800-gate-small.log` прочитан ревьюером: 3612 PASS, 6 FAIL, 62 SKIP; отдельно FAIL `lint:unused` по bundleBytes. Детали M3 |
| `npm run typecheck`, `npm run bundle:sync` | PASS по переданному автором результату; build/typecheck также прошёл до unit-стадии полного гейта |
| `/home/matysh/houseplan-card-576-final/.venv-ci/bin/python -m pytest tests_backend/test_ha_zigbee_topology.py -q --tb=short` | 32 PASS по результату backend-исполнителя; HA 2026.8.3 / pytest-homeassistant-custom-component 0.13.357, WSL Ubuntu |
| Тот же Python: `-m pytest tests_backend -q --tb=short` | 1007 PASS, 1 SKIP по результату backend-исполнителя; не нативный Windows pure-only прогон |
| `/home/matysh/houseplan-card-576-final/.venv-ci/bin/ruff check custom_components/houseplan` | PASS по результату backend-исполнителя |
| Node 22.23.2: `node demo/smoke_zigbee_topology_job.mjs` | 15/15 PASS по результату автора в `/home/matysh/hp800-6yPzWT`; ревьюер прочитал assertions и просмотрел narrow light/dark PNG |
| `node demo/smoke_general_settings_form.mjs` | 30/30 PASS по результату автора |
| `node demo/smoke_zigbee_topology_hover.mjs` | Первоначально FAIL на искусственном возрасте; после указанного выше fixture-only изменения PASS по результату автора. Не считать исходный smoke зелёным на неизменённом материале |
| Reload-проба текущего production runtime | FAIL ожидаемого восстановления, исполнено ревьюером без изменения файлов; M1 |
WSL-проверки исполнителя запускались через `wsl -d Ubuntu --cd
/home/matysh/hp800-6yPzWT -- ...`. Эта копия содержала актуальные файлы, но её
Git HEAD оставался базовым: это локальное доказательство исполнения файлов,
не exact-SHA CI attestation. Backend-логи ревьюером повторно не исполнялись;
числа переданы исполнителем и не выдаются за собственный прогон.
### smoke-select
Исполнена команда `node scripts/smoke-select.mjs --base
1faed2af7d89f768e7fd15a55489c5f8317f7032 --head
379de706d29f4b1547c3b162c1c205654487c6b8`: 12 прямых и 2
зарегистрированных совпадения. Решения перечислены полностью:
| Строка выборки | Решение |
| --- | --- |
| `smoke_zigbee_topology_hover`, `smoke_zigbee_topology_job` | Применимы; результаты выше |
| `smoke_bg_color` | Не исполнен: совпало общее имя `_snapshot`, фон не менялся |
| `smoke_danger_confirm_branches`, `smoke_dialog_help_clipping`, `smoke_dialog_modal_recovery`, `smoke_ha_form_shell_parity`, `smoke_preloader_lifecycle`, `smoke_space_card_bg`, `smoke_summary_dialog_scroll` | Не исполнены: совпало общее имя `connectedCallback`; соответствующие компоненты не менялись. Общие настройки дополнительно покрыты профильным smoke |
| `smoke_support_feedback` | Не исполнен: `errorCode` здесь локальная функция другого модуля, support не менялся |
| `smoke_warm_owners` | Не исполнен: совпало общее имя `disconnectedCallback`; lifecycle самого Zigbee проверяется профильным smoke/runtime suite |
| `smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness` | Не исполнены: связь по удалённому локальному `keyOf` Zigbee совпала с независимым геометрическим символом; resize не менялся |
Полная smoke-матрица остаётся предрелизной проверкой. Этот отчёт не объявляет
неисполненные строки зелёными.
## Находки
### M1 — живой браузер остаётся на умершем job после reload интеграции
`custom_components/houseplan/zigbee_topology.py:422–436` очищает `_listeners`
без сообщения существующим WS-наблюдателям. При reload только интеграции
соединение HA WebSocket остаётся открытым. Frontend сохраняет `unsubscribe`
старого coordinator и старый `session`; `startFeed` не создаёт новую подписку,
пока старый unsubscribe существует, а `accept` отбрасывает ответ команды
нового coordinator как чужую сессию (`src/zigbee-topology-runtime.ts:102,
142, 169–172`).
Воспроизведение: оставить настройки/overlay подписанными, запустить scan,
reload интеграции без перезагрузки браузера. Старое ожидание продолжает
рисоваться, «Обновить карту» остаётся disabled. После 600 секунд отмена
старого id получает `conflict`, который frontend намеренно игнорирует.
Даже явный start нового job из того же runtime не принимается клиентским
кешем. Закрытие одних настроек не помогает, пока overlay удерживает общий feed.
Ревьюер исполнил непереписывающую Node-пробу: production TypeScript
транспилирован в памяти, принят `old-job`, затем смоделирована фактическая
очистка listener при смене coordinator, вызваны cancel и start новой сессии.
Вывод: после reload `phase: loading`, `jobId: old-job`, `serverStarts: 2`,
`observers: 0`. Контракт §4.7/§6 и AC4/AC6 нарушен.
Нужно передать клиенту инвалидирование старой серверной сессии и обеспечить
восстановление наблюдения после reload, без автоматического сканирования.
Нужен тест с подписанным клиентом до reload и тем же живым WS после него.
Нынешний backend unload-тест до выгрузки вообще не подписывается.
### M2 — AC1 не имеет свидетеля, чувствительного к прежнему 600-секундному deadline
`tests_backend/test_ha_zigbee_topology.py:142–147, 175–219` изменяет только
импортированные `zigbee.monotonic` и `zigbee.time`. `clock.now += 600/300`
не продвигает `hass.loop.time()` и очередь `asyncio`-таймеров. Следовательно,
добавление прежнего общего `asyncio.timeout(600)` вокруг ожидания карты не
успеет сработать в этом тесте: он всё равно получит немедленный fake ответ
и будет зелёным. Frontend smoke также продвигает число в fake server state,
а не backend deadline.
Чтением подтверждено, что нынешний `_run` общего таймаута не содержит; это
не доказательство требуемой регрессии. AC1 прямо требует отрицательную пробу
прежнего лимита, а PROCESS §2.7 — названный свидетель защиты. Соответствующей
декларации в `scripts/mutation-registry.mjs` нет.
Нужно управлять реальным временем event loop/запланированными timeout callbacks
в witness и зарегистрировать мутацию восстановления общего deadline. Мутант
в этом цикле не запускать: его исполнение остаётся ночи.
### M3 — материал не проходит обязательный gate:small
В прочитанном полном логе есть относящиеся к новому материалу отказы:
- `test/data-hp-contract.test.mjs`: шесть новых `zigbee-scan-*` хуков не
объявлены публичными или внутренними.
- `test/reviews-index.test.mjs`: `PREFLIGHT-SPEC-800.md` не соответствует
схеме имён; индекс новых review-документов не приведён в согласованное состояние.
- `test/smoke-select.test.mjs`: новые backend `.py` пути в `SMOKE_LINKS`
нарушают существующую схему `src/*.ts`.
- `lint:unused`: `bundleBytes` 2 696 965 против базы 2 686 188, рост 10 777 Б
при полосе 2 000 Б. Не приложено требуемое обоснование/согласованная база.
Это воспроизводимый отказ обязательного AC-гейта, не допустимое умолчание
перед merge. Исправить декларации и размер/его процессное обоснование,
повторить полный gate в каноническом окружении. Три остальных Windows-отказа
(`dev-build`, `nightly-workflow`, `iso-overlay-fixture-types`) отдельно
не объявляются продуктовыми регрессиями #800: требуется результат Linux/WSL,
но нельзя заранее засчитать их зелёными.
### L1 — English guide всё ещё обещает прекращение ожидания через 10 минут
`docs/USER-GUIDE.md:347` по-прежнему говорит «House Plan waits up to 10 minutes».
Это прямо противоположно новой функции. ТЗ §10 требует USER-GUIDE RU/EN и
STATUS; English guide и STATUS не изменены. Исправить вместе с возвратом
задачи; это не основание расширять продуктовый скоуп.
## AC и «чем краснеет»
| AC | Чем доказан / граница доказательства | Чем краснеет |
| --- | --- | --- |
| AC1 | Чтением отсутствие scan deadline; backend `test_issue_800_background_15_minutes_two_clients_and_reopen` доказывает elapsed/приём ответа без UI, но не прохождение реального deadline | Недостающий отрицательный witness прежнего лимита — M2 |
| AC2 | Backend two-clients/reopen; frontend `one shared feed`, `late 15m result`; browser reload | Повтор start обязан оставить publications=1; detach обязан оставить server job живым; cleanup mutant `zigbee-topology-z2m-subscriptions-leak` |
| AC3 | Backend cancel-boundary/old-id/race; frontend 599/600/hours, cancelled-last-good; keyboard smoke | 599 отказ / 600 разрешение, старый id при уже cancellable новом job; мутанты `zigbee-background-stale-cancel-affects-new-job`, `zigbee-background-cancel-too-early` |
| AC4 | Runtime ownership/late-ack/identity tests; browser elapsed/one subscription/detach/admin loss; просмотр light/dark PNG | Late callback после detach, stale action response, identity replacement не должны менять новый snapshot. Reload живого WS не покрыт и сломан — M1 |
| AC5 | HA permission/topic/retained/foreign/oversize/invalid-shape/instant-error/disconnect/timeout/capacity/teardown tests; frontend old-backend test | Реальные non-admin WS отказы; неверная transaction/retain/oversize сохраняют last-good; 9-й active slot отвергнут; registry `zigbee-topology-z2m-foreign-response-accepted`, `zigbee-topology-z2m-malformed-response-waits-for-timeout`; timeout/disconnect negative cases в тестах |
| AC6 | 52 unit PASS включая прежние route fixtures, ZHA cached read; чтением отсутствие сериализации runtime; backend unload очищает runtime | ZHA asserts точный `zha/devices` и никогда radio update; мутант `zigbee-topology-zha-read-starts-scan`; frontend forbidden MQTT call; integration reload клиента требует M1 |
## Что корректно и что не проверялось
Корректны по чтению и названным тестам: резервирование job до async работы,
один запрос на topic, transaction/job-id isolation, admin-only WS независимо
от editor policy, bounded payload/slots, last-good/stale, очистка поздних
subscribe acknowledgements, немедленная correlated provider error даже внутри
publish, отсутствие frontend MQTT fallback и отсутствие автоматических retry.
ZHA, resolver routes/LQI и persisted store version не переписаны.
Число, видимое на обеих UI-поверхностях, — elapsed: обе вызывают один
`zigbeeScanElapsedMs` и `formatZigbeeScanElapsed`. Сервер авторитетен по
600-секундному порогу, передаёт `cancel_after_ms`; frontend fallback не
ослабляет серверный guard. Нет ежесекундной передачи карты.
На заявленном HA floor наличие `ConfigEntry.async_create_background_task`
и MQTT connection-status API сверено чтением официального
[HA 2024.6.4 config_entries](https://github.com/home-assistant/core/blob/2024.6.4/homeassistant/config_entries.py)
и [MQTT module](https://github.com/home-assistant/core/blob/2024.6.4/homeassistant/components/mqtt/__init__.py).
Отсутствие необязательного readiness helper отдельно моделируется тестом;
полный запуск старого HA не выполнялся.
Риски: async/data/permissions/host lifecycle применимы и рассмотрены;
геометрия не менялась, invariants не требуются. Визуал ограничен настройками;
narrow light/dark изображения просмотрены, cancel не обрезан. Touch editor:
not exposed; новые настройки доступны touch, целевой размер проверен smoke,
настоящий touch-device/экранный диктор не испытывались. Работа диктора
проверена только чтением `role=status` и `aria-live=off` у таймера.
Реальная сеть владельца из 67 устройств, Linux full Validate точного SHA,
полная smoke/golden/performance матрица и выпуск не проверялись. Это не
обещание скорости радио-сканирования Z2M. Golden не назначен ТЗ/меткой;
runtime-кеш ограничен чтением/тестами, отдельный performance benchmark не
назначен. До закрытия M1–M3 зелёный вердикт и merge невозможны.
+3 -1
View File
@@ -1,12 +1,14 @@
# Индекс ревью
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 301, issue: 151. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 303, issue: 152. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|---|---|---|---|---:|---:|---|---|
| бета v1.80.0-beta.1 | [SHIP-REVIEW-v1.80.0-beta.1.md](SHIP-REVIEW-v1.80.0-beta.1.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
| бета v1.79.0-beta.2 | [SHIP-REVIEW-v1.79.0-beta.2.md](SHIP-REVIEW-v1.79.0-beta.2.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
| бета v1.79.0-beta.1 | [SHIP-REVIEW-v1.79.0-beta.1.md](SHIP-REVIEW-v1.79.0-beta.1.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — |
| #800 | [SPEC-REVIEW-800-r1.md](SPEC-REVIEW-800-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #800 | [CODE-REVIEW-800-r1.md](CODE-REVIEW-800-r1.md) | code · r1 | 🟡 жёлтый | 0 | 3 | живой браузер остаётся на умершем job после reload интеграции; AC1 не имеет свидетеля, чувствительного к прежнему 600-секундному deadline; материал не проходит обязательный gate:small; English guide всё ещё обещает прекращение ожидания через 10 минут | `custom_components/houseplan/zigbee_topology.py` `scripts/mutation-registry.mjs` `test/data-hp-contract.test.mjs` `test/reviews-index.test.mjs` `PREFLIGHT-SPEC-800.md` `test/smoke-select.test.mjs` `docs/USER-GUIDE.md` |
| #799 | [CODE-REVIEW-799-r1.md](CODE-REVIEW-799-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #798 | [SPEC-REVIEW-798-r1.md](SPEC-REVIEW-798-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #798 | [CODE-REVIEW-798-r1.md](CODE-REVIEW-798-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
+1 -1
View File
@@ -20,7 +20,7 @@ SHA базы материала: `1faed2af7d89f768e7fd15a55489c5f8317f7032`.
не результат неотработавшей модели. Сбой штатного S4 не считается
отрицательным содержательным вердиктом.
Предварительные проходы, описанные в [PREFLIGHT-SPEC-800.md](PREFLIGHT-SPEC-800.md),
Предварительные проходы, описанные в [800-spec-preflight.md](../analysis/800-spec-preflight.md),
не занимают канонические r1/r2 и не расходуют блокирующие циклы. Текущее тело
побайтно после штатной нормализации совпадает с зелёным материалом второго
preflight-прохода. Обе предварительные Medium исправлены до этого r1.
+1 -1
View File
@@ -4,5 +4,5 @@
"hostRefs": 5067,
"portPrivates": 103,
"harnessPrivates": 100,
"bundleBytes": 2686188
"bundleBytes": 2697519
}
+24
View File
@@ -9866,6 +9866,30 @@ const MUTANT_DEFINITIONS = [
replace: ' if (monotonic() - job.started) * 1000 < 0:',
}],
},
{
id: 'zigbee-background-scan-restores-ten-minute-deadline',
guard: 'node scripts/backend-test-guard.mjs issue_800_background_15_minutes_two_clients_and_reopen '
+ 'tests_backend/test_ha_zigbee_topology.py',
because: '#800 AC1: advancing the actual asyncio loop clock through 600 and 900 seconds '
+ 'must keep the server job waiting; the cancel threshold is not a scan deadline',
patches: [{
file: 'custom_components/houseplan/zigbee_topology.py',
find: ' await self.hass.loop.create_future()',
replace: ' async with asyncio.timeout(600):\n await self.hass.loop.create_future()',
}],
},
{
id: 'zigbee-background-unload-leaves-ui-waiting',
guard: 'node scripts/backend-test-guard.mjs issue_800_entry_unload_clears_jobs_and_new_entry_session_is_idle '
+ 'tests_backend/test_ha_zigbee_topology.py',
because: '#800 AC4/AC6: integration reload does not close HA WebSocket; existing observers '
+ 'must receive invalidation before their coordinator is discarded',
patches: [{
file: 'custom_components/houseplan/zigbee_topology.py',
find: ' self._emit({"kind": "closed", "session_id": self.session_id,\n "revision": self.revision})',
replace: ' pass # mutant: silently discard subscribers',
}],
},
{
id: 'touch-pinch-click-block-cleared-on-terminal',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+1 -2
View File
@@ -30,8 +30,7 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
files: ['src/zigbee-topology-runtime.ts', 'src/hp-zigbee-topology-settings.ts',
'custom_components/houseplan/zigbee_topology.py', 'custom_components/houseplan/zigbee_websocket.py'],
files: ['src/zigbee-topology-runtime.ts', 'src/hp-zigbee-topology-settings.ts'],
smokes: ['smoke_zigbee_topology_job.mjs'],
because: '#800: background-job restore across a real page reload, elapsed/cancel boundary, '
+ 'mounted UI lifecycle and mobile keyboard target are observed through the production '
+21 -1
View File
@@ -90,6 +90,18 @@ function accept(cache: Cache, message: unknown, fromSubscription: boolean): void
if (!envelope || typeof envelope.session_id !== 'string' || !Number.isSafeInteger(envelope.revision)) return;
const revision = envelope.revision as number;
if (revision < 0) return;
if (envelope.kind === 'closed') {
if (!fromSubscription || envelope.session_id !== cache.session || revision < cache.serverRevision) return;
// Integration reload does not close the HA socket. Retire this observer and
// all pending old-session work, but retain mounted UI listeners for recovery.
cache.generation++;
cleanup(cache.unsubscribe); cache.unsubscribe = undefined;
cache.feedPending = cache.capability = undefined;
cache.session = undefined; cache.serverRevision = -1; cache.topicRevisions.clear();
for (const key of cache.inflight.keys()) if (key.startsWith('z2m:')) cache.inflight.delete(key);
removeTopics(cache, new Set());
cache.backendConnected = false; cache.backendError = 'backend_required'; notify(cache); return;
}
if (envelope.kind === 'reset') {
if (!fromSubscription || !Array.isArray(envelope.topics) || envelope.topics.length > 8) return;
if (cache.session === envelope.session_id && revision < cache.serverRevision) return;
@@ -216,6 +228,14 @@ function command(hass: ZigbeeTopologyHass, baseTopic: string, jobId?: string): P
const promise = (async () => {
requireAdmin(hass); if (!topic) throw fail('invalid_topic');
await capability(cache, hass); if (!active()) return;
if (jobId === undefined) {
// Explicit start also recovers an observer invalidated by integration reload.
// Joining it never publishes; only the command below starts a server job.
startFeed(cache, hass);
await cache.feedPending;
if (!active()) return;
if (cache.listeners.size && !cache.unsubscribe) throw fail(cache.backendError || 'backend_required');
}
const result = await hass.callWS!({ type: `houseplan/zigbee/${jobId === undefined ? 'start' : 'cancel'}`,
base_topic: topic, ...(jobId === undefined ? {} : { job_id: jobId }) });
if (active()) accept(cache, result, false);
@@ -228,7 +248,7 @@ function command(hass: ZigbeeTopologyHass, baseTopic: string, jobId?: string): P
if (jobId === undefined && cache.states[key]?.phase !== 'loading') {
state(cache, key, { ...cache.states[key], phase: 'error', error: code, stale: cache.states[key]?.obtainedAt !== undefined });
} else { cache.backendError = code; notify(cache); }
}).finally(() => cache.inflight.delete(actionKey));
}).finally(() => { if (cache.inflight.get(actionKey) === promise) cache.inflight.delete(actionKey); });
cache.inflight.set(actionKey, promise); return promise;
}
/** Quick server-owned job start: no browser MQTT, scan timer, or fallback. */
@@ -281,3 +281,76 @@ test('#800 stale action response cannot roll back later streamed terminal state'
provider: { ...provider, phase: 'loading', elapsed_ms: 0 } });
await pending; assert.equal(b.snapshot().states[KEY].phase, 'ready'); assert.equal(b.snapshot().states[KEY].elapsedMs, 900000); off();
});
test('#800 integration closed invalidates mounted jobs; explicit same-WS start reattaches without duplicate scan', async () => {
const b = backend(), off = b.observe(); await refreshZ2mTopology(b.hass, TOPIC);
b.update(TOPIC, { phase: 'ready', result: map, obtained_at: 80 });
await refreshZ2mTopology(b.hass, TOPIC);
const oldCallback = b.callbacks[0], oldState = b.envelope(b.jobs.get(TOPIC));
const oldPublications = b.publications;
b.emit({ kind: 'closed', session_id: b.session, revision: ++b.revision });
assert.deepEqual(b.snapshot().states, {}, 'unloaded coordinator cannot leave disabled Update map/loading UI');
assert.deepEqual(b.snapshot().topologies, [], 'unloaded coordinator last-good cache is gone');
assert.equal(b.snapshot().backendConnected, false);
assert.equal(b.cleanups, 1); assert.equal(b.live.size, 0);
await turn(); assert.equal(b.publications, oldPublications, 'close never scans or automatically retries');
const closedRevision = b.snapshot().revision;
oldCallback({ ...oldState, revision: 999 });
oldCallback({ kind: 'reset', session_id: 'obsolete', revision: 1000, topics: [TOPIC] });
assert.equal(b.snapshot().revision, closedRevision, 'late old feed cannot revive its session');
b.session = 'reloaded'; b.revision = 0; b.jobs.clear();
const start = refreshZ2mTopology(b.hass, TOPIC);
assert.equal(refreshZ2mTopology({ ...b.hass }, TOPIC), start);
await start;
assert.equal(b.callbacks.length, 2); assert.equal(b.live.size, 1);
assert.equal(b.publications, oldPublications + 1);
assert.equal(b.snapshot().states[KEY].phase, 'loading');
assert.equal(b.snapshot().backendConnected, true);
b.update(TOPIC, { phase: 'ready', result: map, obtained_at: 90 });
assert.equal(b.snapshot().states[KEY].obtainedAt, 90, 'new session streams without remount');
off(); assert.equal(b.cleanups, 2);
});
test('#800 closed while subscribe ACK is pending cleans late ACK without releasing new feed', async () => {
const b = backend(); b.subscribeGate = deferred();
const oldAck = b.subscribeGate, off = b.observe(); await turn();
b.emit({ kind: 'closed', session_id: b.session, revision: ++b.revision });
b.session = 'reloaded'; b.revision = 0; b.jobs.clear(); b.subscribeGate = null;
await refreshZ2mTopology(b.hass, TOPIC);
assert.equal(b.callbacks.length, 2);
oldAck.resolve(); await turn();
assert.equal(b.live.size, 1); assert.equal(b.cleanups, 1);
b.update(TOPIC, { phase: 'ready', result: map, obtained_at: 99 });
assert.equal(b.snapshot().states[KEY].obtainedAt, 99);
off(); assert.equal(b.cleanups, 2);
});
test('#800 only current-session non-stale closed event can invalidate runtime', async () => {
const b = backend(), off = b.observe(); await refreshZ2mTopology(b.hass, TOPIC);
const before = b.snapshot();
b.emit({ kind: 'closed', session_id: 'foreign', revision: 999 });
b.emit({ kind: 'closed', session_id: b.session, revision: b.revision - 1 });
assert.equal(b.snapshot().revision, before.revision); assert.equal(b.live.size, 1); off();
});
test('#800 late command completion from closed session cannot erase new command deduplication', async () => {
const b = backend(), off = b.observe(); await turn();
const oldReply = deferred(); b.commandGate = oldReply;
const oldStart = refreshZ2mTopology(b.hass, TOPIC); await turn();
const oldEvent = { kind: 'state', session_id: b.session, revision: 10, provider: {
topic: TOPIC, job_id: 'old', phase: 'loading', elapsed_ms: 100,
} };
b.emit({ kind: 'closed', session_id: b.session, revision: ++b.revision });
b.session = 'reloaded'; b.revision = 0;
const newReply = deferred(); b.commandGate = newReply;
const newStart = refreshZ2mTopology(b.hass, TOPIC); await turn();
oldReply.resolve(oldEvent); await oldStart;
assert.deepEqual(b.snapshot().states, {}, 'late action result from unloaded session stays rejected');
assert.equal(refreshZ2mTopology(b.hass, TOPIC), newStart, 'old finally must not delete new in-flight action');
newReply.resolve({ ...oldEvent, session_id: b.session, revision: 1,
provider: { ...oldEvent.provider, job_id: 'fresh', elapsed_ms: 0 } });
await newStart;
assert.equal(b.snapshot().states[KEY].jobId, 'fresh');
assert.equal(b.calls.filter(c => c.type.endsWith('/start')).length, 2);
assert.equal(b.callbacks.length, 2); off();
});
+40 -8
View File
@@ -173,7 +173,7 @@ async def _start(client, transport, *, topic="zigbee2mqtt"):
async def test_issue_800_background_15_minutes_two_clients_and_reopen(
hass, hass_ws_client, transport, clock,
hass, hass_ws_client, transport, clock, monkeypatch,
):
entry, coordinator = await _setup(hass)
first, second = await hass_ws_client(hass), await hass_ws_client(hass)
@@ -190,14 +190,24 @@ async def test_issue_800_background_15_minutes_two_clients_and_reopen(
await second.close()
await hass.async_block_till_done()
assert len(transport.published) == 1 and not coordinator._listeners
await _until(lambda: coordinator.snapshot("zigbee2mqtt")["provider"]["stage"] == "waiting")
clock.now += 600
await hass.async_block_till_done()
assert coordinator.snapshot("zigbee2mqtt")["provider"]["phase"] == "loading"
clock.now += 300
waiting = coordinator.snapshot("zigbee2mqtt")["provider"]
assert waiting["elapsed_ms"] == 900_000 and waiting["phase"] == "loading"
answer = transport.respond("zigbee2mqtt", waiting["job_id"])
# Advance actual event-loop deadlines too, not only the displayed elapsed
# counter. A reintroduced asyncio.timeout(600) must run and fail this witness.
original_loop_time, clock_start = hass.loop.time, clock.now
with monkeypatch.context() as timers:
timers.setattr(hass.loop, "time", lambda: original_loop_time() + clock.now - clock_start)
clock.now += 600
for _ in range(4):
await asyncio.sleep(0)
await hass.async_block_till_done()
assert coordinator.snapshot("zigbee2mqtt")["provider"]["phase"] == "loading"
clock.now += 300
for _ in range(4):
await asyncio.sleep(0)
waiting = coordinator.snapshot("zigbee2mqtt")["provider"]
assert waiting["elapsed_ms"] == 900_000 and waiting["phase"] == "loading"
answer = transport.respond("zigbee2mqtt", waiting["job_id"])
await _until(lambda: not coordinator._operations)
assert transport.listener_count == 0 and not transport.status_callbacks
@@ -462,11 +472,26 @@ async def test_issue_800_entry_unload_clears_jobs_and_new_entry_session_is_idle(
):
entry, coordinator = await _setup(hass)
client = await hass_ws_client(hass)
subscription = await _command(client, "subscribe")
assert subscription["success"]
assert (await client.receive_json())["event"]["kind"] == "reset"
await _start(client, transport)
previous_revision = coordinator.revision
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert coordinator.closed and not coordinator._jobs
assert transport.listener_count == 0 and not transport.status_callbacks
async with asyncio.timeout(1):
while True:
event = (await client.receive_json())["event"]
if event["kind"] == "closed":
break
assert event == {"kind": "closed", "session_id": coordinator.session_id,
"revision": previous_revision + 1}
# The same live socket can retire its old feed, then attach to the next
# loaded entry. Receiving closed never publishes an automatic replacement.
await client.send_json_auto_id({"type": "unsubscribe_events", "subscription": subscription["id"]})
assert (await client.receive_json())["success"]
refused = await _command(client, "subscribe")
assert refused["success"] is False and refused["error"]["code"] == "not_ready"
assert await hass.config_entries.async_setup(entry.entry_id)
@@ -475,6 +500,13 @@ async def test_issue_800_entry_unload_clears_jobs_and_new_entry_session_is_idle(
assert replacement.session_id != coordinator.session_id
assert replacement.initial_events()[0]["topics"] == []
assert len(transport.published) == 1
assert (await _command(client, "subscribe"))["success"]
reset = (await client.receive_json())["event"]
assert reset == {"kind": "reset", "session_id": replacement.session_id,
"revision": 0, "topics": []}
restarted = await _start(client, transport)
assert restarted["session_id"] == replacement.session_id
assert len(transport.published) == 2
async def test_issue_800_missing_mqtt_is_optional_and_reports_unavailable(