Локальное исполнение автоматизаций (Zigbee2MQTT)

Автоматизация Rocket Home может исполняться либо в облаке (движок в hmq2), либо на локальном сервере пользователя — хабе с Zigbee2MQTT, поставленном по нашей инструкции (rocket-installer). Инвариант протокола: в любой момент времени автоматизацию исполняет ровно один исполнитель. Локальная автоматизация продолжает работать при полном отсутствии интернета; облако её не подхватывает никогда, пока она в локальном режиме.

Этот документ — спецификация протокола: место исполнения, формат «локального бандла», доставка, журнал прогонов, подтверждения и границы v1. Грамматика самих автоматизаций — Язык автоматизаций Rocket-home (спецификация); локальное исполнение использует её подмножество (см. Переносимость (критерий v1)).

Архитектура

janus (Postgres) ──┐                          локальный сервер пользователя
                   │ поллинг                  ┌──────────────────────────────┐
hmq2 local-sync ◄──┘                          │ mosquitto ◄─ z2m + раннер    │
     │  ▲                                     │     ▲            (extension) │
     ▼  │            MQTT-мост (topic # both) │     │                        │
облачный брокер hmq ◄────────────────────────►│  мост rocket                 │
                                              └──────────────────────────────┘

Исполнитель на локальной стороне — external extension Zigbee2MQTT (контракт 1.42.x: CommonJS, 7-аргументный конструктор): один универсальный раннер-интерпретатор, а сами автоматизации доставляются данными — retained-бандлом. Исходник раннера — resources/js/local-runner/; собранный одно-файловый артефакт — contracts/local-runner/rocket_local_runner.v1.cjs (+ manifest.json c версией и sha256; пересборка npm run build:local-runner, CI-проверка npm run check:local-runner). hmq2 держит вендор-копию артефакта (go:embed) и доставляет его через zigbee2mqtt/bridge/request/extension/save.

Место исполнения (execution_place)

Колонка automations.execution_place (PG ENUM automation_execution_place):

cloud

исполняет только облако (по умолчанию). Все pick-пути hmq2 гейтятся предикатом COALESCE(execution_place,'cloud') = 'cloud'.

local

исполняет только хаб. Строка попадает в бандл локации (при is_active и без broken_at); облачный движок её не видит.

pending_cloud

переходное local→cloud: не исполняет никто со стороны облака, из желаемого бандла строка уже исключена. В cloud её переводит hmq2 local-sync после свежего retained-status с хэшем бандла, собранного уже без неё (двухфазный handoff).

Правила переключения (действуют в SetExecutionPlace, janus):

  • менять место можно только у выключенной автоматизации (is_active = false) — иначе 422;

  • local: валидатор переносимости + хаб на связи + мост по TLS; в той же транзакции — штамп schema_version = max(9, грамматика), next_due_at_utc = NULL у всех тайм-триггеров (fast-path облачного планировщика не имеет schema-гейта — NULL закрывает его даже на откаченных подах), добивание осиротевших claimed/failed диспатч-строк;

  • cloud: pending_cloud (флип делает hmq2 по подтверждению) либо force:true (только владелец, явное предупреждение: мёртвый хаб может воскреснуть со старым бандлом — окно двойного исполнения до применения нового retained). Включение (toggle) при pending_cloud блокируется.

Топики

Все суффиксы живут внутри mount point локации (hex UUID без дефисов); мост topic # both 2 прозрачен, поэтому локально топики видны буквально. Namespace rocket/ не входит в device-идентификацию коллектора hmq2 и exempt-ится от freeDeviceLimit.

Топик

Направление

Retain

Содержимое

rocket/automations/bundle

облако → хаб

да

желаемый набор автоматизаций локации (local-bundle.v1.schema.json)

rocket/automations/status

хаб → облако

да

применённый бандл + снапшот per-automation; он же heartbeat (60 с)

rocket/automations/event

хаб → облако

нет (QoS1)

батчи журнала прогонов (local-run-events.v1.schema.json)

rocket/automations/event/ack

облако → хаб

да

курсор усечения буфера {epoch, acked_seq}

zigbee2mqtt/bridge/request/extension/save

облако → хаб

нет

доставка/обновление кода раннера (retry по response + retained bridge/extensions)

ACL (hmq2, до devicelimit): PUBLISH в zigbee2mqtt/bridge/request/extension/# (канал исполнения кода!), rocket/automations/bundle и rocket/automations/event/ack запрещён всем клиентским сессиям — эти топики публикует только внутренний local-sync. status/event публикуются сессией моста; ingest обязан валидировать их против mount-локации сессии (location_id, принадлежность automation_id и time_trigger_id) — защита от подделки внутри тенанта.

Граница доверия v1 (известное ограничение)

Валидация выше держит границу МЕЖДУ тенантами, но не внутри одного. У всех клиентов локации общие MQTT-креды (accounts.login/password), поэтому status и event может опубликовать любой клиент этой локации — вторая копия хаба, MQTT-JWT-сессия панели, пользовательский скрипт. Поддельный status со скопированным из retained-бандла хэшем способен: подтвердить handoff pending_cloud cloud, пока настоящий хаб офлайн (окно двойного исполнения до применения нового retained), удовлетворить гейт isHubFresh в janus и подменить показываемый next_due. Закрывается это только отдельной идентичностью раннера (свои креды или подпись status) — изменение протокола.

Решение: сознательно не закрываем в v1. Причины, а не забывчивость:

  • подделать status может только держатель кред локации — сам владелец, его панель, его скрипт; ущерб замкнут на собственную установку, межтенантная граница держится отдельно (валидация по mount выше);

  • худший эффект совпадает со штатной кнопкой: досрочный handoff при офлайновом хабе — это ровно то, что делает force, только с явным предупреждением и меткой forced_cloud_at;

  • самые опасные варианты уже перекрыты по-другому: флип требует, чтобы серверный desired_bundle не содержал автоматизацию (совпадения хэшей мало), ack журнала не уезжает за неудачную вставку, disarm one_time привязан к совпадающему due, вторая копия раннера подсвечивается бейджем two_runners_detected;

  • цена закрытия — новый scope и реестр привязки хаба к OAuth-клиенту, поле в профиле брокера, ACL-правило, бамп обобщённого спека и правки инсталлятора с перевыдачей кред на живых установках; при этом владелец, запросив hub-токен, всё равно сможет представиться своим хабом.

Вернуться к идентичности хаба следует, когда «самому себе» станет «постороннему»: шеринг кред с интеграциями или гостевой доступ к локации, не-owner MQTT-сессии (сейчас authdb джойнит только lu.role='owner'), SLA-гарантии на локальное исполнение, либо расширение канала rocket/automations/* на данные, которые облако применяет без сверки с БД.

Бандл

Формат — openapi/schemas/local-bundle.v1.schema.json. Ключевые свойства:

  • один retained-документ на локацию = полное желаемое состояние; автоматизации, исчезнувшие из бандла, локально останавливаются (живой прогон абортится); пустой automations: [] — явный «стоп всему» (перепривязка хаба, force последней автоматизации);

  • аргументы запечены облаком: {{arg:…}}/argumentRef/duration_arg резолвятся сборщиком (три уровня значений, EffectiveArgs) в литералы; раннер модели параметров не имеет. {{prop:…}}-токены остаются — их резолвит раннер в момент исполнения;

  • топики резолвнуты: голые суффиксы zigbee2mqtt/… (шаги — janus’ом при записи, device-триггеры — из topic_suffix);

  • version — монотонная (живёт в location_local_runners), hash — sha256 канонизированного automations. Правило применения: > — применить, < — игнор, == с другим hash — применить retained + mismatch в status;

  • лимит сериализованного бандла — 256 KiB (превышение — 422 bundle_too_large на очередном переносе).

Retained не является durable-хранилищем: локальный mosquitto работает без persistence, а retained-стор hmq — in-memory (local-sync на старте ре-retain-ит бандлы и ack из location_local_runners). Поэтому раннер персистит применённый бандл файлом (bundle.json) и поднимается из него автономно.

Раннер: семантика исполнения

Паритет с облачным движком фиксируют общие тест-векторы contracts/local-runner-vectors/{conditions,render,cron,solar} — их гоняют vitest (JS), Pest (PHP-эталоны SolarTimeService/ValidCronExpression/dragonmantank) и Go-тесты hmq2 (вендор-копия). Ключевые правила (зеркало scenariorunner):

  • выбор блока — «первый совпавший»; armed-флаги crosses_* продвигаются во всех блоках на каждом наблюдении; временной триггер берёт только безусловный блок;

  • один активный прогон на автоматизацию; повторный триггер при живом прогоне скипается;

  • матчинг device-триггера — по суффиксу с отбрасыванием хвостовых сегментов (сообщение …/set матчит триггер state-топика — паритет topic_index);

  • сигнальный стор (prev-payload) пишется на каждое наблюдение watch-топика, даже без матчей; наблюдения — публикации z2m (onMQTTMessagePublished) и внешние входящие (onMQTTMessage); собственные команды раннера наблюдаются один раз (echo-фильтр);

  • pause/wait_until — WAL с абсолютными дедлайнами: после рестарта z2m прогон доспит остаток, а не начнётся заново; WAL-resume выполняется до подписки на MQTT;

  • wait_until — оба режима (условие / «любой новый статус» с baseline), send_get, захват payload для late-binding {{prop:<device_id>.…}}; на таймауте условный wait захватывает последнее известное, «любой статус» — ничего;

  • message — шаг мгновенный: раннер рендерит текст и отдаёт его событием журнала, а отправляет облако (Уведомления локальных автоматизаций); текст, схлопнувшийся в пустой (нерезолвнутый плейсхолдер), — пропуск шага, а не провал прогона: «уведомить + перекрыть кран» обязано перекрыть кран (та же семантика, что у pause с нерезолвнутой длительностью, ADR 0012);

  • guard (v10) — проверка внутри цепочки: ложное условие завершает локальный прогон (completed, detail=guard_stopped), оставшиеся шаги не выполняются. Голый путь читается из payload сработавшего триггера, <device_id>.path — из payload, пойманного предыдущим wait_until (тот же late-binding, что у {{prop:…}});

  • операнд времени в условии (v10, {"now":…,"tz":…}) — те же поля и та же семантика, что в облаке; солнечные поля берут координаты из бандла (location.lat/lon), и без них операнд значения не имеет (блок fail-safe ложен). Паритет фиксируют общие вектора contracts/local-runner-vectors/conditions/time.json;

  • retrigger_policy=extend (v10) — срабатывание во время живого прогона перевзводит дедлайн текущего pause/wait_until в WAL и будит спящий шаг; на шаге без дедлайна поведение равно skip. Поле едет в бандле рядом с schema_version;

  • тайм-триггеры: cron (диалект ValidCronExpression, включая L), solar (порт suncalc, эталон SolarTimeService), one_time c grace (кэп 600 с; протухший — skip-событие, по которому облако гасит триггер в БД). Пропущенные минуты не докатываются (простой хаба = пропуск);

  • clock-guard: системное время раньше generated_at бандла или откат назад против persisted-отметки (Pi без RTC после офлайн-ребута) подавляет тайм-триггеры (clock_valid: false в status); device-триггеры работают;

  • cancel_on (v11) — правило отмены живого прогона по событию: топик приезжает уже резолвнутым (карты device_id → топик у раннера нет), условие запечено теми же правилами, что и блоки. finish снимает дедлайн текущего pause/wait_until и доигрывает остаток цепочки, abort завершает прогон (aborted, detail=cancelled_by_event); на шаге без дедлайна правило — no-op. Топик правила попадает в watch-набор раннера наравне с триггерным. Паритет фиксируют общие вектора contracts/local-runner-vectors/cancel/cancel.json;

  • эскалация уведомления (v11) раннеру не видна и не нужна: он по-прежнему отдаёт уведомление событием журнала один раз, а лестницу повторов ведёт облако, читая её параметры из сохранённой логики. Гейт версии раннера она всё же поднимает — см. ниже: скипается ВСЯ автоматизация, а не отдельная конструкция;

  • ask (v12) — вопрос жильцу: раннер отдаёт его событием журнала prompt (отправляет облако — транспорта чатов на хабе нет) и ЖДЁТ ответа до дедлайна, который так же лежит в WAL, как дедлайн паузы. Ответ приезжает обратно retained-документом {mount}/rocket/automations/answers (Вопросы жильцу из локальных прогонов). Впервые на хаб приезжают НЕзапечённые параметры: те, значения которых спрашивает вопрос, облако намеренно не запекает, а рядом кладёт их прежние эффективные значения (ask_fallback) — на случай молчания при on_timeout=continue. Паритет фиксируют общие вектора contracts/local-runner-vectors/ask/ask.json;

  • schema_version новее известной раннеру — fail-safe skip + skipped_schema в status.

Durable-файлы раннера (data/extension/rocket-runner/): bundle.json, state.json (эпоха, seq, WAL прогонов, armed-флаги, prev-payload, fired one_time, occurrence-дедуп 48 ч, clock-mark; атомарная запись rename), events-<epoch>.ndjson (журнальный буфер).

Журнал прогонов

Формат — openapi/schemas/local-run-events.v1.schema.json. Прогоны попадают в него только терминальными событиями run_finished (никаких running-строк — облачные реаперы automation_runs не должны их трогать; ingest пишет lease_expires_at NULL, реаперы фильтруют claimed_by NOT LIKE 'local:%'). Второе событие — notification, оно шлётся посреди прогона (Уведомления локальных автоматизаций); третье — prompt, вопрос жильцу, на котором прогон останавливается (Вопросы жильцу из локальных прогонов).

  • идемпотентность: event_id = id прогона = automation_runs.id; ingest — таргетless ON CONFLICT DO NOTHING (покрывает и PK, и слот (automation_id, due_minute); конфликт слота с чужой строкой = метрика нарушения эксклюзивности, событие ack-ается);

  • офлайн-буфер: NDJSON-файл на эпоху state-файла; ack — пара (epoch, acked_seq); потеря state = новая эпоха, старые файлы дозаливаются первыми со своей эпохой; кэп 5 МБ / 5000 событий, drop-oldest (уведомления вытесняются последними) + маркер gap;

  • откат ``seq``: state.json пишется с троттлингом в 1 с, а строка журнала — сразу, поэтому после power-cut счётчик может оказаться позади retained-курсора облака, и следующие события были бы усечены ack’ом ДО отправки. Раннер сравнивает пришедший acked_seq со своим seq и при опережении начинает новую эпоху (её курсора у облака нет);

  • пометка исполнителя: claimed_by = 'local:' || <hex локации>; time-события несут time_trigger_id (disarm one_time на облаке) и due (occurrence-минута);

  • выживший журнал — вторичный fired-набор one_time при потере state-файла.

Handoff и статусы UI

Производный transfer_status (janus, из execution_place + location_local_runners): cloud | pending_local (бандл ещё не подтверждён) | local | local_offline (хаб не на связи, работает автономно) | pending_cloud | cloud_unsynced (после force) | бейдж детекции двух раннеров (чередование runner_instance_id).

cloud local: janus ставит local (автоматизация выключена — не исполняет никто) → включение пользователем → local-sync включает строку в бандл только при отсутствии живых claimed/running-строк облака → retained-бандл → status подтверждает. local cloud: pending_cloud → бандл без строки → живой status с новым хэшем (доказуемо свежий: хэш неизвестен хабу до доставки) → флип в cloud (UPDATE WHERE execution_place = 'pending_cloud'). Хаб офлайн → 409 + force.

Бейдж cloud_unsynced снимается, когда хаб выходил на связь после forced_cloud_at и в его применённом бандле этой автоматизации больше нет (состав last_status.automations — то же серверное доказательство, что гейтит флип; совпадения applied/desired мало: сразу после force desired — ещё сборка ДО возврата, она содержит строку). Если строки location_local_runners нет вовсе (хаб выведен из эксплуатации), статус — cloud: подтверждать нечего, исполняемой копии не существует.

Что из этого видно в SPA (resources/js/utils/automationPlace.js — общий словарь статусов, цветов и кодов блокеров):

  • строка списка несёт чип места (иконка + подпись) для любого статуса, кроме спокойного cloud: офлайн хаба иначе замечали бы, только открыв окно каждой автоматизации. Именно с подписью — title не показывается на тач-устройстве, а иконка Vuetify скрыта от скринридера;

  • секция «Исполнение» окна показывается владельцу всегда, даже у включённой облачной, — иначе о самой возможности переноса узнать негде (кнопок у включённой нет, есть подсказка, почему);

  • pending_local / pending_cloud / cloud_unsynced страница опрашивает (composables/useSettlingPoll): их двигает сервер, и без опроса пользователь ждал бы у неизменного «Переносится на хаб…». Тик раз в 10 с перезапрашивает только несошедшиеся строки по GET …/automations/{id} — не список локации целиком. Бюджет — 30 успешных тиков на состояние (провалы его не тратят, но шесть подряд опрос останавливают); смена статуса и кнопка «Обновить» дают новый бюджет. На скрытой вкладке и при открытом редакторе опрос стоит (фоновый 401 увёл бы страницу на /login вместе с несохранённой формой), при возврате — немедленный тик, но не чаще интервала. local_offline и детекция двух раннеров держатся до починки хаба — под них не опрашиваем;

  • правку уже локальной автоматизации, после которой она перестала быть переносимой, сервер отвергает теми же кодами блокеров в errors.execution_place — редактор переводит их в баннер (без перевода Laravel показал бы там message, то есть первый машинный код).

Переносимость (критерий v1)

Переносима автоматизация, у которой все устройства (триггеры и шаги) — драйвер zigbee2mqtt этой локации, и грамматика укладывается в локальное подмножество. Блокеры (коды LocalEligibility): group_grammar (группы: set_group_expose, group_expose-триггеры, owner_device_id, is_system), step_duty_cycle (центральный поллер), bare_topic_trigger (голый topic без device_id), foreign_driver / dead_reference, relative_datetime_arg (пресеты today* резолвятся в дату исполнения — запечь нельзя), one_time_arguments (собственные arguments разового расписания резолвятся только в облаке в момент запуска; точечные операции над разовыми при local отвечают 422 тем же правилом), missing_coordinates (solar без координат), bundle_too_large. Гейты хаба в SetExecutionPlace дают не-eligibility-коды 422: hub_offline (нет свежего hub_last_seen_at), hub_not_tls (мост без TLS), hub_runner_outdated (у автоматизации есть шаг message, а хаб отчитался раннером старее messageStepMinRunner; либо она использует грамматику v10 — шаг guard, операнд времени или retrigger_policy=extend — при раннере старее v10GrammarMinRunner = 1.2.0; либо грамматику v11 — правило отмены cancel_on или эскалацию уведомления — при раннере старее v11GrammarMinRunner = 1.3.0. Гейт v11 общий на обе конструкции, хотя лестницу повторов крутит облако: раннер 1.2.x скипает автоматизацию по незнакомой schema_version целиком, то есть на хабе она не исполняла бы вообще ничего; либо грамматику v12 — шаг ask — при раннере старее v12GrammarMinRunner = 1.4.0: такой раннер не подписан на топик ответов вовсе, и вопрос висел бы до таймаута на каждом прогоне). Гейт временный, как hub_offline: он снимется, когда облако доставит хабу новый артефакт раннера. lead_seconds учтён заранее: janus запекает его в one_time_at_utc.

Уведомления локальных автоматизаций

Шаг message исполняет хаб, отправляет облако. Раннер рендерит текст теми же правилами токенов, что и облачный движок (subject/body/torenderPlaceholders, проза, не JSON; {{arg:*}} запекает сборщик бандла), и кладёт готовый текст в журнал отдельным событием notification; облако валидирует его и отправляет по обычному пути (scenariorunner.Runner.SendMessageStep — тот же код, которым отправляет облачный прогон).

  • Событие уходит немедленно, не в конце прогона: пауза на 12 часов следующим шагом задержала бы алерт на 12 часов. Поэтому notification всегда приходит раньше run_finished того же прогона — и не означает успеха прогона: следующий шаг мог упасть, а сам прогон — быть оборван handoff’ом (aborted).

  • Доставка — best-effort. Гарантировано локальное действие (закрыть кран), а не письмо: уведомление едет тем же журналом и ждёт связи. Порог задержки и TTL — конфиг брокера localNotifications (по умолчанию 5 мин и 24 ч): свыше порога в текст добавляется строка «Событие произошло: <время>», свыше TTL уведомление не отправляется вовсе (строка в БД остаётся с suppressed_reason).

  • Ретраи разведены во времени. Пять попыток отправки идут с паузами 0 / 30 с / 2 мин / 8 мин / 30 мин от ingested_at — последняя приходится на полчаса после приёма, внутри TTL. Без пауз тикер notifyLoop (10 с) сжигал бы все попытки за ~40 секунд, и минутная недоступность почтовика (ровно такая случилась на стенде — не резолвился SMTP-хост) хоронила бы уведомление, хотя TTL обещает сутки.

  • Дедуп. event_id детерминирован от (run_id, step_index), в БД — automation_local_notifications с PK по нему и уникальным ключом (run_id, step_index). Переисполнение шага после resume и ретрансляция журнала дают одну отправку. FK на automation_runs нет: строка прогона приезжает позже уведомления (или никогда).

  • Отправка вне ingest’а. handleEventBatch только сохраняет; шлёт отдельная горутина notifyLoop. Иначе один медленный SMTP держал бы единственную горутину consumeLoop на весь флот — вместе с heartbeat’ами, ack и публикацией бандлов, а неотправленный ack заставлял бы хаб ретранслировать тот же батч.

  • Приоритет в офлайн-буфере. При переполнении журнал вытесняет сначала run_finished: уведомление всегда старше терминального события своего прогона и заметно жирнее, чистый drop-oldest выбрасывал бы именно его.

  • Force-возврат. Если автоматизация уже cloud и forced_cloud_at позже occurred_at события — уведомление не отправляется: облако исполняет её само, и это дубль от копии на воскресшем хабе.

  • Совместимость раннеров. Старый раннер на незнакомом типе шага валит весь прогон (unsupported step type), поэтому автоматизация с message не попадает в бандл хаба с раннером ниже messageStepMinRunner (bundleAutomations), а перенос такой автоматизации закрыт гейтом hub_runner_outdated.

Граница доверия: осознанное сужение

Креды MQTT общие на локацию (v1), поэтому изнутри тенанта можно подделать событие журнала. До этой фичи цена подделки — мусорная строка в истории прогонов; теперь — отправка текста владельцу от имени сервиса. Противопоставлено: событие обязано ссылаться на реально существующий в сохранённой логике шаг message того же канала по паре (block_index, step_index); получателя резолвит облако (адрес уезжает на хаб только если автор задал его явно); длина текста ограничена; частота — не более notifyPerLocationHourly уведомлений на локацию в час. Остаточный риск: сам текст в пределах существующего шага выбирает хаб. Триггеры возврата к идентичности хаба (отдельные креды/ключ раннера) — прежние: шеринг кред, гостевой доступ, не-owner MQTT-сессии, требования SLA.

Вопросы жильцу из локальных прогонов

Шаг ask (v12) исполняет хаб, спрашивает облако. Раннер рендерит текст вопроса теми же правилами, что и текст уведомления, кладёт его в журнал событием prompt — и, в отличие от notification, останавливается: следующий шаг не выполняется, пока не придёт ответ либо не истечёт окно.

  • Вверх — журналом, вниз — retained-документом. Облако принимает событие (ingestPrompt) и записывает вопрос (automation_run_prompts); отправляет его отдельным рассыльщиком (sendPendingPrompts на тике notifyLoop) существующим путём (SendMessageStep) со ссылкой /p/{token}, а когда жилец ответил — публикует ответ в {mount}/rocket/automations/answers (схема openapi/schemas/local-answers.v1.schema.json).

  • Запись и отправка — разные факты. Строка заводится ДО отправки (иначе ссылка вела бы в 404), поэтому «вопрос записан» ≠ «жильца спросили»; второе несёт sent_at. Рассыльщик берёт строки без sent_at и живые по expires_at, то есть упавшая отправка повторяется сама, а истёкшее окно ответа само же её и прекращает — счётчика попыток не нужно. Отправка вынесена из ingest-горутины по той же причине, что у уведомлений: она одна на весь флот.

  • Без ссылки вопрос не задаётся. Базовый адрес ссылки — конфиг брокера localNotifications.ackBaseUrl (в проде https://rocket-home.ru), дефолта у него нет намеренно: угаданный хост — это мёртвая кнопка в чужом почтовом ящике. Пустое значение эскалация (v11) переживает — алерт доходит, просто без «прекратить повторы», — а вопрос нет: жилец получил бы фразу, на которую нечем ответить, а прогон занял бы автоматизацию до конца таймаута. Поэтому облачный прогон падает на шаге с явной ошибкой (текст называет настройку), а рассыльщик локальных вопросов пропускает тик, оставляя sent_at пустым — после правки конфига уйдёт всё, что ещё в своём окне ответа. Именно этой дырой фича приехала в прод: блок localNotifications в чарте не рендерился вовсе, задать адрес было нечем.

  • Протухшие вопросы не задаются вовсе. Хаб буферизует журнал в оффлайне, и вопрос может всплыть, когда прогон давно разрешился по таймауту локально. Старше notifyMaxAge — дропаем с логом; моложе — окно ответа считается от occurred_at, а не от момента приёма, так что жилец получает то, что от окна осталось.

  • Почему retained, а не разовая команда. Типовой отказ — хаб перезагрузился, пока вопрос висел: разовая публикация к моменту переподписки уже исчезла бы, и прогон досидел бы таймаут с ответом, данным десять минут назад. Retained читается заново при каждом реконнекте, то есть ответ доносит та же конвергенция, на которой держится бандл. Документ — ПОЛНОЕ желаемое состояние: чего в нём нет, то потреблено или протухло; публикуется на каждом тике сборки (retained-стор брокера — в памяти и рестарт пода не переживает).

  • Идемпотентность. На хабе — по prompt_id (повторная доставка retained-копии ничего не меняет), в облаке — по уникальному (run_id, block_index, step_index): переисполнение шага после resume и ретрансляция журнала дают ОДИН вопрос, а не второе сообщение жильцу.

  • Топик ответов — broker-only. rocket/automations/answers в isLocalSyncOnlyTopic рядом с бандлом и ack-курсором: креды локации общие, и без запрета любой её клиент публиковал бы «подтверждено» сам. Это не порча данных, а решение за жильца.

  • Незапечённые параметры — в границах СВОЕГО блока. Значения, которые спрашивает вопрос, сборщик бандла НЕ запекает в шагах того блока, где стоит сам вопрос (иначе вопрос был бы декоративным). В других блоках тот же параметр запекается как обычно — блоки альтернативны, вопрос из одного значения другому не даёт. Условие блока запекается всегда: оно решается ДО первого шага, ответа на тот момент нет ни при каких обстоятельствах. Ровно та же область действия у облака (substituteSteps берёт набор из шагов прогона, а прогон — это один блок). Токены едут к раннеру целыми, а рядом кладётся ask_fallback с прежними эффективными значениями: молчание при on_timeout=continue оставляет цепочке ровно то, что она получила бы до v12.

  • Что берётся из сохранённой логики, а не с провода. Окно ответа, смысл молчания и список запрашиваемых параметров облако читает из хранимой автоматизации — как параметры эскалации. С провода приходит только текст, и он же ограничен: событие обязано ссылаться на реально существующий шаг ask того же канала, частота — не более promptsPerLocationHourly вопросов на локацию в час. Остаточный риск тот же, что у уведомлений, но цена выше: вопрос — это кнопка, которая что-то включает. Модель угроз целиком — ADR 0016.

Версионирование

Локальная автоматизация штампится schema_version = max(9, грамматика); мажор 9 = «исполняется вне облака» (Версионирование и расширяемость (SemVer)). Бандл имеет собственный fmt (сейчас 1); версия раннера — semver в manifest.json, код сверяется облаком по sha256 retained bridge/extensions и обновляется через extension/save. v1 поддерживает только z2m 1.42.x (CJS, 7 аргументов); мажор z2m читается из retained bridge/info.

Два уточнения порядка доставки (оба пойманы живым стендом фазы E):

  • retained bridge/extensions обычно приходит один раз — при коннекте моста, до первого бандла локации (перенос случается позже), а z2m не переиздаёт его без save/reconnect. Облако кэширует снимок per location и после публикации первого непустого бандла повторяет доставку из кэша — реконнект моста не требуется;

  • сразу после установки/обновления раннера (снимок bridge/extensions со свежим sha) облако переиздаёт retained-бандл: retained мог быть доставлен клиенту z2m до загрузки extension’а (гонка установки), а повторная подписка того же клиента надёжной пере-доставки retained не гарантирует. Для раннера повтор идемпотентен (тот же version/hash не применяется заново).

Снятие раннера с хаба (порядок обязателен)

zigbee2mqtt/bridge/request/extension/remove удаляет файл, но уже загруженное расширение продолжает жить в процессе z2m — нужен рестарт z2m, иначе остаётся «зомби»: он публикует heartbeat с прошлой bundle_version и при подключении хаба к другому облаку заполняет там applied_bundle_version/runner_version мусором ещё до первого бандла. Порядок: (1) вернуть автоматизации в cloud; (2) снять мост — пока он жив, remove тут же вызывает переустановку (deliverRunner гейтится только на desired_bundle_version != 0, а версия не сбрасывается); (3) remove + удалить data/extension/rocket-runner; (4) рестарт z2m; (5) затереть retained rocket/automations/status пустым сообщением на самом облачном брокере (через мост затирка не проходит); (6) удалить строку location_local_runners локации — она единственный источник переиздания бандла/ack на старте пода, и без неё снимается бейдж cloud_unsynced у форсированных автоматизаций.