diff --git a/custom_components/houseplan/zigbee_topology.py b/custom_components/houseplan/zigbee_topology.py index d438bcfc..4be61a55 100644 --- a/custom_components/houseplan/zigbee_topology.py +++ b/custom_components/houseplan/zigbee_topology.py @@ -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) diff --git a/demo/smoke_zigbee_topology_hover.mjs b/demo/smoke_zigbee_topology_hover.mjs index b88a7369..5d5b7fcc 100644 --- a/demo/smoke_zigbee_topology_hover.mjs +++ b/demo/smoke_zigbee_topology_hover.mjs @@ -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')] diff --git a/demo/smoke_zigbee_topology_job.mjs b/demo/smoke_zigbee_topology_job.mjs index dba50a47..e55c4c22 100644 --- a/demo/smoke_zigbee_topology_job.mjs +++ b/demo/smoke_zigbee_topology_job.mjs @@ -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 } }; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 487f5260..cbe23242 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ad8ae4e..fdda74f7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 070cae52..c321fd58 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index d6ccc291..65db0ad7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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`) | ## 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. | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 505776dc..76cc41c8 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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), diff --git a/docs/reviews/PREFLIGHT-SPEC-800.md b/docs/analysis/800-spec-preflight.md similarity index 99% rename from docs/reviews/PREFLIGHT-SPEC-800.md rename to docs/analysis/800-spec-preflight.md index 25568090..9a10bf4b 100644 --- a/docs/reviews/PREFLIGHT-SPEC-800.md +++ b/docs/analysis/800-spec-preflight.md @@ -1,4 +1,4 @@ -# Локальное предварительное ревью ТЗ #800 +# Локальная предварительная проверка ТЗ #800 (до канонического r1) Итог второго предварительного прохода: зелёный. High: 0 · Medium: 0 · Low: 0. diff --git a/docs/data-hp-contract.json b/docs/data-hp-contract.json index 54e7b79f..8204389f 100644 --- a/docs/data-hp-contract.json +++ b/docs/data-hp-contract.json @@ -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", diff --git a/docs/reviews/CODE-REVIEW-800-r1.md b/docs/reviews/CODE-REVIEW-800-r1.md new file mode 100644 index 00000000..a8e1e7a8 --- /dev/null +++ b/docs/reviews/CODE-REVIEW-800-r1.md @@ -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 невозможны. diff --git a/docs/reviews/INDEX.md b/docs/reviews/INDEX.md index 0348a7ea..74cce9a2 100644 --- a/docs/reviews/INDEX.md +++ b/docs/reviews/INDEX.md @@ -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 | — | — | diff --git a/docs/reviews/SPEC-REVIEW-800-r1.md b/docs/reviews/SPEC-REVIEW-800-r1.md index e8336761..e07a37ce 100644 --- a/docs/reviews/SPEC-REVIEW-800-r1.md +++ b/docs/reviews/SPEC-REVIEW-800-r1.md @@ -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. diff --git a/scripts/monolith-baseline.json b/scripts/monolith-baseline.json index f06c9802..32180a63 100644 --- a/scripts/monolith-baseline.json +++ b/scripts/monolith-baseline.json @@ -4,5 +4,5 @@ "hostRefs": 5067, "portPrivates": 103, "harnessPrivates": 100, - "bundleBytes": 2686188 + "bundleBytes": 2697519 } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 450faec8..34f428a7 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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 ' diff --git a/scripts/smoke-links.mjs b/scripts/smoke-links.mjs index 706bd55d..a17dd235 100644 --- a/scripts/smoke-links.mjs +++ b/scripts/smoke-links.mjs @@ -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 ' diff --git a/src/zigbee-topology-runtime.ts b/src/zigbee-topology-runtime.ts index dccffa73..7c1a459d 100644 --- a/src/zigbee-topology-runtime.ts +++ b/src/zigbee-topology-runtime.ts @@ -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. */ diff --git a/test/zigbee-topology-runtime-routes.test.mjs b/test/zigbee-topology-runtime-routes.test.mjs index b365ec5c..9891c5dc 100644 --- a/test/zigbee-topology-runtime-routes.test.mjs +++ b/test/zigbee-topology-runtime-routes.test.mjs @@ -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(); +}); diff --git a/tests_backend/test_ha_zigbee_topology.py b/tests_backend/test_ha_zigbee_topology.py index 3df68a66..0dad8097 100644 --- a/tests_backend/test_ha_zigbee_topology.py +++ b/tests_backend/test_ha_zigbee_topology.py @@ -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(