Локальное исполнение автоматизаций (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}

rocket/groups

облако → хаб

да

зеркало состояния групп локации: гейт фоновой задачи (v20)

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 и 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 журнала не уезжает за неудачную вставку, 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;

  • косметика в бандл не попадает: название и пояснение шага (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 — таргет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, 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/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.

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

Ждущее сообщение (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 у форсированных автоматизаций.