Compare commits

...
Author SHA1 Message Date
Matysh 9722153e01 docs(review): record independent local review for #799
Issue: #799
User-Visible: no
2026-10-05 16:34:50 +03:00
Matysh 0f77b921f6 fix(zigbee): allow slow route scans without extending transport stalls
Issue: #799
User-Visible: yes
2026-10-05 16:33:06 +03:00
14 changed files with 262 additions and 13 deletions
+4 -2
View File
@@ -602,8 +602,10 @@ prove the complete chain. Mapping/resolution are memoized outside hover.
in-flight dedupe: ZHA reads `zha/devices` without a scan; Z2M checks the retained
bridge-info topic, sends one correlated raw `routes:true` request through
`mqtt.publish`, rejects retained/foreign/late replies and always unsubscribes.
A shared deadline bounds subscriptions, publish and response, including late
subscription cleanup. Refresh failure retains the last-good snapshot with a
A shared 10-minute deadline bounds the route-table scan; each subscription and
publish additionally has a 10-second transport cap, and retained bridge-info
confirmation keeps its 4-second limit (#799). Stages never restart the shared
deadline, including late subscription cleanup. Refresh failure retains the last-good snapshot with a
stale flag; ZHA cache retrieval is never described as a fresh radio scan.
The pointer-transparent overlay is a child of the `.devlayer` camera, projected
once with the markers by `live-viewport.ts`; only the source and drawable
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- Fixed Zigbee2MQTT route-map updates timing out after 150 seconds on larger
networks. House Plan now waits up to 10 minutes for the scan, while stalled
MQTT setup or publishing still fails promptly ([#799](https://github.com/Matysh/houseplan-card/issues/799)).
## v1.80.0-beta.1 — 2026-10-05
- Zigbee hover arrows now use parents and active next hops reported by ZHA or
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Исправлен преждевременный таймаут обновления карты Zigbee2MQTT с маршрутами
через 150 секунд на больших сетях. House Plan ждёт результат до 10 минут,
сохраняя короткие ограничения на зависшие подключение и отправку MQTT
([#799](https://github.com/Matysh/houseplan-card/issues/799)).
## v1.80.0-beta.1 — 2026-10-05
- Стрелки Zigbee теперь показывают родителей и активные следующие узлы по
+1
View File
@@ -30,6 +30,7 @@ Everything computable from the tree and git; regenerate, never edit by hand
| Current local cycle | **Beta v1.80.0-beta.1 candidate** — integration-reported Zigbee routes, LED zoom-lighting optimization and source-coloured strip cores, plus test/process fixes. Exact-SHA full gates 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. |
| 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. |
+2 -1
View File
@@ -344,7 +344,8 @@ 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 10 seconds to 2 minutes and can temporarily slow the Zigbee network.
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.
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),
+3 -2
View File
@@ -361,8 +361,9 @@ Escape и закрытие диалога отбрасывают изменен
опция выключена. Там же надо явно получить снимок: **Прочитать данные ZHA**
читает уже существующий кеш ZHA, а **Обновить карту** запускает raw-сканирование
Zigbee2MQTT для каждого указанного базового топика (по умолчанию
`zigbee2mqtt`). Сканирование занимает от 10 секунд до 2 минут и временно может
снизить отзывчивость Zigbee-сети.
`zigbee2mqtt`). Сбор карты с маршрутами может занять несколько минут; House Plan
ждёт до 10 минут. Не запускайте новое сканирование, пока идёт загрузка.
Сканирование временно может снизить отзывчивость Zigbee-сети.
После загрузки данных наведение реальной мыши на сопоставленный Zigbee-маркер
показывает родителя конечного устройства или активный следующий узел роутера к
+75
View File
@@ -0,0 +1,75 @@
# Код-ревью #799 — ожидание длительного сбора Zigbee2MQTT
Вердикт: зелёный · заход r1 · блокирующих циклов 0/2 · High: 0 · Medium: 0 · Low: 0.
## Материал и разрешение
- Issue: https://github.com/Matysh/houseplan-card/issues/799, `track:show`.
- База: `e69b3f2009c531fbd138efbc2eeb51a364c4fae6`.
- Продуктовый SHA: `0f77b921f66f0fb3104a3e473247a811581b9d64`.
- 12 файлов, 185 добавленных и 12 удалённых строк. Терминальные трейлеры
`Issue: #799`, `User-Visible: yes` и оба changelog присутствуют.
Владелец разрешил исправление и немедленную экспресс-бету с минимальными
локальными проверками. Внешняя модель недоступна: автор выполнил самопроверку,
отдельный локальный агент `/root/zigbee_timeout_backend`, не писавший патч,
прочитал весь дифф и подтвердил SHA перед финальным вердиктом. Это не вердикт
модели конвейера. [Разрешение и план](https://github.com/Matysh/houseplan-card/issues/799#issuecomment-5995516221).
## Результат независимого чтения
Скоуп — J7: явно запущенное получение топологии Zigbee. Нет новых настроек,
автоматических сканирований, миграций и изменений ZHA.
`src/zigbee-topology-runtime.ts` вычисляет общий deadline 600 секунд один раз
до подписок. Каждая подписка и MQTT publish ограничены меньшим из 10 секунд
и остатка общего бюджета; retained bridge-info сохраняет 4 секунды.
Отдельный cap publish не ограничивает десятью секундами ожидание карты.
Promise вложенного timeout включён в Promise.all, необработанного отклонения нет.
Сохранены routes:true, transaction, дедупликация connection/topic, guards
преждевременных/retained/чужих/поздних ответов, late-unsubscribe cleanup,
старый snapshot и timestamp со stale при ошибке, повторный явный запрос.
Число транспортного cap имеет один источник Z2M_TRANSPORT_TIMEOUT_MS.
Четыре локали и обе пользовательские документации согласованы с 600 секундами.
## Проверки
Node 22.23.2, Windows. Ревьюер не дублировал исполнение: проверил сценарии и
assertions чтением, результаты переданы автором и тестовым агентом.
| Гейт | Результат |
| --- | --- |
| 4 Zigbee unit-suite | 57/57 PASS |
| Runtime-suite отдельно | 19/19 PASS |
| Регрессия AC1 на старом runtime, транспилированном из Git в памяти | RED: на виртуальных 150 секундах error вместо loading |
| typecheck, build | PASS |
| bundle-policy --verify на продуктовом SHA | PASS: свежая сборка цела, 42 ассета |
| demo/smoke_zigbee_topology_hover.mjs | PASS, все флаги true |
| smoke-select | Единственная зарегистрированная связь — указанный smoke; выполнен |
| git diff --check | PASS, также исполнено независимым ревьюером |
Первый smoke отказал из-за stale demo; доказательством является последующий
PASS после bundle-sync. Generated bundles очищены перед продуктовым коммитом.
## AC: чем краснеет
| AC | Доказательство | Отрицательный случай |
| --- | --- | --- |
| AC1 | production timeout accepts … after 180 seconds | Старый runtime прекращает ожидание на 150 секунде, тест ждёт loading и затем ready на 180-й |
| AC2: общий предел | ten-minute budget includes subscription setup… | Границы 599999/600000 мс; 18 секунд setup входят в общий бюджет, поздний callback не меняет revision |
| AC2: транспорт | Два subscription-теста и publish stops after ten seconds… | 9999/10000 мс; поздний unsubscribe очищается; ранняя карта не обходит зависший publish |
| AC2: info | retained bridge confirmation… | 3999/4000 мс; нет publish; поздний retained callback не оживляет операцию |
| AC2: сохранённые guards | Старые #798 runtime-тесты и новые cache/retry assertions | Чужая transaction, retained, поздний ответ, stale timestamp, совпадающий promise concurrent-вызовов |
| AC3 | 4 suites, build/typecheck, smoke, независимое чтение | Права/ZHA/конфиг не меняются, длительность обновлена во всех локалях |
## Риски и ограничения
Async/MQTT и кеш покрыты AC1/AC2. Геометрия, ввод и отрисовка маршрутов не
затронуты. Реальное радиооборудование пользователя не проверялось: 10 минут —
ограниченный бюджет, не обещание завершения любой сети. Прекращение ожидания
House Plan не отменяет скан внутри Z2M.
Полный локальный npm test, backend/HA и полные smoke/golden/performance не
выполнялись по express-разрешению. Штатный full Validate точного SHA кандидата
остаётся отдельным условием публикации. Этот отчёт не подтверждает его заранее.
+2 -1
View File
@@ -1,12 +1,13 @@
# Индекс ревью
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 300, issue: 150. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 301, issue: 151. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). 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 | — | — |
| #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 | — | — |
| #795 | [CODE-REVIEW-795-r1.md](CODE-REVIEW-795-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
+1 -1
View File
@@ -12,7 +12,7 @@
"z2m": "Zigbee2MQTT",
"z2m_topics": "Basistopics (eines pro Zeile)",
"z2m_update": "Karte aktualisieren",
"z2m_warning": "Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",
"z2m_warning": "Ein Netzwerkscan mit Routen kann mehrere Minuten dauern. House Plan wartet bis zu 10 Minuten. Der Scan kann Zigbee vorübergehend verlangsamen.",
"status_idle": "Daten nicht geladen",
"status_loading": "Wird geladen…",
"status_ready": "Empfangen: {time}",
+1 -1
View File
@@ -12,7 +12,7 @@
"z2m": "Zigbee2MQTT",
"z2m_topics": "Base topics (one per line)",
"z2m_update": "Update map",
"z2m_warning": "A network-map scan can take 10 seconds to 2 minutes and may temporarily reduce Zigbee responsiveness.",
"z2m_warning": "A network-map scan with routes can take several minutes. House Plan waits up to 10 minutes. Scanning may temporarily reduce Zigbee responsiveness.",
"status_idle": "Data not loaded",
"status_loading": "Loading…",
"status_ready": "Received {time}",
+1 -1
View File
@@ -12,7 +12,7 @@
"z2m": "Zigbee2MQTT",
"z2m_topics": "Topics de base (un par ligne)",
"z2m_update": "Actualiser la carte",
"z2m_warning": "L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",
"z2m_warning": "L’analyse de la carte réseau avec les routes peut prendre plusieurs minutes. House Plan attend jusqu’à 10 minutes. L’analyse peut réduire temporairement la réactivité de Zigbee.",
"status_idle": "Données non chargées",
"status_loading": "Chargement…",
"status_ready": "Reçu : {time}",
+1 -1
View File
@@ -12,7 +12,7 @@
"z2m": "Zigbee2MQTT",
"z2m_topics": "Базовые топики (по одному в строке)",
"z2m_update": "Обновить карту",
"z2m_warning": "Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",
"z2m_warning": "Сканирование карты сети с маршрутами может занять несколько минут. House Plan ждёт до 10 минут. Сканирование временно может снизить отзывчивость Zigbee.",
"status_idle": "Данные не загружены",
"status_loading": "Загрузка…",
"status_ready": "Получено: {time}",
+10 -3
View File
@@ -48,6 +48,10 @@ type Cache = ZigbeeTopologyRuntimeSnapshot & {
const caches = new WeakMap<object, Cache>();
// Route-table scans are sequential in Z2M and can outlast the old 150 s budget.
const Z2M_SCAN_TIMEOUT_MS = 600_000;
const Z2M_TRANSPORT_TIMEOUT_MS = 10_000;
function keyOf(hass: ZigbeeTopologyHass | null | undefined): object | null {
const key = hass?.connection || hass;
return key && (typeof key === 'object' || typeof key === 'function') ? key : null;
@@ -200,7 +204,7 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
/** Explicit, completion-aware Z2M raw network-map request through HA MQTT. */
export function refreshZ2mTopology(
hass: ZigbeeTopologyHass, baseTopic: string, timeoutMs = 150_000,
hass: ZigbeeTopologyHass, baseTopic: string, timeoutMs = Z2M_SCAN_TIMEOUT_MS,
): Promise<void> {
const cache = cacheOf(hass);
if (!cache) return Promise.resolve();
@@ -241,7 +245,7 @@ export function refreshZ2mTopology(
if (finished) cleanup(unsubscribe);
else unsubscribers.push(unsubscribe);
});
await withTimeout(pending, deadline - Date.now());
await withTimeout(pending, Math.min(Z2M_TRANSPORT_TIMEOUT_MS, deadline - Date.now()));
};
try {
await subscribeTo('bridge/info', (message: unknown) => {
@@ -265,7 +269,10 @@ export function refreshZ2mTopology(
qos: 0,
retain: false,
});
const [, value] = await withTimeout(Promise.all([published, response]), deadline - Date.now());
const [, value] = await withTimeout(Promise.all([
withTimeout(published, Math.min(Z2M_TRANSPORT_TIMEOUT_MS, deadline - Date.now())),
response,
]), deadline - Date.now());
if (!active()) throw fail('timeout');
const status = recordOf(value)?.status;
if (status && status !== 'ok') throw fail('provider');
@@ -299,3 +299,155 @@ test('#798 only an admin can request a map, and retained bridge confirmation is
assert.equal(unconfirmed.publishes.length, 0);
assert.deepEqual(unconfirmed.cleanups, [INFO, RESPONSE]);
});
test('#799 the production timeout accepts a correlated routes response after 180 seconds', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
const control = bridge({ publish({ control: current, request }) {
assert.equal(request.routes, true);
setTimeout(() => current.emit(networkmap(request.transaction)), 180_000);
} });
const refresh = refreshZ2mTopology(control.hass, TOPIC);
await turn();
t.mock.timers.tick(150_000);
await turn();
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].phase, 'loading');
assert.equal(refreshZ2mTopology(control.hass, TOPIC), refresh, 'the long scan is still deduplicated');
assert.equal(control.publishes.length, 1);
t.mock.timers.tick(30_000);
await refresh;
const successful = zigbeeTopologyRuntimeSnapshot(control.hass);
assert.equal(successful.states[KEY].phase, 'ready');
assert.equal(successful.states[KEY].obtainedAt, 1_180_000);
assert.deepEqual(successful.topologies[0].nodes.map((node) => node.ieee), [COORDINATOR, DEVICE]);
assert.deepEqual(control.cleanups, [INFO, RESPONSE]);
assert.equal(control.listeners.size, 0);
});
test('#799 the ten-minute budget includes subscription setup, retains cache, and rejects late results', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
let failing = false;
const control = bridge({
subscribe({ unsubscribe }) {
if (failing) return new Promise((resolve) => setTimeout(() => resolve(unsubscribe), 9_000));
},
publish({ control: current, request }) {
if (!failing) current.emit(networkmap(request.transaction));
},
});
await refreshZ2mTopology(control.hass, TOPIC);
const successful = zigbeeTopologyRuntimeSnapshot(control.hass);
failing = true;
const refresh = refreshZ2mTopology(control.hass, TOPIC);
assert.equal(refreshZ2mTopology(control.hass, TOPIC), refresh);
t.mock.timers.tick(9_000);
await turn();
t.mock.timers.tick(9_000);
await turn();
assert.equal(control.publishes.length, 2);
const oldCallback = control.listeners.get(RESPONSE);
const oldTransaction = control.publishes[1].transaction;
t.mock.timers.tick(581_999);
await turn();
const loading = zigbeeTopologyRuntimeSnapshot(control.hass);
assert.equal(loading.states[KEY].phase, 'loading');
assert.equal(loading.topologies[0], successful.topologies[0]);
assert.equal(loading.states[KEY].obtainedAt, successful.states[KEY].obtainedAt);
t.mock.timers.tick(1);
await refresh;
const timedOut = zigbeeTopologyRuntimeSnapshot(control.hass);
assert.equal(Date.now(), 1_600_000);
assert.equal(timedOut.states[KEY].error, 'timeout');
assert.equal(timedOut.states[KEY].stale, true);
assert.equal(timedOut.states[KEY].obtainedAt, successful.states[KEY].obtainedAt);
assert.equal(timedOut.topologies[0], successful.topologies[0]);
assert.deepEqual(control.cleanups, [INFO, RESPONSE, INFO, RESPONSE]);
assert.equal(control.listeners.size, 0);
oldCallback({ retain: false, payload: JSON.stringify(networkmap(oldTransaction, OTHER)) });
await turn();
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).revision, timedOut.revision);
failing = false;
await refreshZ2mTopology(control.hass, TOPIC);
const recovered = zigbeeTopologyRuntimeSnapshot(control.hass);
assert.equal(recovered.states[KEY].phase, 'ready');
assert.equal(recovered.states[KEY].obtainedAt, 1_600_000);
assert.notEqual(recovered.states[KEY].stale, true);
assert.equal(control.publishes.length, 3);
});
for (const blockedTopic of [INFO, RESPONSE]) {
test(`#799 production ${blockedTopic} subscription stops after ten seconds and cleans late completion`, async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
const pending = deferred();
let lateUnsubscribe;
const control = bridge({ subscribe({ topic, unsubscribe }) {
if (topic !== blockedTopic) return;
lateUnsubscribe = unsubscribe;
return pending.promise;
} });
const refresh = refreshZ2mTopology(control.hass, TOPIC);
await turn();
t.mock.timers.tick(9_999);
await turn();
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].phase, 'loading');
t.mock.timers.tick(1);
await refresh;
const timedOut = zigbeeTopologyRuntimeSnapshot(control.hass);
assert.equal(timedOut.states[KEY].error, 'timeout');
assert.equal(control.publishes.length, 0);
pending.resolve(lateUnsubscribe);
await turn();
assert.equal(control.listeners.size, 0);
assert.equal(control.cleanups.length, blockedTopic === INFO ? 1 : 2);
assert.equal(new Set(control.cleanups).size, control.cleanups.length);
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).revision, timedOut.revision);
});
}
test('#799 production publish stops after ten seconds even with an early map response', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
const published = deferred();
let responseCallback;
const control = bridge({ publish({ control: current, request }) {
responseCallback = current.listeners.get(RESPONSE);
current.emit(networkmap(request.transaction));
return published.promise;
} });
const refresh = refreshZ2mTopology(control.hass, TOPIC);
await turn();
t.mock.timers.tick(9_999);
await turn();
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].phase, 'loading');
t.mock.timers.tick(1);
await refresh;
const timedOut = zigbeeTopologyRuntimeSnapshot(control.hass);
assert.equal(timedOut.states[KEY].error, 'timeout');
assert.equal(timedOut.topologies.length, 0);
assert.deepEqual(control.cleanups, [INFO, RESPONSE]);
published.resolve();
responseCallback({ retain: false, payload: JSON.stringify(networkmap(control.publishes[0].transaction)) });
await turn();
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).revision, timedOut.revision);
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).topologies.length, 0);
});
test('#799 production retained bridge confirmation still stops after four seconds', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
const control = bridge({ confirm: false, subscribe({ topic, callback }) {
if (topic === INFO) callback({ retain: false, payload: '{}' });
} });
const refresh = refreshZ2mTopology(control.hass, TOPIC);
await turn();
const lateInfo = control.listeners.get(INFO);
t.mock.timers.tick(3_999);
await turn();
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).states[KEY].phase, 'loading');
t.mock.timers.tick(1);
await refresh;
const timedOut = zigbeeTopologyRuntimeSnapshot(control.hass);
assert.equal(timedOut.states[KEY].error, 'timeout');
assert.equal(control.publishes.length, 0);
assert.deepEqual(control.cleanups, [INFO, RESPONSE]);
lateInfo({ retain: true, payload: '{}' });
await turn();
assert.equal(zigbeeTopologyRuntimeSnapshot(control.hass).revision, timedOut.revision);
});