Локальное исполнение автоматизаций (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 |
Содержимое |
|---|---|---|---|
|
облако → хаб |
да |
желаемый набор автоматизаций локации (local-bundle.v1.schema.json) |
|
хаб → облако |
да |
применённый бандл + снапшот per-automation; он же heartbeat (60 с) |
|
хаб → облако |
нет (QoS1) |
батчи журнала прогонов (local-run-events.v1.schema.json) |
|
облако → хаб |
да |
курсор усечения буфера |
|
облако → хаб |
нет |
доставка/обновление кода раннера (retry по response + retained |
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 журнала не уезжает за неудачную вставку, disarmone_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 — таргетlessON 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/to — renderPlaceholders,
проза, не 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 у форсированных автоматизаций.