Локальное исполнение автоматизаций (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) |
|
облако → хаб |
да |
курсор усечения буфера |
|
облако → хаб |
да |
зеркало состояния групп локации: гейт фоновой задачи (v20) |
|
облако → хаб |
нет |
доставка/обновление кода раннера (retry по response + retained |
ACL (hmq2, до devicelimit): PUBLISH в zigbee2mqtt/bridge/request/extension/# (канал
исполнения кода!), rocket/automations/bundle, rocket/automations/event/ack и
rocket/groups запрещён всем клиентским сессиям — эти топики публикует только внутренний 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;косметика в бандл не попадает: название и пояснение шага (
alias/description, v48.1) и название ветви сборщик вырезает — у шага, в телах ветвей, в шагах обработчика просрочки и в ветке гашения. Раннер их не читает, а оценка бюджета (ниже) их не считает: пояснительная проза не должна отбирать у автоматизации право исполняться на хабе. Отсюда же порядок выката поля — сначала hmq2: пока ключей ни у кого нет, вырезание байт-в-байт no-op и хэш бандла не меняется;лимит сериализованного бандла — 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,control,task} — их гоняют 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);операнд времени в условии (v10,
{"now":…,"tz":…}) — те же поля и та же семантика, что в облаке; солнечные поля берут координаты из бандла (location.lat/lon), и без них операнд значения не имеет (блок fail-safe ложен). Паритет фиксируют общие вектораcontracts/local-runner-vectors/conditions/time.json;продление шага (v13,
then_actions[].retrigger = extend_while) — срабатывание во время живого прогона перевзводит дедлайнpause/wait_until, на котором прогон спит, в WAL и будит его. Продление УСЛОВНОЕ, как в облаке: перевзводит только статус, проходящий условие блока прогона; переходные операторы в этой проверке ложны, армированное состояние сигнала не двигается. Прогон, чьё определение переписал новый бандл, продлению не подлежит (гейт по хэшу определения): живой прогон несёт свой снапшот шагов, аblock_indexадресовал бы уже другой массив блоков — продлить по чужой ветке хуже, чем не продлить. Требует раннера ≥ 1.5.0. Прежнее поле автоматизацииretrigger_policyснято, но раннер 1.5.0 его ещё читает: retained-бандл на хабе не перепишется до следующей пересборки, а его версию раннер не валидирует — чтение уйдёт отдельной уборкой;тайм-триггеры: cron (диалект
ValidCronExpression, включаяL), solar (порт suncalc, эталонSolarTimeService), one_time c grace (кэп 600 с; протухший — skip-событие, по которому облако гасит триггер в БД). Пропущенные минуты не докатываются (простой хаба = пропуск);clock-guard: системное время раньше
generated_atбандла или откат назад против persisted-отметки (Pi без RTC после офлайн-ребута) подавляет тайм-триггеры (clock_valid: falseв status); device-триггеры работают;then_actions[].cancel_on(v16) — правило отмены ждущего шага: топик приезжает уже резолвнутым (картыdevice_id→ топик у раннера нет), условие запечено теми же правилами, что и блоки.finishснимает дедлайн текущегоpause/wait_untilи доигрывает остаток цепочки,abortзавершает прогон (aborted,detail=cancelled_by_event); на шаге без дедлайна правило — no-op. Топик правила попадает в watch-набор раннера наравне с триггерным. Паритет фиксируют общие вектораcontracts/local-runner-vectors/cancel/cancel.json. С v18 адрес правила может быть авторским (cancel_on.topic), и тогда условие необязательно — отсутствиеconditionзначит «любое сообщение в этот топик отменяет шаг»; требует раннера ≥ 1.11.0 (v18GrammarMinRunner), потому что раннер ниже читает пустое условие как «не отменяю никогда» — молча. Адрес обязан лежать внутри дереваzigbee2mqtt/: расширение видит только сообщения своегоbase_topic, поэтому правило на любой другой адрес делает автоматизацию непереносимой (блокерbare_topic_cancel) и исполняется только в облаке;публикация в произвольный топик (голый
topicбезdevice_id, подтип шага «Действие») — раннер публикует адрес как есть, мажора это не требует. Паритет фиксируют общие вектораcontracts/local-runner-vectors/topic/topic.json. Снятые шаги обрыва цепочки по условию (guard, затемstop_run) раннер не исполняет вовсе: бандл с таким шагом он отвергает целиком;ждущее сообщение (v15,
messageсmode = confirm | prompt) — вопрос жильцу: раннер отдаёт его событием журналаprompt(отправляет облако — транспорта чатов на хабе нет) и ЖДЁТ ответа до дедлайна, который так же лежит в WAL, как дедлайн паузы. Ответ приезжает обратно retained-документом{mount}/rocket/automations/answers(Вопросы жильцу из локальных прогонов). Впервые на хаб приезжают НЕзапечённые параметры: те, значения которых спрашивает вопрос, облако намеренно не запекает, а рядом кладёт их прежние эффективные значения (prompt_fallback) — на случай молчания приon_timeout=continueи отказа приon_decline=continue. Паритет фиксируют общие вектораcontracts/local-runner-vectors/prompt/prompt.json. До v15 это был отдельный шагask;фоновая задача группы (v21,
triggers.task) — прогон-СЕАНС: цепочка шагов повторяется, пока открыт гейт, а не исполняется один раз. Гейт —on_off-регистр группы-владельца, и читается он из ЗЕРКАЛАrocket/groups: группа живёт в облаке, своей шины у неё на хабе нет. Fail-safe односторонний: нет зеркала, нет группы в нём или регистр не включён ⇒ гейт ЗАКРЫТ — обратный дефолт означал бы реле, щёлкающее до тех пор, пока кто-нибудь не выдернет вилку. Окно исполнения одно на все задачи — минута (с 27.08.2026; было 30 с) — и считается от НАЧАЛА круга: уложившийся досиживает остаток, не уложившийся начинает следующий сразу. Каждый круг работает на СВЕЖЕЙ копии шаблона шагов: повтор по тем же объектам сжёг бы паузы (дедлайн ставится, только если ещё не задан) и заморозил бы вход регулятора на первом измерении. Отказ шага завершает КРУГ, а не сеанс; десять отказов подряд гасят задачу. Разборка сеанса (гейт закрылся, задача ушла из бандла) играет веткуon_stop— только команды, потому что пауза или ожидание в ней означали бы останов, способный зависнуть. Требует раннера ≥ 1.17.0 (taskWindowMinRunner/TASK_WINDOW_MIN_RUNNER— самый строгий порог носителяtask, он и гейтит задачи целиком): 1.13.x не знает режима сеанса и проиграл бы цепочку ровно один раз, 1.14.x ждёт темпа в бандле и, не найдя его, крутил бы цепочку втрое чаще окна, а 1.15–1.16 знают окно в 30 с и крутили бы её вдвое чаще нынешнего;регулятор (v20, шаг
control) — П/ПИ/ПИД по тому, какие коэффициенты ненулевые; поля «закон» у шага нет. Такт БЕЗ нового измерения не считает, не публикует и ничего не пишет — именно поэтому задачу-регулятор можно крутить часто, а датчику говорить редко. Нет измерения, оно устарело или уставка недоступна — публикуетсяfail_safe(промолчать значило бы оставить нагреватель в последней мощности навсегда). Уставка может браться из регистра группы — её раннер читает из того же зеркала, поэтому правка «до скольки греем» с панели доезжает до хаба без пересборки бандла. Выход кладётся в переменную прогона (output.var), аpauseсduration_fromпревращает её в скважность медленного ШИМ; вырожденный сегмент (доля ниже секунды) пропускается ВМЕСТЕ с командой, которая его открывает, — иначе получился бы щелчок ON→OFF без паузы между ними, каждый период. Зеркалирование выхода в регистр группы (output.mirror_expose) на хабе НЕ поддержано: писать в облачный регистр раннер не может, и такая автоматизация непереносима (блокерgroup_mirror_unreachable). Состояние регулятора живёт вstate.jsonи теряется вместе с ним — это безударный рестарт отbias, а не продолжение интеграла. Паритет вычисления фиксируют общие вектораcontracts/local-runner-vectors/control/control.jsonи…/task/task.json;schema_versionновее известной раннеру — fail-safe skip +skipped_schemaв status.
Durable-файлы раннера (data/extension/rocket-runner/): bundle.json, state.json
(эпоха, seq, WAL прогонов, armed-флаги, prev-payload, occurrence-дедуп 48 ч, легаси-набор
fired one_time раннеров ≤1.9.x, clock-mark; атомарная запись rename), events-<epoch>.ndjson
(журнальный буфер).
Идентичность разового вхождения¶
Строка разового расписания переживает перевзвод: «взвести снова» правит ту же строку, id не
меняется. Поэтому идентичностью вхождения не является ни id триггера, ни one_time_at_utc — из
второго сборщик уже вычел подготовку (lead_seconds). Идентичность — отдельное поле
occurrence_utc (несдвинутый one_time_at, только у time_mode=fixed), которое раннер эхом
отдаёт в событии журнала полем occurrence.
дедуп раннера — два раздельных пространства ключей:
one_time_occ|<id>|<минута вхождения>иone_time_at|<id>|<минута огня>; метятся оба, блокирует любой. Один префикс на обе роли дал бы ложное совпадение (подготовка 30 мин: вхождение 10:00 пометило бы и 09:30, а перевзвод ровно на 10:30 попал бы моментом огня в занятые 10:00 — легитимный запуск не сработал бы никогда);гашение в облаке сравнивает
one_time_atсoccurrence, а не сdue. С непустой подготовкой сверка поdueне совпадала никогда: строка оставалась взведённой,delete_after_runне отрабатывал, а через 48 ч GC ключа давал фантомныйskippedи ложную тревогуEXCLUSIVITY VIOLATION;деградация обязательна в обе стороны: нет
occurrence_utcв бандле — раннер опирается на момент огня; нетoccurrenceв событии (раннер ≤1.9.x, WAL спящего прогона, NDJSON старой эпохи) — облако сверяет поdue, как раньше. Раннер и сборщик выкатываются порознь;минута — общесистемная гранулярность вхождения (снимок
snapshotOneTimeState, слотautomation_runs), поэтому два разовых на одну минуту janus отвергает на ВСЕХ путях записи: точечном добавлении, перевзводе и правке набора триггеров из редактора.
Журнал прогонов¶
Формат — 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,due(момент срабатывания →automation_runs.due_minute) иoccurrence(момент вхождения — по нему идёт disarm one_time, Идентичность разового вхождения);выживший журнал — вторичный fired-набор one_time при потере state-файла: из
dueстроится ключ пространства огня, изoccurrence(если есть) — ключ пространства вхождения.
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),
bare_topic_trigger (голый topic без device_id),
group_gate_unreachable (у фоновой задачи не резолвится выключатель группы — на хабе гейт
закрыт по fail-safe, и задача не сделает ни круга), group_mirror_unreachable (регулятор пишет
выход в регистр группы — писать в него может только облако),
bare_topic_cancel (правило отмены на авторский адрес вне дерева zigbee2mqtt/ — раннер таких
сообщений не видит вовсе), foreign_driver / dead_reference, relative_datetime_arg (пресеты today*
резолвятся в дату исполнения — запечь нельзя), missing_coordinates
(solar без координат), bundle_too_large.
Собственные arguments разового расписания блокером больше не являются (был код
one_time_arguments, снят в rocket-home-api 27.0.0). Сборщик запекает аргументы на этапе сборки
бандла, поэтому такому вхождению он кладёт ОТДЕЛЬНУЮ копию блоков в документ его триггера
(timeTrigger.condition_blocks, local-bundle 1.10.0), а раннер исполняет её вместо общей —
модели аргументов хабу по-прежнему не нужно, всё остаётся литералами. Цена — полная копия блоков на
каждое такое вхождение, поэтому её считает оценка bundle_too_large, и та же оценка стоит на
пути добавления разового запуска (AutomationWriter::assertOccurrenceFitsBundle): блокеры
считаются на переносе и правке логики, а копии плодит именно добавление. Гейты хаба в SetExecutionPlace дают
не-eligibility-коды 422: hub_offline (нет свежего hub_last_seen_at), hub_not_tls
(мост без TLS), hub_runner_outdated (у автоматизации есть шаг message, а хаб
отчитался раннером старее messageStepMinRunner; либо она использует грамматику v10 — шаг
guard или операнд времени — при раннере старее v10GrammarMinRunner = 1.2.0; либо грамматику v16 — правило отмены cancel_on на ждущем шаге (v16GrammarMinRunner = 1.7.0);
либо грамматику v18 — правило отмены с авторским адресом и/или без условия (v18GrammarMinRunner =
1.11.0): раннер ниже читает пустое условие как «не отменяю никогда», и настроенная отмена не
срабатывает ни разу;
либо грамматику v19 — окно свежести ожидания (last_status_max_age_seconds,
v20GrammarMinRunner = 1.14.0 — фоновая задача, регулятор и длина паузы из переменной; номер
1.14.0, а не 1.13.0, потому что 1.13.0 уже выпущена с фиксом «команда устройству — не его событие»;
v19GrammarMinRunner = 1.12.0): раннер ниже выбросил бы незнакомое поле шага на round-trip в
свой WAL — окно молча исчезло бы, а штамп 19 он и так скипает целиком; либо несёт штамп 11 — при раннере старее
v11GrammarMinRunner = 1.3.0. Обе конструкции волны 11 сняты (правило отмены переехало на шаг и
стампится 16, эскалация уведомления убрана целиком), но гейт остаётся: строка со штампом 11 в БД
лежать может, а раннер 1.2.x скипает автоматизацию по незнакомой schema_version целиком, то
есть на хабе она не исполняла бы вообще ничего;
либо грамматику v12 — снятый шаг ask — при раннере старее v12GrammarMinRunner = 1.4.0: такой
раннер не подписан на топик ответов вовсе, и вопрос висел бы до таймаута на каждом прогоне (сам шаг
снят в v15, но штамп 12 в уже уехавших бандлах законен, поэтому гейт остаётся);
либо грамматику v15 — ждущее сообщение — при раннере старее v15GrammarMinRunner = 1.6.0: такой
раннер исполнил бы подтверждение как обычное уведомление и поехал бы дальше, не дождавшись ответа;
либо грамматику v13 — продление шага — при раннере старее v13GrammarMinRunner = 1.5.0: такой
раннер выбросил бы незнакомое поле шага и досидел бы паузу до конца, то есть продление не сработало
бы ни разу и молча). Гейт v13 врезан ещё и в правку УЖЕ локальной автоматизации
(AutomationWriter): перенос — не единственный путь грамматики на хаб, а из бандла сборщик такую
строку держит у себя, и тогда её не исполняет никто.
Гейт временный, как 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.
Вопросы жильцу из локальных прогонов¶
Ждущее сообщение (v15, mode = confirm | prompt) исполняет хаб, спрашивает облако. Раннер рендерит текст вопроса теми же
правилами, что и текст уведомления, кладёт его в журнал событием 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), дефолта у него нет намеренно: угаданный хост — это мёртвая кнопка в чужом почтовом ящике. Обычный алерт пустое значение переживает — ссылки в нём нет вовсе, — а вопрос нет: жилец получил бы фразу, на которую нечем ответить, а прогон занял бы автоматизацию до конца таймаута. Поэтому облачный прогон падает на шаге с явной ошибкой (текст называет настройку), а рассыльщик локальных вопросов пропускает тик, оставляя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берёт набор из шагов прогона, а прогон — это один блок). Токены едут к раннеру целыми, а рядом кладётсяprompt_fallbackс прежними эффективными значениями: молчание приon_timeout=continue(как и отказ приon_decline=continue) оставляет цепочке ровно то, что она получила бы без вопроса.Что берётся из сохранённой логики, а не с провода. Окно ответа, смысл молчания и список запрашиваемых параметров облако читает из хранимой автоматизации, а не с провода. С провода приходит только текст, и он же ограничен: событие обязано ссылаться на реально существующее ждущее сообщение того же канала, частота — не более
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.
Не всякий рост версии — грамматика. 1.13.0 её не менял: раннер перестал считать командные
подтопики (…/set, …/get) событием устройства — до него чужая команда устройству запускала
device-триггер и снимала правило отмены с device_id, чего облако никогда не делало
(Язык автоматизаций Rocket-home (спецификация), «Модель исполнения»). Гейта переносимости для таких исправлений нет:
порог в LocalEligibility заводится только под новую грамматику, а поведенческий фикс приезжает на
хабы сам — облако сверяет sha256 артефакта и обновляет расширение через extension/save.
Кому облако ставит раннер: локации, у которой есть бандл (то есть локальное исполнение уже включено), либо хабу, где наш раннер уже стоит — такую копию облако держит свежей независимо от бандла. Хаб без нашего расширения и без бандла не трогаем вовсе: расширение в чужом z2m — след, которого никто не просил.
Почему второе условие обязательно: тупик бутстрапа
Гейт «только при непустом бандле» запирал локацию, на хабе которой лежал ПРОТУХШИЙ раннер:
перенести автоматизацию новой грамматики нельзя (hub_runner_outdated), а обновить раннер
нечем — доставка ждала бандла, которого без переноса не будет. Выхода из круга не было вовсе.
Чистый хаб в него не попадал: у него runner_version пуст, а порог переносимости пустую
версию пропускает (раннер приедет сразу актуальный). Попасть же в круг легко — раннер, оставшийся
от прежней локации или пережитый ре-бутстрап, в котором сбросили строку, но не расширение
(см. врезку «Снятие раннера с хаба» ниже). Поймано живой проверкой волны v20 на боевом хабе.
Два уточнения порядка доставки (оба пойманы живым стендом фазы E):
retained
bridge/extensionsобычно приходит один раз — при коннекте моста, до первого бандла локации (перенос случается позже), а z2m не переиздаёт его без save/reconnect. Облако кэширует снимок per location и повторяет доставку из кэша — и сразу после публикации первого непустого бандла, и на КАЖДОМ тике пересборки (30 с, темп ограничен кулдауном доставки 5 мин). Тик обязателен: без него хаб с протухшим раннером ждал бы реконнекта моста, то есть, возможно, сутками;сразу после установки/обновления раннера (снимок
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 тут же
вызывает переустановку (desired_bundle_version не сбрасывается вместе с автоматизациями); (3) remove + удалить data/extension/rocket-runner;
(4) рестарт z2m; (5) затереть retained rocket/automations/status пустым сообщением
на самом облачном брокере (через мост затирка не проходит); (6) удалить строку
location_local_runners локации — она единственный источник переиздания бандла/ack на
старте пода, и без неё снимается бейдж cloud_unsynced у форсированных автоматизаций.