Язык автоматизаций Rocket-home (спецификация)

Автоматизации Rocket-home — небольшой скриптовый язык: правило читается как «ON триггер: IF условие THEN действия». У языка две формы представления:

  • Каноническая форма (AST) — структурированный JSON, который хранится и исполняется (аналог «байткода»). Единственный источник истины — JSON Schema openapi/schemas/automation-contract.v1.schema.json.

  • Поверхностный синтаксис (display) — человекочитаемая запись условия (call/инфикс-нотация), которую рисует редактор/превью. Display выводится из AST детерминированным форматтером и никогда не парсится обратно из строки.

Термины — см. глоссарий.

Модель исполнения

Автоматизацию запускает триггер (расписание kind:"time" или событие устройства kind:"device"). После запуска воркер перебирает condition_blocks по порядку и выполняет then_actions первого блока, чьё condition истинно (condition: null = всегда истина). Ни один блок не подошёл — ничего не выполняется.

Условие вычисляется относительно входного payload события. Для операторов переходов (changed_to, changed_from_to, crosses_up, crosses_down) сравниваются предыдущее (prev/триггерное) и текущее значения одного свойства.

Событие устройства — это его СТАТУС, а не команда ему. Адрес устройства матчится с отбрасыванием хвостовых сегментов (правило на zigbee2mqtt/lamp ловит и zigbee2mqtt/lamp/action), но командные подтопики — /set и /get — из этого исключены: публикация в них адресована прибору и ничего о нём не сообщает, прибор может её и не исполнить. Иначе управление устройством из панели, от Алисы или из бота выглядело бы как его собственное событие: запускало бы device-триггер и снимало бы правило отмены с device_id по факту приказа, а не исполнения. Правило действует одинаково в облаке (там команду отсекает драйверный конвейер коллектора — до диспетчера она не доходит) и на хабе (раннер 1.13.0; до него команды наблюдались, и перенос облако⇄хаб менял поведение). Исключение — авторский адрес правила отмены (v18): там хвост /set законен и описан ниже.

Один активный прогон на автоматизацию — инвариант, а не настройка. Пока прогон жив, следующее срабатывание любого её триггера (в том числе очередное срабатывание расписания) не создаёт второй прогон, а пропускается — в логах scenario_busy с идентификатором мешающего прогона. Отдельный случай — повторная доставка того же срабатывания расписания (доставка диспетчера at-least-once): она распознаётся по идентификатору occurrence и логируется как duplicate_trigger. Практический смысл: длинный сеанс (пауза, ожидание статуса, удержание) никогда не «наслаивается» сам на себя, а следующее расписание просто пропускается — как если бы его не было.

Продление при повторном срабатывании (v13). Инвариант выше остаётся, но спящий шаг может попросить, чтобы срабатывание не пропадало: then_actions[].retrigger = "extend_while" у шагов pause и wait_until (отсутствие поля и skip — прежнее поведение, срабатывание отбрасывается). Тогда срабатывание, пришедшее пока прогон спит на ЭТОМ шаге, перевзводит его дедлайн на полную длительность: прогон продолжается с того же места, шаги не переигрываются, второй прогон не создаётся. Удачное продление логируется как run_extended.

Продление условное: дедлайн перевзводит только статус, который проходит условие блока, из которого растёт прогон. Этим и выражается «свет горит N минут после последнего движения»: публикация «движения нет» паузу не продлевает (лог retrigger_extend_condition_false). Предикаты уже отработавших wait_until не перепроверяются — это не условия, а состоявшиеся ожидания, и требовать от события повторить всю историю прогона значило бы не продлевать никогда.

Проверка вычисляется ВНЕ прогона, и отсюда два следствия. Первое: армированное состояние сигнала не двигается — иначе автоматизация на CROSSES_* продлилась бы один раз и больше никогда, съев фронт у самого триггерного пути. Второе: предыдущего значения нет, поэтому переходные операторы в проверке ложны — условие, которое из-за этого не может стать истинным никогда, сервер отвергает на записи (422), чтобы пользователь услышал об этом сразу, а не через месяц молчания.

Сдвиг срока и «дальше» по команде. Тот же дедлайн двигает и человек — заявкой спящему шагу (POST …/run-deadline {ends_at}, POST …/run-finish-step; на шине deadline / finish_step), в любую сторону и без повторного срабатывания. Заявка адресна текущему шагу (индекс фиксируется под замком строки прогона) и применяется самой горутиной движка, которая на этом шаге спит; снаружи её только будят. «Дальше» у wait_until с on_timeout: cancel ПРОДОЛЖАЕТ цепочку — это не «никто не ответил», а просьба человека. Ждущему сообщению заявка не адресуется по той же причине, что и продление (срок — обещание человеку), сеансу фоновой задачи — потому что его строка держит шаблон круга. Событие на шине — run_deadline_moved с новым сроком, run_step_finished_by_request; опоздавшая заявка — run_step_request_late.

Поле принимается только у pause и wait_until (у остальных шагов дедлайна нет; у ждущего сообщения он есть, но двигать окно ответа задним числом бессмысленно — вопрос уже отправлен), только у автоматизаций с триггером device/group_message (расписание своим следующим срабатыванием паузу не продлевает) и у логики группы только в ветке direction = set — обращение за статусом самого статуса не несёт. Всё остальное — 422. Гейты смотрят ровно на extend_while: голый skip равен отсутствию поля, поэтому принимается где угодно и вырезается канонизацией записи — иначе клиент, материализующий документированный дефолт (генерённый по OpenAPI, MCP), не смог бы сохранить ни одну паузу у автоматизации по расписанию.

Отдельного безусловного режима нет, и запрещать его нечем: у блока без условия продлевает любое срабатывание — проверять просто нечего. Движок знает и безусловное значение (extend), но Janus его не эмитит и не принимает: одно намерение — одно значение.

Прежнее поле АВТОМАТИЗАЦИИ retrigger_policy (v10) снято. Уровень был неверен: перевзвести можно только дедлайн, а он принадлежит конкретному ожиданию — на любом другом шаге поле было честным no-op (retrigger_extend_noop). Отмена прогона (cancel_on) переехала на шаг тем же путём по обратной причине: она обязана действовать на любом спящем шаге. Обоснование и порядок выката — ADR 0017.

Режима «перезапустить» нет сознательно: сегодняшний анти-спам уведомлений держится ровно на инварианте «один активный прогон» (сообщение плюс пауза в хвосте цепочки), и перезапуск выключил бы единственный работающий механизм. Он вернётся вместе с явным кулдауном канала.

Граница инварианта — прогоны. Одношаговую тайм-автоматизацию (единственное действие device_command в единственном блоке) планировщик публикует напрямую, прогона не создаёт; сеанса, который мог бы пересечься, у неё нет.

Что видно снаружи: журнал исполнения. Каждый прогон — строка automation_runs; её пишет движок (облачный воркер или, через ingest локального раннера, хаб), Janus только читает: GET /api/locations/{location}/automations/{automation}/runs (session-only, постранично, per_page до 100). Кроме исхода и времени строка отвечает на вопрос «почему так»: индекс выбранного блока, список адресных операндов без показания (v22 — блок не сработал по «неизвестно», а не по «ложно»), признак «исполнял хаб» и — у фоновой задачи — номер круга с кольцом последних СМЕН исхода.

Именно СМЕН: ровно идущая задача следов не оставляет вовсе, а простой в границах («ни одна ветвь не подошла») пишется один раз, а не каждый круг — иначе журнал смен выродился бы в журнал кругов и стоил бы UPDATE строки прогона раз в тридцать секунд до выключения группы.

У прогонов, приехавших с ХАБА, разбор условия пуст: раннер вычисляет unresolved так же, как облако, но событие журнала его не несёт, и if_evaluated у такой строки не заполняется вовсе. История хаба отвечает «что и когда», но не «почему» — до расширения события раннера.

Исходы, при которых прогон не создаётся (ни один блок не подошёл, автоматизация уже идёт, группа выключена), строки не оставляют по построению: у датчика, публикующего раз в десять секунд, это была бы тысяча строк в сутки на автоматизацию. Они видны только в логах воркера (run_skipped_no_block_matched, run_skipped_already_running, run_skipped_group_gated_off).

Предварительный запуск (готовность к времени)

Для процессов с временем выхода в рабочий режим (баня греется ~2 ч, тёплый пол, прогрев авто) автоматизация несёт длительность подготовки lead_seconds: пользователь задаёт целевое время one_time_at = T (к которому должно быть готово), а подготовка стартует заранее. then-действия — обычные (включить нагреватель и т.п.). Janus считает фактический запуск:

next_due_at_utc = clamp(now, T − lead_seconds)

Семантика: запланировано более чем за lead → подготовка стартует ровно в T lead; менее чем за leadнемедленно (клампится в текущую минуту); T уже прошло → не срабатывает (next_due = null). Гранулярность минутная (как у всех разовых триггеров).

Уровень поля — автоматизация, а не расписание: подготовка описывает её саму, поэтому её наследуют все разовые расписания автоматизации с фиксированным временем — включая добавленные операцией «Разовый запуск», где своего поля для подготовки нет (иначе оно там молча терялось бы). Расписания cron и солнечные подготовку игнорируют: у повторяющегося расписания нужное опережение задаётся его собственным временем («каждую субботу к 18:00 с подготовкой 2 ч» = «каждую субботу в 16:00»), и отдельный механизм там был бы вторым способом сказать то же самое (ADR 0006). Задать подготовку автоматизации без разовых расписаний можно — это легальный no-op на будущее, а не ошибка. hmq2 исполняет по next_due_at_utc (one_time_at в due-логике не участвует; хранится как целевое время для отображения) и колонку lead_seconds не читает. lead_seconds = null — подготовки нет.

Краевые случаи: (1) toggle off→on внутри окна (T−lead, T) пересчитывает next_due = now любому взведённому разовому расписанию автоматизации → подготовка стартует повторно немедленно (осознанно: «включили — подготовь к цели»). (2) Повышение lead_seconds в редакторе при уже взведённом разовом запуске, до которого подготовка не успевает (T < now + lead), отвергается 422 на triggers.N.one_time_at: молчаливый клампинг в текущую минуту выстрелил бы прогоном сразу, чего пользователь не просил. Для вновь создаваемого разового запуска клампинг «немедленно» остаётся желаемым поведением. (3) Если воркер недоступен во время старта подготовки дольше grace-окна (5 мин), запуск пропускается — общее поведение любого разового триггера. (4) delete_after_run отрабатывает janus, а не движок: hmq2 разовый триггер после срабатывания только деактивирует (is_active=false, next_due_at_utc=NULL), а уборщик automations:prune-spent-runs затем удаляет деактивированные разовые триггеры с этим флагом. Признак срабатывания — завершённый прогон по trigger_ref_id: без него снятый с взвода вручную запуск не отличить от отработавшего. Исключение — последний триггер автоматизации: он остаётся, иначе получилась бы автоматизация, которую нечем запустить (triggers min:1 на записи).

Действия (then_actions)

Четыре типа шагов: device_command (публикация в MQTT), pause (задержка), message (уведомление по каналу) и wait_until (ожидание состояния). Шаги блока выполняются линейно по порядку — за одним исключением: шаг branch (v23) выбирает, какие шаги играть дальше, в тот момент, когда цепочка до него дошла (см. «Ветвление внутри цепочки» ниже).

В редакторе device_command показан пунктом «Действие» с выбором подтипа: «Устройство» или «Публикация в топик». Подтипы различает наличие device_id — на проводе это один и тот же тип шага (ADR 0020, ADR 0022).

``device_command`` — публикация payload в командный топик устройства. Адрес задаётся device_id; topic при этом производный — Janus считает его сам (DeviceTopic::forDevice + /set) и переопределяет присланный клиентом, как и у wait_until. Причина: пара driver/friendly_name живёт на сервере, а клиент видит лишь её копию в payload устройства и на своих правилах собирал адрес неверно (для устройства с неизвестным драйвером получалось {driver}/{friendly}/set, для тегированного камина в командный топик попадал тег). То же и у device-триггера: topic_suffix считается из device_id триггера.

topic без device_idпубликация в произвольный топик локации (подтип «Публикация в топик»; раньше этот путь существовал только для внешних клиентов, у которых устройства в Janus нет). Адрес пишет автор, и он окончателен: /set к нему не дописывается. Топик всегда относителен локации — mount point добавляет брокер, выйти за её пределы таким шагом нельзя. Проверки на записи (422): без wildcard +/# (публикуют в конкретный топик, а не в маску), без ведущего и замыкающего /, без пустых уровней и управляющих символов, не длиннее 255 символов; payload обязан быть валидным JSON — воркер кладёт его в jsonb, и невалидный уронил бы шаг в рантайме. Мажора этот подтип не требует: на проводе он тот же device_command, который оба движка исполняют с самого начала.

Пустыми не могут быть оба: device_id или topic обязателен. Устройство обязано принадлежать локации автоматизации (иначе 422 — тот же cross-tenant guard, что у остальных шагов).

{ "type": "device_command", "device_id": "…", "payload": "{\"state\":\"ON\"}" }  // topic посчитает сервер

``pause`` — задержка. Длительность задаётся константой (duration_seconds, 1..43200 — приоритетнее; либо duration_minutes, 1..720) либо ссылкой duration_arg на параметр number с constraints.format=duration (значение — целые секунды, 0..43200). duration_arg приоритетнее константы и позволяет вынести длительность (напр. «время парения» в сценарии бани) в параметр, значение которого пользователь задаёт при запуске автоматизации (или на разовом расписании). Ссылка на параметр стампит schema_version=8; hmq2 резолвит её в секунды при материализации run (значение запекается в WAL, переживает рестарт). Редактор пишет duration_seconds: секунды есть в UI паузы, и округление их до минут на записи было тихой потерей введённого значения.

{ "type": "pause", "duration_arg": { "arg": "steam_time" } }   // длительность = значение параметра steam_time (секунды)

``wait_until`` (v2.1) — блокирует последовательный run до таймаута (timeout_seconds, 1..43200 — приоритетнее; либо timeout_minutes, 1..720; один из них обязателен), в одном из двух режимов: по условию — пока condition по свойству целевого устройства (device_id — любого, не только триггерного) не станет истинным; «любой статус» (v7) — пока устройство не пришлёт новый статус, каким бы он ни был. В обоих режимах засчитывается только статус, полученный после входа в ожидание (v19), либо последний уже полученный статус, явно допущенный окном свежести last_status_max_age_seconds (см. ниже).

{ "type": "wait_until",
  "device_id": "<uuid>",
  "condition": { "op": ">=", "left": { "var": "temperature" }, "right": 60 },  // тот же ConditionNode, что и «Если»
  "timeout_minutes": 120,
  "send_get": true }   // опц., best-effort

{ "type": "wait_until",           // режим «любой статус»
  "device_id": "<uuid>",
  "condition": null,              // условие не задано — ждём любую свежую публикацию
  "timeout_seconds": 45 }

{ "type": "wait_until",           // окно свежести (v19): не ждать, если статус уже есть
  "device_id": "<uuid>",
  "condition": null,
  "timeout_seconds": 45,
  "last_status_max_age_seconds": 1800 }  // «принимать последний статус не старше 30 минут»

{ "type": "wait_until",           // исход по таймауту (v23)
  "device_id": "<uuid>",
  "condition": null,
  "timeout_seconds": 30,
  "on_timeout": "cancel" }        // не дождались — прогон кончился, хвост не играется
  • condition — полноценный ConditionNode (тот же формат, что условие блока; допускает and/or и argRef в позиции значения). Вычисляется над последним засчитанным payload целевого устройства (стейт-стор брокера, обновляется на каждую публикацию устройства). С v19 условный режим больше не завершается на значении, лежавшем в сторе до входа в шаг (в т.ч. retained сколь угодно старом): без окна свежести условие проверяется только по публикациям новее wait_baseline_at_utc. Только пороговые операторы (=, , <, EXISTS …): операторы перехода (CHANGED_TO, CROSSES_*) в ожидании запрещены (валидатор → 422) — раннер вычисляет предикат по одному payload (prev = nil), поэтому они никогда не сработали бы и ожидание молча висело бы до таймаута.

  • ``condition: null`` (или отсутствие) — режим «любой статус», а не незаполненное поле. Шаг завершается по первой публикации целевого устройства позже момента входа в ожидание (wait_baseline_at_utc). Именно «позже», а не «статус известен»: в стейт-сторе почти всегда уже лежит показание, и вторая трактовка превратила бы шаг в мгновенный no-op. Практический смысл — «дождись, пока устройство ответит, и используй его статус дальше» (см. late-binding ниже).

  • send_get: true — на входе в ожидание брокер публикует /get целевому устройству, провоцируя свежую публикацию статуса. Best-effort: устройства без поддержки /get его игнорируют и просто ждут до таймаута. Редактор предлагает флаг только для устройств, у которых есть хотя бы одно retrievable умение/свойство; виртуальная группа не публикует MQTT вовсе, поэтому ожидание на группе не дождётся ничего (в пикере ожидания групп нет).

  • ``last_status_max_age_seconds`` (v19, окно свежести) — «принимать последний полученный статус, если он не старше N секунд» (1..43200; потолок 12 ч — консистентен с таймаутом). Задано — последний уже полученный брокером статус засчитывается наравне со свежей публикацией, когда его возраст на момент входа в ожидание не превышает N: в «любом статусе» шаг тогда завершается сразу (ослабление — не ждать нового, если недавний уже есть), в условном — недавнее значение допускается к проверке условия (без окна оно отсекается baseline-гейтом). Смысл — устройства без обратной связи (не отвечают на /get, публикуют редко): редактор предлагает опцию только для них. Механика: штамп wait_accept_after_utc = «вход в шаг − N» (RFC3339Nano, пишется один раз рядом с baseline, resume-стабилен); засчитывается наблюдение с at > baseline или at accept_after. Окно заморожено на входе в шаг: продление (extend_while) и долгий resume двигают дедлайн, но не окно, поэтому относительно «сейчас» приём растягивается. Возраст считается от момента получения брокером, не от часов устройства; retained-переснимок при реконнекте устройства выглядит свежим. Отсутствующий/битый штамп при заданном N — опция игнорируется (строгий baseline-дефолт), не ошибка шага. Поле допустимо только у wait_until (на другом типе шага — 422); гейт «только устройства без обратной связи» — UI-only, как у send_get.

  • ``on_timeout`` (v23) — что значит «не дождались». continue (и отсутствие поля) — прогон идёт со следующего шага; так шаг вёл себя с самого появления, поэтому это дефолт, а голый continue на записи ВЫРЕЗАЕТСЯ как no-op (иначе клиент, материализующий документированный дефолт по OpenAPI или из MCP, поднимал бы мажор на ровном месте — та же механика, что у retrigger: skip). cancel — прогон завершается на этом шаге со статусом aborted и detail=wait_timed_out, оставшиеся шаги не выполняются.

    Зачем: завершиться по таймауту иначе нечем. Шаг кончается одинаково и когда дождался, и когда истёк, цепочка идёт линейно, а ветвления внутри неё в языке нет — значит «никто не ответил, гасим» до v23 не выражалось вовсе. Правило отмены этого не закрывает: оно снимает дедлайн по СОБЫТИЮ, а здесь событие как раз и не наступило.

    Отличие от cancel_on ровно в причине, и когда срабатывают оба, побеждает отмена: у неё есть событие, а у таймаута только его отсутствие. Словарь общий с ждущим сообщением намеренно — «что делать по таймауту» одно понятие языка, и второй набор слов означал бы, что в редакторе один вопрос задаётся двумя способами.

    У фоновой задачи ``cancel`` завершает СЕАНС. Прогон у задачи один на всю её жизнь, поэтому дальше идёт разборка через on_stop — этим и выражается «спросили, никто не ответил, гасим». Исход у такого сеанса СВОЙ и в журнале, и наружу: причина остановки wait_timed_out, круг помечается ended (не ok и не error: цепочка отработала как написана, но следующего круга не будет), терминальный статус — completed, тот же, что у погасшего выключателя группы. Не aborted: у обычной автоматизации этот статус означает «хвост цепочки не сыгран», а сеанс задачи кончился ровно так, как в нём написано. И не отказ круга: десять таких кругов подряд пометили бы автоматизацию сломанной за поведение, которое автор задал сам.

  • По таймауту шаг завершается и run идёт дальше (как истёкший pause). Режимы расходятся в том, что попадает в captured_payload: по условию — последнее известное показание (информационный v7-фолбэк; при заданном окне свежести — только не старше окна, иначе протухшее уехало бы в {{prop:*}} под опцией, которая от него отгораживает), «любой статус» — ничего (нового статуса не было; устаревший payload под видом дождавшегося накормил бы следующие шаги старым значением). Асимметрия намеренная: без окна timeout-capture условного режима сохраняет поведение v7, хотя условие с v19 по лежавшему значению уже не проверяется.

  • Персистентность: абсолютный дедлайн (wait_ends_at_utc), baseline (wait_baseline_at_utc, с v19 нужен обоим режимам), окно свежести (wait_accept_after_utc) и наблюдённый payload (captured_payload) WAL-персистятся — незавершённое ожидание переживает рестарт (досып до дедлайна, повторный опрос состояния). Baseline и окно пишутся один раз: пересъёмка на resume съела бы ту самую публикацию, которой ожидание ждёт (или сдвинула бы окно).

  • Ограничения стейт-стора (общие для обоих режимов, не новые): last-payload живёт в памяти пода, поэтому ожидание видит публикации, только пока run исполняется на поде, который их получает (в проде hmq — одна реплика); переполненный канал триггеров дропает публикацию, и она не доходит до стора; пустой payload стор не обновляет, т.е. устройство, публикующее пустое тело, ожидание «любого статуса» не завершит. Это же ограничивает окно свежести (v19): после рестарта/деплоя брокера «последний полученный статус» пуст, и шаг ведёт себя как без опции — ждёт новую публикацию. Соседний нюанс: зеркало стора в Redis (шов SetWatchedTopics) сейчас дремлет без продовых вызовов, но если его снова включить, у watched-топиков появится TTL 1 ч (тише капа 12 ч) и clock-skew подов — окно свежести тогда пересмотреть.

  • Late-binding: свойства целевого устройства из captured_payload доступны последующим шагам через device-квалифицированный плейсхолдер {{prop:<device_id>.path}} (<device_id> = UUID устройства шага wait_until; path — точечный путь в его payload). Такие токены не резолвятся при материализации run (значение ещё неизвестно) — их оставляют нетронутыми и подставляют лениво, на исполнении, из captured_payload соответствующего wait_until (движок накапливает захваты по device_id; повторный захват того же устройства перекрывает прежний). Резолв идёт до WAL-записи шага, поэтому подставленный литерал переживает рестарт; аккумулятор восстанавливается из завершённых шагов при resume. Токен на устройство, ещё не захваченное (forward-ссылка), даёт пустую строку. Обычный (не device-квалифицированный) {{prop:path}} по-прежнему резолвится из payload триггера при материализации (см. раздел «Параметры»).

  • Версия: сам тип действия аддитивен (MINOR), PG-миграции нет. Но секундный таймаут и режим «любой статус» исполнимы только воркером ≥ v7, поэтому Janus стампит schema_version=7 по факту их использования (задан timeout_seconds или condition = null) — пре-v7 воркер округлил бы 45 с до минуты, а null-условие прочитал бы как нулевую, вечно ложную ноду и досидел бы до таймаута; fail-safe скип по версии честнее. Автоматизация в старой форме (условие + минуты) стампится как прежде и работает на старом воркере. Выкат — hmq2 первым: наш редактор пишет timeout_seconds даже при простом переоткрытии старой автоматизации, т.е. пересохранение поднимает её до 7.

Снятый обрыв цепочки по условию

Оборвать цепочку по условию нельзя: оба шага, которые это умели, сняты. Сначала guard (v10, «Проверка внутри цепочки»: прогон продолжался, пока условие ИСТИННО) уступил место stop_run (v17, «Завершение»: прогон кончался, когда условие истинно), а затем сняли и его — до выпуска, поэтому ни одной сохранённой строки ни с тем, ни с другим типом не существует. type: "guard" и type: "stop_run" отвергаются 422 по полю type; молча выбросить такой шаг нельзя — цепочка, которая раньше кончалась на нём, стала бы доигрывать хвост.

Мажоры 10 (второй носитель — операнд времени, он остался) и 17 заняты навсегда; 17 больше не штампуется. Досрочно прекратить прогон можно только правилом отмены по событию у ждущего шага (cancel_on, раздел ниже). Решение — ADR 0022 (отменяет часть ADR 0020).

Отмена по событию (v16; авторский адрес — v18)

Правило cancel_on — поле ждущего шага: снять досрочно можно только дедлайн, а дедлайн есть у шага, не у автоматизации:

{ "type": "pause", "duration_seconds": 600,
  "cancel_on": {
    "device_id": "<датчик движения>",
    "condition": { "op": "=", "left": { "var": "occupancy", "fn": "bool" }, "right": "false" },
    "mode": "finish"
  } }

Адрес правила — либо устройство локации (выше), либо топик, который пишет автор (v18): прибор чужого моста, свой контроллер, под-топик прибора, которого в Janus нет:

{ "type": "pause", "duration_seconds": 600,
  "cancel_on": { "topic": "sensors/hallway/state", "mode": "abort" } }

Пока прогон спит на этом шаге, каждая публикация по адресу правила проверяется условием, и при истине прогон отменяется в режиме mode:

finish

дедлайн текущего шага снимается досрочно, прогон продолжается со следующего шага. Так собирается «свет горит, пока есть движение»: пауза снимается, а выключение доигрывается сразу.

abort

прогон завершается на текущем шаге со статусом aborted (detail=cancelled_by_event), оставшиеся шаги не выполняются. Так собирается «протечку устранили — перестать греть».

Существенные свойства:

  • Носители — только шаги с ДЕДЛАЙНОМ: pause, wait_until и сообщение в ждущем режиме (mode=confirm|prompt). На команде и на уведомлении снимать нечего — обрывать их посреди публикации значило бы прервать само действие, — и правило там отвергается (422). До v16 оно жило на автоматизации, и такой случай был законным no-op с логом cancel_noop: настройкой, которая молча ничего не делает. Переезд существует ровно затем, чтобы этого больше не было.

  • Матчится правило ТОГО шага, на котором прогон стоит сейчас. У соседних ждущих шагов правила разные: «правило автоматизации» вычисляло бы условие по payload чужого устройства — класс ложных отмен, который переезд и закрывает.

  • ``finish`` на ожидании читается как истёкший таймаут: условное ожидание отдаёт дальше последнее известное показание, «любой статус» не захватывает ничего. Иначе хвост цепочки видел бы в {{prop:…}} разное в зависимости от того, ПОЧЕМУ ожидание кончилось.

  • Адрес ровно один → иначе 422. device_id — топик считает сервер, и осиротевшая после удаления устройства ссылка отличима от нормальной: по ней ревизия помечает автоматизацию сломанной. topic — адрес пишет автор: хвост внутри локации, без mount point (его навешивает брокер, вырваться из своей локации таким адресом нельзя), без wildcard +/#, без краевых и пустых уровней /, минимум два уровня — корень драйвера собирал бы сообщения всех приборов сразу. Матч авторского адреса префиксный по границе /: правило на sensors/hallway ловит и sensors/hallway/state; хвост /set законен, то есть «отменить, когда прибору приказали» — выразимо. Это единственное место, где команда считается сообщением по адресу правила: у адреса-устройства (device_id) командные подтопики исключены, см. «Модель исполнения» — там правило слушает статусы прибора, а не приказы ему. Ни ревизия ссылок, ни репойнтер адресов авторский адрес не трогают: связи с устройством у него нет, а правка по совпадению текста переписывала бы чужие адреса (ADR 0023) — переименование прибора протухший адрес молча не поправит.

  • Условие обязательно у адреса-устройства → 422 без него: пустое правило истинно всегда, то есть первая же публикация датчика убивала бы прогон. У авторского адреса условие необязательно, и его отсутствие значит «любое сообщение в этот адрес отменяет шаг» — там нет каталога сигналов, по которому условие можно было бы собрать, а сам факт сообщения в названный автором адрес уже событие (тот же выбор, что у ожидания без условия). В jsonb такая форма хранится БЕЗ ключа condition.

  • Где правило работает. В облаке — на любом топике локации: сообщение, которое драйверный конвейер брокера отбрасывает (неизвестный драйвер, пропущенный под-топик, устройства нет в БД), доезжает до диспетчера отдельной веткой ингресса и только если за этим топиком следит правило с авторским адресом. Два следствия: на бесплатной локации такой PUBLISH отклоняется лимитом устройств раньше брокерского хука (адрес выглядит device-образным), а после рестарта пода первые секунды правило не отменяет ничего — индекс собирается заново (fail-closed), как и у любого вновь добавленного правила, которое начинает работать в пределах минуты. На хабе раннер видит только своё дерево zigbee2mqtt/: адрес вне него делает автоматизацию непереносимой (блокер bare_topic_cancel). Служебные пространства ($SYS/, rocket/) в плоскость отмены не попадают вовсе.

  • Переходные операторы запрещены (changed_to, changed_from_to, crosses_*) → 422: армированное состояние сигнала принадлежит пути триггеров. Ссылки на параметры ({arg}) запрещены по смежной причине: отмену считает диспетчер ВНЕ прогона, а эффективные значения параметров принадлежат конкретному запуску — сравнение шло бы со значением другого. Операнд времени разрешён. Короткая формулировка обоих запретов одна: правило проверяется на каждом сообщении устройства, ещё до того как прогон найден, — поэтому сравнивать не с чем: ни с предыдущим значением (CHANGED_TO, CROSSES_*), ни со значениями параметров запуска.

  • Доставка отмены — at-most-once. Публикация может быть потеряна на переполнении очереди брокера (там же, где теряется и триггер), поэтому «сразу» означает «по первому дошедшему сообщению».

  • Правило целиком — из снимка шага в WAL прогона. Бегущий прогон несёт свои шаги, поэтому ни включение отмены, ни правка её условия до него не доезжают: они действуют со следующего прогона. Так же ведёт себя вся остальная логика прогона, и оба движка — облачный и хабовый — здесь согласованы.

  • Версия: schema_version=16. Воркер, знающий только v15, выбросил бы незнакомое поле при round-trip шага через свою структуру — правило исчезло бы из WAL, и пауза досиделась бы до конца, молча и не так, как просил пользователь.

Сообщение: уведомить, запросить подтверждение, запросить значения (v15)

Шаг message — одно семейство диалогов с человеком, по образцу JavaScript. Режим задаёт поле mode; его отсутствие означает alert, то есть поведение, которое шаг имел всегда:

{ "type": "message", "channel": "tg", "body": "Кран перекрыт." }

{ "type": "message", "mode": "confirm", "channel": "tg", "body": "Открыть ворота?",
  "confirm_label": "Открыть", "decline_label": "Не открывать",
  "timeout_seconds": 600, "on_timeout": "cancel", "on_decline": "cancel" }

{ "type": "message", "mode": "prompt", "channel": "tg",
  "body": "Греем сауну. На сколько включить?", "prompt_arguments": ["minutes"],
  "timeout_seconds": 600, "on_timeout": "cancel", "on_decline": "cancel" }
alert

сообщить и идти дальше. Кнопок нет вообще — отвечать не на что, прогон не ждёт.

confirm

две кнопки с авторскими подписями, прогон ждёт решения.

prompt

форма значений параметров плюс те же две кнопки.

У ждущих режимов исходов ровно три:

согласие

цепочка играет дальше; введённые значения параметров доступны последующим шагам обычными {{arg:…}} и {arg}-операндами — в том числе как длительность паузы (duration_arg).

отказ

разрешается полем on_decline: cancel — прогон завершается со статусом aborted (detail=prompt_declined), continue — шаг завершается, цепочка идёт дальше, а отказ остаётся виден в журнале (result=declined, detail=prompt_declined_continue). Дефолта нет намеренно: «спросил и всё равно сделал» и «спросил и не сделал» — противоположные сценарии. До v15 отказ всегда прерывал прогон (ADR 0016, решение №5); настраиваемым его сделала авторская подпись кнопки — она снимает ловушку «кнопка не делает того, что на ней написано» (ADR 0018).

молчание

разрешается полем on_timeout: continue — прогон идёт дальше (запрошенные параметры сохраняют свои эффективные значения, то есть ведут себя как до появления вопроса), cancel — прогон завершается (detail=prompt_timeout). Дефолта нет намеренно: молчание — самый вероятный исход, и цена ошибки у вариантов противоположная.

Существенные свойства:

  • Ждущий шаг ДЕРЖИТ прогон. Шаги после вопроса зависят от ответа, ждать и есть его смысл. Плата — инвариант «один активный прогон»: пока вопрос висит, автоматизация не срабатывает. Отсюда потолок таймаута 1 час (у паузы и ожидания — 12): полсуток немоты автор не выбирает осознанно.

  • Ответ приходит на страницу по непрозрачному токену (/p/{token}), а не кнопкой в чате: входящего канала от мессенджер-бота не существует. Страница отвечает по POST, а не по переходу — ссылку открывают анти-вирусные прокси и превью-боты, и «подтверждение» по GET они выполнили бы за жильца.

  • Подписи кнопок задаёт автор (confirm_label/decline_label, ≤ 32 символа). Пустые — страница берёт перевод по умолчанию, причём у кнопки отказа он зависит от on_decline.

  • Значение по умолчанию в форме — эффективное значение параметра на момент вопроса (argument_values либо preset). Его часто нет вовсе: спрашиваемый параметр как раз освобождён от требования иметь значение к моменту включения, и тогда поле открывается пустым.

  • Первый ответ выигрывает. Ссылку пересылают и открывают повторно; второй клик ничего не переигрывает.

  • Нет ссылки — нет вопроса. Адрес страницы собирается из конфига брокера localNotifications.ackBaseUrl; если он пуст, шаг падает с ошибкой, называющей настройку, вместо отправки сообщения, на которое нечем ответить (см. Вопросы жильцу из локальных прогонов).

  • Запросить можно только объявленный параметр, и его нельзя использовать в шагах ДО вопроса и в тексте самого вопроса → 422: до ответа значения нет, и токен отрендерился бы пустотой. Определения параметров снимаются в момент вопроса — правка автоматизации, пока вопрос висит, на уже отправленную форму не влияет.

  • ``mode=prompt`` требует непустого ``prompt_arguments``, а confirm — его отсутствия (422): вопрос без единого поля — это подтверждение, и называться он должен так же.

  • Ждущие поля запрещены у ``alert`` → 422 (а не тихий срез): шаг, который выглядит спрашивающим, а работает как односторонняя телеграмма, — худший вид отказа.

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

  • Отмена по событию работает и настраивается на самом ждущем шаге (v16): abort завершает прогон, finish читается как истёкшее окно ответа (то есть применяет on_timeout) — хвост цепочки не должен уметь отличить отменённый вопрос от протухшего. А вот продлить вопрос нельзя: дедлайн у него есть, но двигать окно ответа задним числом бессмысленно — он уже отправлен, и поле retrigger на этом шаге отвергается на записи.

  • Локальное исполнение поддержано. Хаб задаёт вопрос событием журнала prompt (отправляет облако — транспорта чатов на хабе нет) и ждёт; ответ возвращается вниз retained-документом {mount}/rocket/automations/answers. Требует раннера ≥ 1.6.0; топик ответов — broker-only.

  • Версия: schema_version=15 — и только у ждущих режимов. alert мажор не поднимает: это поведение, которое шаг имел до v15, и тысячи сохранённых уведомлений не обязаны узнать о появлении режимов.

Снятый шаг ask (v12)

До v15 вопрос был отдельным типом шага ask. Тип снят: он делил с message канал, текст, тему, получателя и половину валидации, а отличался ровно тем, ждёт ли прогон ответа — то есть был режимом, оформленным как отдельная сущность. Мажор 12 занят навсегда и больше не штампуется.

Конвертации не было (сознательное отступление от правила «MAJOR = backfill + каткавер», см. ADR 0018): на момент выката ни одной автоматизации с этим шагом в проде не существовало. Запись шага type: "ask" отвергается на валидации, редактор показывает такой шаг нередактируемым с объяснением, а воркер закрывает уже созданный прогон надгробием — завершает его как отменённый, а не падает с «неизвестным типом шага».

Коридор поддержания (из пороговых триггеров)

Удержание измеряемого свойства датчика (напр. temperature) в коридоре low..highне отдельный примитив, а композиция из уже существующих: одна device-триггерная автоматизация с двумя блоками. Движок исполняет condition_blocks по порядку и берёт первый матчащий, а пороговые операторы (<, >, = …) вычисляются над каждым показанием датчика:

// Триггер: device (топик датчика). Автоматизация САМОСТОЯТЕЛЬНАЯ — см. «Группа» ниже.
"condition_blocks": [
  { "condition": {"op":"<","left":{"var":"temperature"},"right":63},
    "then_actions": [ /* device_command: актуатор ON */ ] },
  { "condition": {"op":">","left":{"var":"temperature"},"right":67},
    "then_actions": [ /* device_command: актуатор OFF */ ] }
]

Ниже low (63) → первый блок → ON; выше high (67) → второй блок → OFF; между порогами ни один блок не матчится → актуатор держит состояние (гистерезис = зазор high−low). Гейт «одна активная сессия на автоматизацию» не даёт наложиться повторному run.

  • Гейтинг по группе — ДЕКОММИССИРОВАН как механизм, но выразим как условие (v22). Прежде коридор вешали на группу (owner_device_id), и hmq2 пропускал его run, пока on_off-регистр группы = OFF. Вход логики группы сузился до её собственных регистров (см. «Группа как устройство»), поэтому device-триггер с owner_device_id отвергается на записи (422), а гейт в рантайме hmq2 остался мёртвым кодом. Возвращать его не понадобилось: с адресным операндом «пока группа включена» пишется прямо в условии блока — on группы = ON в И с порогом. Прежняя формулировка «способа включать коридор из другой автоматизации в языке нет» больше не верна.

  • Версия: обычная device-грамматика — schema_version стампится 1/2/3 как любая device-автоматизация.

  • Ограничения (осознанно): fail-safe при пропаже датчика нет — если датчик замолчал, актуатор держит последнее состояние (при необходимости — отдельный watchdog-триггер). Прежний примитив start_reading_reconciler (device_reading_reconcilers, schema_version=5) декоммиссирован.

Группа как устройство: одна логика + фоновые задачи

Устройство-группа (devices.kind='group') — first-class устройство: снаружи оно неотличимо от обычного (панель и Алиса пишут в его регистры через device_expose_states), а внутри у него есть собственное поведение. Оно описывается одной логикой плюс сколькими угодно фоновыми задачами. Различает их роль владения — automations.group_role (logic | task): владелец у них один и тот же, а грамматика входа разная.

Логика группы (group_role = 'logic')

  • Одна логика на группу. Инвариант держит partial unique automations (owner_device_id) WHERE NOT is_system AND group_role = 'logic' и предпроверка во write-path (422 вместо 23505). Задачи в этот индекс не попадают — их у группы много.

  • Вход — только обращение к самой группе. У логики допустим ровно один вид триггера — group_message: сообщение на её MQTT-адрес ({loc}/generic/{friendly}/set или …/get), направление обязательно. time и device отвергаются (422): расписание и события чужих устройств — грамматика локации. Нужен график — заводится обычная автоматизация, которая командует группой как устройством. Прежний вход group_expose (изменение регистра) снят вместе со своим диспетчером: он узнавал о записи ПОСЛЕ неё, то есть проверить входящий статус уже не мог.

  • Блок принадлежит ветке. У каждого блока логики группы есть direction, совпадающее с одним из объявленных направлений: иначе ответ на /get прокручивал бы шаги записи. Объявленная ветка без единого блока — 422.

  • Команда группе. Шаг device_command, нацеленный на устройство-группу, — обычная команда: у группы есть свой адрес, шаг сохраняется как есть и уезжает на {driver}/{friendly}/set. Сообщение запускает её логику, а регистры пишет только шаг set_group_expose внутри логики. Прежнее переписывание команды в набор set_group_expose снято. Детектор циклов диспатча при этом остался: кольцо «группа A командует B, B командует A» отвергается на записи. Значение bool-регистра принимается только в однозначной форме (ON/OFF/true/false/1/0): TOGGLE и плейсхолдер {{arg:…}} отвергаются 422 — «инвертируй» в регистре невыразимо, а подстановка идёт по строковым полям шага, а не по значению. Текст ошибки называет блок и шаг: ключ ошибки у автоматизаций один на всё тело, и без адреса непонятно, какой из шагов править.

  • Параметров у логики группы нет (422 на непустой arguments; PATCH /argument-values → 404; разовое расписание не добавляется). Параметр — шаблонизация, значение которой вводят в конкретный момент: запуск, разовое расписание, сообщение в режиме «спросить значения». У группы такого момента нет — её запускает собственный регистр, то есть железо, — а обязательный параметр без значения гасит автоматизацию, то есть молча ломает поведение устройства. Меняемое значение выражается обычной автоматизацией, которая командует группой. Ссылки ({arg}, {{arg:…}}, duration_arg, prompt_arguments) отдельного правила не требуют: без объявления их отвергает проверка ссылок.

Фоновая задача (group_role = 'task', v21)

Фоновая задача — цепочка шагов, которая повторяется, пока включён on_off-регистр группы. Это отдельная автоматизация владельца с единственным видом входа — task (automation_task_triggers: ветка гашения и денормализованное имя регистра-выключателя; настроек темпа у входа нет). Прежний примитив — дути-цикл (таблицы device_duty_tasks / device_duty_cycles и шаги start/stop_duty_cycle) — снят целиком, без обратной совместимости: он умел ровно «одно устройство, два состояния, круг», а задача — произвольную циклограмму, регулятор и осмысленное гашение. Ни таблиц, ни шагов, ни предохранителя больше нет; шаг снятого типа отвергается обычной проверкой перечисления. См. ADR 0024.

  • Окно исполнения = прогон. Цикл крутится ВНУТРИ одного долгоживущего прогона; «один активный прогон на автоматизацию» — существующий инвариант, и «не в паузе и не в ожидании — значит идёт» получается из него даром. Именно поэтому задача не может быть веткой логики: вечный сеанс заблокировал бы обработку обращений к самой группе.

  • Темп задаёт ЦЕПОЧКА, а не автор входа. Окно исполнения одно на все задачи — минута — и считается от НАЧАЛА круга: ожидание равно max(0, 60 с длительность круга). Уложившийся в окно круг досиживает остаток; круг, шедший дольше, начинает следующий немедленно, ничего не догоняя. Троттл именно от начала, а не пауза после конца: цепочка, которая сама держит время внутренними паузами, не получает мёртвых секунд сверх своей длины, и скважность ШИМ не искажается постоянной добавкой к такту. Сверх этого есть общий потолок публикаций на локацию; исчерпав его, задача пропускает круг, но отказом это не считается и гашения не касается. См. ADR 0025 и 0026.

  • Ресурс реле — ответственность автора. Пол периода (300 с для цепочки, обращающейся к одному адресу дважды за круг) снят вместе с самим периодом. Цепочка ON OFF без пауз щёлкает раз в минуту — это ~1440 коммутаций в сутки и ресурс типового механического реле (~10⁵ циклов) примерно за два месяца. Нужен более редкий цикл — его держит пауза в конце круга (так устроены демо-сидеры: 30 с работы + 270 с отдыха). Гистерезис как форма предпочтительнее ШИМ на реле: при равной пульсации он щёлкает реже и выражается двумя шагами wait_until.

  • Старт — в момент ЗАПИСИ выключателя, и других входов у супервизора задач больше нет: пятисекундного тика реконсилера не существует (ADR 0028, решение 22). Писатель регистра будит его сам — и для MQTT-входа, и для шага автоматизации, и для панели с Алисой: панель шлёт команду публикацией на MQTT-адрес группы (задачей очереди), Алиса впрыскивает сообщение в тот же вход ин-процессно. Прежняя пара «запись desired_value + поллер моста» снята целиком, задержка везде секунды. Полноту, которую раньше давал тик, даёт сверка: о правках Janus сообщает pg_notify из той же транзакции, что и сама запись, а движок делает полный проход при каждом подключении слушателя — уведомление не переживает разрыв соединения.

  • Тело — ЦИКЛ, и блоки означают «Пока верно» (v29). Подпись у них своя — «Пока верно» / «Или пока верно», — потому что и смысл другой: у остальных автоматизаций блок выбирается один раз, на старте прогона, а здесь КАЖДЫЙ круг. Порядок разрешения прежний, первый истинный.

    Не держится ни одно условие — сеанс кончается: ветка остановки отрабатывает, выключатель группы гаснет, прогон закрывается completed, сломанной автоматизация не помечается. До v29 здесь пропускался круг, и задача, у которой не осталось ни одной причины идти, занимала нагрузку и потолок задач локации, пока человек не щёлкнет выключателем.

    «Держись, ничего не делай» пишется блоком с условием и ПУСТЫМ списком действий. Так выражается коридор между порогами и так же — «пережидать молчание датчика» (not exists → ничего). Обе половины выбора наконец выразимы: раньше пережидание было поведением ПО УМОЛЧАНИЮ и отменить его было нечем, теперь это решение автора, а умолчание — закончиться.

    Отсюда следствие, которое надо знать заранее: неполное покрытие условий стало несущим. Зазор между блоками (t < 22 и t >= 25 при t = 23) раньше значил «держи как есть», теперь — «кончи и погаси». Это цена выразительности, а не побочный эффект.

  • У блока есть момент сверки (check): before (умолчание, перед кругом) или after — блок держится, ПОКА САМ НИ РАЗУ НЕ ОТРАБОТАЛ в этом сеансе. Долг в один круг принадлежит БЛОКУ, а не сеансу: блок, проигравший первый круг соседу, своё право не теряет, — иначе «в конце обязательно продуть» не сыграло бы ни разу у задачи, начинающейся с прогрева.

    После своего круга блок сверяется как обычный — и здесь стоит ловушка, стоившая живого прогона. after значит «сначала круг, потом спрашиваем», а НЕ «сыграть однажды»: пустое условие после гарантированного круга значит «верно всегда», то есть блок держится вечно и соседи не сыграют ни разу. Поэтому after без условия отвергается (422), а «однажды в начале» пишется условием, которое сам круг и опровергает:

    { "check": "after",
      "condition": { "op": "=", "left": { "var": "phase", "device_id": "…" }, "right": "подготовка" },
      "then_actions": [ …, { "type": "set_group_expose", "expose_name": "phase", "value": "парение" } ] }
    

    Круг первый идёт без вопросов (условие не вычисляется вовсе), а к следующему кругу тот же круг уже сделал условие ложным — и цикл уходит соседнему блоку.

    Слово until в значении поля не используется намеренно: в Ruby/Perl/Tcl оно значит «пока НЕ», и в грамматике, где блок подписан «Пока верно», прочиталось бы как отрицание.

    Проверять есть чем только адресным операндом и операндом времени: события у задачи нет, и свойство без адреса она не принимает (422) — оно сравнивалось бы с пустотой. Правка условия, как и правка шагов, доезжает после выключения и включения группы: сеанс читает описание один раз.

    Ветвление НЕЛЬЗЯ разложить на несколько задач: захват целей отбивает вторую задачу, пишущую в ту же нагрузку, как встречный поток — а ветви одного решения пишут именно в неё.

    Параметров у задачи нет (см. логику), ретриггера и правила отмены нет (оба отвечают на повторное СРАБАТЫВАНИЕ, а задача не срабатывает — она идёт), ждущих сообщений нет (дедуп вопросов — уникальность (прогон, блок, шаг), а прогон у задачи один навсегда: следующий круг получил бы ответ предыдущего).

  • Ветка гашения (on_stop) исполняется при разборке сеанса — выключили группу, выключили задачу, круги подряд отказывали. Она живёт ВНУТРИ того же прогона и только при живой лизе: отдельным прогоном её не запустить (старт требует активной автоматизации, а самая частая причина остановки — как раз выключение).

    Ветка гашения бывает только у задачи. Мажор 24 раздавал её любой автоматизации; он отозван (см. «Ветка гашения» ниже), и on_stop у не-задачи отвергается 422. У задачи ветка мажора не поднимает — её грамматика не менялась. Хранится она при этом в automations.on_stop, а не в строке входа: место переезда отката не потребовало, потому что двойной источник хуже отката. В бандле хаба ветка по-прежнему едет внутри входа (triggers[].on_stop).

  • Исход отказа — у ШАГА, а не у задачи (then_actions[].on_failure, v29): continue — идти дальше со следующего шага, cancel (умолчание) — закончить сеанс с гашением. «Печь не включилась» и «не ушло уведомление» — отказы разной цены, и в одной цепочке они соседствуют постоянно; прежний общий на всю задачу triggers[].on_failure (v28) такой разницы выразить не мог и снят (422 с названной заменой).

    Умолчание cancel НАМЕРЕННО расходится с умолчанием on_timeout у ожидания. Там continue значит «шаг отработал, дождались не того»; здесь — «действия не произошло вовсе». Тихо считать такой круг успешным опаснее всего в самом типовом случае: у циклограммы «включить → пауза → выключить» падение закрывающей команды оставило бы нагрузку включённой навсегда.

    Круг с проглоченным отказом (continue) всё равно считается НЕудачным для предохранителя: иначе десять отказов подряд не наступили бы никогда, и задача с навсегда сломанной публикацией крутилась бы вечно, выглядя здоровой.

    Поле законно только внутри задачи и не у всякого шага (иначе 422): у wait_until исход уже есть (on_timeout), а control не отказывает вовсе — без пригодного измерения он публикует fail_safe.

  • Условие продолжения сеанса (while, v23) — СНЯТО (ADR 0031 → отменено ADR 0037). Поле больше не принимается: 422 с указанием замены. Молчаливое вырезание было бы хуже отказа — сеанс, который кончался сам, стал бы вечным, и автор узнал бы об этом от нагрузки, а не от формы.

    Замена — сами блоки задачи (v29): «Пока верно» и есть условие продолжения сеанса, а «пока выполняется» стало избыточным способом сказать то же самое. Форма, ради которой поле снимали, — отдельная задача-взводчик, пишущая выключатель РАБОЧЕЙ группы, — тоже осталась и упростилась:

    Пока верно:      exists(t) И t < 22   → включить «Нагрев»
    Или пока верно:  exists(t) И t >= 25  → выключить «Нагрев»
    Или пока верно:  45 <= t < 52         → (ничего)      ← коридор, сеанс держится
    (не держится ни одно — сеанс кончается веткой остановки и гасит свой выключатель)
    

    Fail-safe «нет показания ⇒ гасим» стал ПРЯМЫМ: показания нет — ни одно условие не держится — сеанс кончается сам, и отдельный блок not exists для этого больше не нужен. Спрашивает exists именно «есть ли показание по адресу»: прибор, ПЕРЕСТАВШИЙ публиковать, последнее показание из хранилища не стирает — его ловит окно свежести у ожидания (v19), а не этот оператор. Ровно так же вёл себя и снятый while. Композиция вдобавок разворачивается в правильную сторону: сеанс ПОЛЬЗУЕТСЯ нагрузкой, а не нагрузка подглядывает за сеансом через её регистр, — и рабочая группа становится переиспользуемой.

    Мажор 23 за волной остался: его несут исход ожидания и ветвление. Подробности — ADR 0037.

  • Потолка работы больше нет (max_runtime_seconds снят). Вместе с ним пропал единственный предохранитель от ЗАЛИПШЕГО датчика: окно свежести показания ловит ЗАМОЛЧАВШИЙ прибор, но не тот, который бодро публикует правдоподобные 61 °C. Сеанс ограничен только выключателем группы — это осознанный размен, см. ADR 0026. Оттуда же следствие: сеанс читает своё описание ОДИН раз при старте, поэтому правка задачи доезжает после выключения и включения группы.

  • Удаление двухфазное. Снос идущей задачи — 409: сначала выключить, дождаться остановки, потом удалять, иначе гасить нагрузку будет некому. force=1 снимает без остановки, и последствие названо в ответе. Удаление ГРУППЫ делает то же самое перед каскадом: выключает её задачи, ждёт подтверждения ограниченное время и честно предупреждает, если движок не ответил.

  • Потолок задач на локацию — 20, и считаются только НЕ спящие: у группы без выключателя задачи не запускаются никогда, и держать их в бюджете значило бы отдавать лимит тому, что не работает. Обратная сторона закрыта проверкой при правке устройства: возвращение выключателя будит задачи группы разом, и без неё потолок обходился бы парой правок.

  • Исполнение на хабе. Задача ПЕРЕНОСИМА, в отличие от логики группы: та живёт входом «сообщение на адрес группы», которого на локальной шине нет вовсе, а задача трогает группу ровно одним способом — выключателем. Он приезжает на хаб ЗЕРКАЛОМ состояния групп (retained rocket/groups, см. Локальное исполнение автоматизаций (Zigbee2MQTT)), и fail-safe у зеркала односторонний: нет документа, нет группы или регистр выключен — гейт ЗАКРЫТ. Требуется раннер 1.14.0: не зная режима сеанса, он проиграл бы цепочку ровно один раз и закрыл прогон. Не переносится задача, у которой не резолвится выключатель (group_gate_unreachable) или чей регулятор зеркалит выход в регистр группы (group_mirror_unreachable) — записать в облачный регистр раннер не может.

  • Версия: schema_version = 21 у линейной задачи (штамп несёт триггер task, шаг control и pause.duration_from) и 22 — у ветвящейся либо с условием круга.

Ветвление внутри цепочки (branch, v23)

Тело шага — те же блоки «Если → Тогда», что и у самой автоматизации. Работает первый подошедший; блок без условия подходит всегда и потому играет роль «иначе». Не подошёл ни один — ветвление не делает ничего, и цепочка идёт дальше со следующего шага:

{ "type": "branch", "blocks": [
    { "condition": { "op": ">=", "left": { "var": "temperature", "device_id": "<uuid>" }, "right": 24 },
      "then_actions": [ { "type": "device_command", "device_id": "<uuid>", "payload": "{\"state\":\"OFF\"}" } ] },
    { "condition": null,
      "then_actions": [ { "type": "device_command", "device_id": "<uuid>", "payload": "{\"state\":\"ON\"}" } ] } ] }

Отличие от блоков автоматизации ровно одно, и в нём весь смысл. Блок выбирается ОДИН раз, когда прогон начинается; ветка — в тот момент, когда цепочка до неё дошла. После паузы или ожидания обстановка уже другая, и до v23 сказать «а теперь посмотри ещё раз» было нечем. Всё остальное — форма условия, поведение «неизвестно», порядок перебора — совпадает намеренно: автор не должен учить язык дважды.

Обрыв цепочки по условию. Пустой список шагов у ветки законен (в отличие от пустого блока автоматизации, который означал бы прогон впустую). Подошедшая ветка закрывает собой все следующие, поэтому ветвление последним шагом с пустой первой веткой — это ровно тот обрыв по условию, который снял ADR 0022 вместе со шагом stop_run, но выраженный ветвлением. Чтобы прекратить цепочку в середине, остаток переносится ВНУТРЬ ветки.

Правило продления ветвление не переигрывает. retrigger: extend_while смотрит только на условие блока, из которого растёт прогон: ветка к моменту продления уже выбрана. Именно эта связка убила stop_run, и здесь её нет.

Ограничения (все 422 на записи):

  • глубина вложения ровно одна — ветка внутри ветки отвергается. Причина не в движке, а в чтении: цепочка с двумя уровнями перестаёт читаться сверху вниз, а ради читаемости ветвление и предпочли снятому обрыву;

  • ``control`` внутри ветки нельзя — он кладёт выход в переменную прогона, которой не появилось бы вовсе, пойди цепочка другой веткой;

  • ``message`` в ждущем режиме внутри ветки нельзя — вопрос заполняет параметр для шагов ниже, и «сначала спросили, потом использовали» перестало бы быть свойством текста. Уведомление можно;

  • операторы перехода в условии ветки нельзя (CHANGED_TO, CHANGED_FROM_TO, CROSSES_*): «предыдущее значение» принадлежит краю события, запустившего прогон, а у CROSSES_* вдобавок есть гистерезис, ключуемый идентификатором автоматизации, — второе вычисление посреди цепочки сдвинуло бы взведённый флаг под условием блока, которое его уже использовало;

  • в теле фоновой задачи ветвления нет — развилка у неё уже есть и она верхнего уровня: блок выбирается каждый круг заново (v22).

Шаги веток и узлы их условий считаются в общих потолках тела (100 шагов, 400 узлов): потолок, который не видит вложенного, обходился бы одним шагом «Ветвление».

Исполнение — раскрытие. Дойдя до шага, прогон вставляет шаги подошедшей ветки в свой собственный список сразу за ветвлением и пишет расширенный список в WAL. Позиция шага остаётся числом, а не превращается в путь, — поэтому дедуп вопросов, ключ идемпотентности шага, продление, отмена по событию и фазы прогона на шине работают без единой правки. В журнале прогона цепочка поэтому видна развёрнутой: шаг ветвления и за ним шаги той ветки, которая сработала. Итог самого шага — branch_taken: ветка N, шагов M либо branch_no_match: (с причиной, если ветка не подошла из-за отсутствующего показания). Подробности — ADR 0030.

Шаг «Регулятор» (control, v20)

Считает выход по измерению и уставке и кладёт его в переменную прогона (output.var), доступную следующим шагам круга. Поля «закон» нет: закон определяется тем, какие коэффициенты ненулевые. Живёт только внутри задачи — вне цикла такт некому повторять (422):

{ "type": "control",
  "input":    { "device_id": "…", "property": "temperature", "max_age_seconds": 900 },
  "setpoint": { "value": 90.0 },
  "gains":    { "kp": 8.0, "ki": 0.02, "kd": 0.0 },
  "direction": "heat",
  "output":   { "var": "power", "min": 0, "max": 100, "bias": 0, "deadband": 1.0 },
  "fail_safe": 0 }

Такт: измерение берётся из кросс-подного стора сигналов по топику (его резолвит Janus из input.device_id) вместе с меткой времени наблюдения. Нет значения или оно старше max_age → выход равен fail_safe и публикуется (иначе нагреватель не гаснет). Метка не изменилась → такт не считает, не публикует и ничего не пишет — именно это позволяет крутить задачу часто, а датчику говорить редко. Новое измерение → dt считается измерение-к-измерению с клампом [1 с, max_age]; интеграл копится в единицах выхода (правка kp безударна по построению) с заморозкой при насыщении; производная берётся по измерению и требует d_filter_seconds при kd 0 (422); само окно max_age не короче 180 секунд — трёх кругов задачи (422): уставку крутит человек, а измерение — ступенька с шагом квантования датчика. Выход из молчания идёт через прайминг, иначе первое измерение после часа тишины дало бы виндап через восстановление. Состояние привязано к хешу конфигурации: правка коэффициентов сбрасывает накопитель.

Мёртвая зона обязательна и это не украшение: зеркало выхода (mirror_expose) пишется в регистр группы, а запись регистра инъектируется обратно в движок как событие устройства — контур с окном в минуту без мёртвой зоны дал бы 1440 синтетических срабатываний в сутки.

Уставка из регистра (setpoint.expose) — это ручка на панели: человек меняет «целевую температуру», и задача подхватывает её следующим тактом, не пересобираясь.

Длительность из значения (pause.duration_from, v20)

Превращает выход регулятора в скважность медленного ШИМ:

{ "type": "pause",
  "duration_from": { "var": "power", "in": [0, 100], "scale": [0, 60] } }

Источник ровно один: var — переменная прогона (её кладёт регулятор ЭТОЙ же задачи) либо expose — числовой регистр группы (так значение читается через границу задач: у двух задач разные прогоны, и общих переменных у них нет). С константой и duration_arg поле не сочетается (422): те вычисляются один раз, а это — каждый круг.

Вырожденный сегмент — длина, не дотянувшая до секунды, — пропускается ЦЕЛИКОМ, вместе с командой, которая его открывает: иначе получился бы щелчок ON→OFF без паузы между ними. Так нулевая мощность и означает «не включать».

Порог min_seconds, которым это выражалось раньше, снят (422 на записи). Он был обязательным полем формы, а его подпись объясняла себя неверно: ноль отсекается сам, без всякого порога. Вместе с полем ушла и возможность сказать «сегмент короче N секунд не играть» — если она понадобится, вернуть её придётся как отдельную, честно названную настройку, а не как предохранитель.

Регистр держит секунды: тождественное отображение

Когда в источнике лежат уже секунды, а не проценты, границы обоих диапазонов делают одинаковыми:

{ "type": "pause",
  "duration_from": { "expose": "duration", "in": [0, 43200], "scale": [0, 43200] } }

Потолок 43200 — потолок самой паузы (12 часов), длиннее её не бывает, поэтому обрезать такому отображению нечего; автор просто называет его вслух.

Длина из регистра ЖИВАЯ (v30)

Пока пауза спит, она перечитывает свой регистр и переставляет срок: конец шага = вход в шаг плюс текущее значение. Так «париться ещё полчаса» выражается правкой настройки прибора, а не отдельной командой прогону, — и одинаково работает, кто бы настройку ни написал: панель, автоматизация, бот или Алиса, все они попадают в один и тот же регистр.

Срок двигается в любую сторону: настройка и есть истина. Значение меньше уже прошедшего времени заканчивает шаг сразу, и цепочка идёт дальше — так «хватит» выражается тем же полем, что и «ещё». Потолок и пол прежние (12 часов, секунда).

Живой источник — только регистр. Длина из переменной круга (var) остаётся снятой на входе: там лежит выход регулятора ЭТОЙ итерации, и меняться, пока шаг спит, ему нечем. Значение ПАРАМЕТРА живым не бывает по устройству языка: параметры запекаются при материализации прогона разом везде — в топике, теле команды, условии и длине, — и «живой» в одном месте из десяти он вводил бы в заблуждение. Правка значения параметра по-прежнему действует со следующего запуска.

Продление живёт один круг и не переживает рестарт пода: сеанс фоновой задачи начинает круг заново, а следующий возьмёт из регистра то, что там лежит.

Штамп — schema_version = 30, порог раннера хаба — 1.28.0. Оба обязательны, хотя форма записи не изменилась ни на поле: воркер без волны прочитает ту же паузу и досидит длину, снятую на входе, ничем не показав, что правку настройки проигнорировал, — а автор увидит прежний конец сеанса и решит, что настройка не сохранилась.

Отдельный режим duration_from.mode = "seconds" (v28), делавший то же самое без диапазонов, снят: ключ mode на записи отвергается (422), сохранённые автоматизации переписаны миграцией. У него были свои, обратные запреты (in и scale запрещались, min_seconds становился необязательным) и обратное поведение на пустом источнике — не «сегмента нет», а отказ шага; в редакторе же выбрать его было нечем. Мажор 28 остался без носителей вовсе.

Пустой источник всегда значит «сегмента нет» — «мощности нет, не включать», ровно то, что нужно ШИМ; отказом шага это не считается. Нужно, чтобы отсутствие значения кончало сеанс, — это пишется условием блока задачи: «Пока верно: duration существует».

Плоскость автоматизаций на шине (ADR 0028)

У автоматизации есть собственный адрес в MQTT — внутри mount point локации:

automations/{kind}/{name}            состояние         ← только сервер
automations/{kind}/{name}/event      фаза прогона      ← только сервер
automations/{kind}/{name}/result     ответ на команду  ← только сервер
automations/{kind}/{name}/set        команда           ← клиент
automations/{kind}/{name}/get        запрос состояния  ← клиент

{kind}tasks | logic | rules, из иммутабельной роли автоматизации в группе. Отдельный сегмент под вид существует ради одной практической подписки: automations/tasks/# — «покажи только фоновые задачи». {name}automations.friendly_name: по умолчанию hex идентификатора, поэтому переименование автоматизации в панели адрес не двигает.

У группы две плоскости, и это не дубль: как устройство она отвечает по generic/{friendly} (её регистры), как автоматизации её логика и задачи говорят по automations/logic/* и automations/tasks/* (прогоны и фазы). Первое — про то, чем группа управляет; второе — про то, что с ней происходит.

Существенные свойства:

  • Пространство ``automations/`` закрыто для авторских топиков шага «публикация в топик» и правила отмены — 422 на запись. Такое ребро не видит ни автор-тайм детектор циклов (он строит граф по адресам групп), ни рантаймовый ограничитель (он ключуется устройством группы), и пара взаимных запусков раскрутилась бы без предела.

  • Три серверных топика клиенту публиковать нельзя. Состояние, фазу и ответ на команду объявляет только сервер; иначе любой, у кого есть логин локации, объявлял бы «прогон идёт» или «команда выполнена», и внешний клиент верил бы подделке.

  • Сколько автоматизация рассказывает о себе, решает она самаtelemetry_level: off | state | events | steps, накопительно. Уровень на КАЖДОЙ автоматизации, а не на локации: шумит конкретная задача с окном в минуту, глушить нужно её. Ответ на /get уровню не подчиняется — молчание в ответ на прямой вопрос неотличимо от потери.

  • Состояние не ретейнится. Стор брокера живёт в памяти процесса и теряется на рестарте, а подписчик не отличил бы снимок от живого. Роль «узнать состояние» играет /get.

  • Адрес отвечает не мгновенно после создания: резолв кэшируется на минуту, включая отрицательные вердикты (защита от потока запросов по несуществующим именам).

Команды (/set) исполняет janus, а не брокер: доменная логика тумблера (проверка графа на цикл, взвод ближайшего запуска, гашение висящих вопросов) живёт там, и второй её реализации быть не должно. Понимаются:

{"state": "ON"}                                 включить (диалект устройств)
{"state": "OFF"}                                выключить
{"command": "run", "arguments": {…}}            разовый запуск (у видов ``tasks`` и
                                                ``logic`` его нет — ``unsupported``)
{"command": "stop"}                             оборвать идущий прогон, тумблер не трогая;
                                                у ФОНОВОЙ ЗАДАЧИ — выключить задачу (ответ
                                                несёт "deactivated": true)
{"command": "deadline", "ends_at": "…"}         сдвинуть срок спящего шага (пауза или
                                                ожидание) на момент ISO-8601 — в любую
                                                сторону, в окне от секунды до 12 часов
{"command": "finish_step"}                      закончить спящий шаг сейчас; цепочка идёт
                                                дальше, как если бы срок наступил
{"command": "telemetry", "level": "events"}     сменить уровень телеметрии
{"command": "telemetry", "level": "steps",
 "minutes": 15}                                 включить шаги на время (по умолчанию 30 мин)

Ответ приходит на .../result ВСЕГДА, когда адрес разобрался: молчание неотличимо от потери команды. В ответе ok, при отказе — машинный error (invalid_payload, unsupported, not_found, not_running, broken, rejected, internal) и человеческий message. Присланный request_id возвращается эхом — это и есть способ сопоставить ответ с командой.

Разовый запуск у выключенной автоматизации отвергается (rejected), как и у сломанной (broken): планировщик отбирает только включённые, и «принято» было бы враньём — взведённое расписание не сработало бы никогда, а место под потолком разовых запусков заняло бы. У логики группы и у фоновой задачи его нет вовсе (unsupported): их вход — сама группа и её выключатель, а разовое расписание владельцу группы write-path не добавляет.

Ретейненная команда не исполняется. Брокер ретейнит любой топик, /set исключением не является, а сессия у подписчика чистая — снимок приходил бы заново на каждом его переподключении, то есть один раз опубликованный с retain разовый запуск исполнялся бы при каждом рестарте пода. Команда — не состояние; её место — обычная публикация. Ответ на такую команду не уходит: адресат ответа — тот, кто послал её сейчас, а здесь никто ничего не посылал.

Остановка сеанса фоновой задачи выключает задачу. Сеанс живёт, пока включён гейт группы: оборви его и оставь задачу включённой — реконсилер заведёт новый сеанс на ближайшей побудке, и «остановлено» окажется «перезапущено». Выключение видно в том же тумблере, которым задачу включают обратно; скрытая «пауза до перевключения гейта» стала бы новой загадкой «почему не идёт», а выключение гейта погасило бы все задачи группы разом. Обычного прогона это не касается: у него нет гейта, который его перезапустит.

Две задержки названы честно: разовый запуск ждёт до минуты (расписания живут на минутной сетке, «сейчас» усекается в прошлое) — а у автоматизации с подготовкой ещё и саму подготовку: lead_seconds прибавляется к времени запуска, иначе анти-дрейф относительных пауз отверг бы такой запуск всегда (подготовка не бывает короче минуты). Выбранное время возвращается в ответе полем at. Остановка замечается движком на такте heartbeat — как и выключение тумблера. В истории прогонов видно обе метки: когда попросили и когда остановилось.

Состояние — тот же диалект, что у устройств и групп (state = "ON"/"OFF"), плюс фаза (idle | running | waiting), текущий прогон, последний исход, broken с причиной, ближайший запуск и место исполнения. Фаза waiting означает «прогон спит на паузе или ожидании» — это отдельный ответ на вопрос «почему ничего не происходит».

Фазы прогона (/event)

Состояние отвечает на вопрос «что сейчас», фазы — на другой: «что произошло». Без них внешний клиент не дождётся конца прогона и не отличит «кончился успешно» от «кончился, потому что выключили»: снимок показывает исход только последнего, и два прогона подряд сливаются в один.

Уровень events печатает жизнь прогона:

run_created  run_started  run_completed  run_failed  run_interrupted  run_extended
run_stopped_deactivated  run_stopped_by_request  run_stopped_by_prompt
run_stopped_task_reason  run_stopped_by_retired_step  run_cancelled_by_event
run_skipped_already_running  run_skipped_definition_gone
run_skipped_no_block_matched  run_skipped_group_gated_off
task_session_started  task_session_stopped  task_session_interrupted
task_session_ended_by_condition  task_session_ended_by_wait
task_session_broken  task_session_stalled  run_prompt_armed

Уровень steps добавляет шаги и круги фоновой задачи:

step_started  step_succeeded  step_retry_scheduled  step_retry_exhausted
step_skipped_unresolved_duration  stop_branch_executed
task_iteration_succeeded  task_iteration_failed
task_iteration_deferred  task_iteration_over_budget

Сообщение плоское: event, at, automation_id, kind, name, а при наличии — run_id, step, step_type, reason, error. Подробности берутся из состояния по тому же адресу.

Существенное:

  • Имена фаз — это имена журнала движка. Одна и та же строка видна и в логе пода, и на шине; второй словарь означал бы вопрос «а это то же самое событие?» на каждом разборе.

  • Список публикуемых фаз белый. В журнале движка есть и потеря лизы, и отказ claim-лока — это внутренности воркера, а не фазы прогона. Новое событие журнала наружу по умолчанию не выходит.

  • Круг фоновой задачи виден любым исходом. Удался, отказал, отсрочен (затор общей шины — task_iteration_deferred, исчерпан бюджет локации — task_iteration_over_budget) или прошёл простоем в границах (task_iteration_skipped, причина — в поле reason). В журнале прогона часть этих кругов придавлена окном тишины, на шине окна нет: steps включают на время и ровно затем, чтобы видеть каждый круг.

  • Ответ на ``/get`` ограничен по темпу — до 5 запросов в секунду с локации (всплеск до 60). Сверх этого вопрос сбрасывается молча: сказать «слишком часто» в топике состояния нечем.

  • ``steps`` живёт со сроком. Это единственный дорогой уровень (до 600 сообщений в минуту с локации), поэтому включается он на время: по умолчанию 30 минут, потолок — сутки. Срок виден в telemetry_steps_until и возвращается в ответе на команду. Когда он истёк, действующий уровень — events: гашение возвращает к наблюдению, а не к немоте.

  • Прогонов хаба здесь нет. Локальное исполнение присылает только итог (run_finished), пачками и с возможным пропуском: публиковать «идёт» по событию, приехавшему через минуты, — врать в реальном времени.

Свойство другого устройства (v22)

Операнд с device_id читает последний известный статус названного устройства, а не payload сработавшего триггера:

{ "op": "and", "children": [
  { "op": "=", "left": { "var": "occupancy" },                          "right": true },
  { "op": "<", "left": { "var": "illuminance", "device_id": "<uuid>" }, "right": 50 } ] }

То есть «свет по движению, но только если темно» — одно правило, а не композиция с ожиданием.

Существенные свойства:

  • Адрес разрешает сервер. Клиент присылает device_id; Janus резолвит топик состояния (DeviceTopic::forDevice) и кладёт его рядом в topic_suffix, переопределяя присланное — ровно как у wait_until, cancel_on и control.input. Движок читает состояние по топику (util.SignalStoreKey), поэтому голый device_id без суффикса адресным операндом не считается. Устройство обязано принадлежать локации автоматизации (иначе 422).

  • Нет показания — «неизвестно», а не ложь. Устройство молчит, статуса ещё не приходило, свойства нет в присланном пакете — во всех трёх случаях у операнда нет ответа. «Неизвестно» не инвертируется отрицанием (иначе НЕ (окно = открыто) на мёртвом датчике открывало бы нагрузку), проигрывает лжи в И и истине в ИЛИ — то есть гасит только свою ветвь, если соседняя уже решила исход, — а на корне блока означает «блок не подошёл». Причина уезжает в журнал прогона отдельным полем: «круг ничего не сделал» — это не диагноз.

  • ``EXISTS`` — исключение. Он и ЕСТЬ вопрос «есть ли значение», поэтому для него молчание — честный ответ falseNOT EXISTStrue). Иначе вопрос стал бы невыразимым.

  • Где адрес запрещён (422): у операторов перехода (CHANGED_*, CROSSES_*), в условии wait_until и в правиле отмены cancel_on. Во всех трёх местах предыдущий снимок принадлежит топику триггера, и ответ был бы про чужое устройство — то есть правило не срабатывало бы никогда, молча.

  • Хаб. Адрес уезжает в бандл разрешённым и читается из стора раннера. Исключение — регистр группы: зеркало rocket/groups несёт сырое значение без нормализации ON/OFF, и ответ разошёлся бы с облачным, поэтому такая автоматизация в облаке и остаётся (group_condition_unreachable).

  • Версия: schema_version = 22. Старый воркер прочитал бы адрес как обычное свойство payload, то есть ответил бы про ДРУГОЕ устройство — тихо. Fail-safe скип по мажору.

Время как операнд (v10)

Операнд {"now": <поле>, "tz": "<IANA>"} читает «сейчас» в заданной зоне и сравнивается теми же операторами, что и свойство устройства:

{ "op": "and", "children": [
  { "op": "=",  "left": { "var": "occupancy" },                          "right": true },
  { "op": ">=", "left": { "now": "minute_of_day", "tz": "Europe/Moscow" }, "right": 1320 } ] }

Поля now:

minute_of_day

минута суток 0–1439 в зоне tz (22:30 = 1350);

dow

день недели числом в каноне cron этого же контракта (0 = воскресенье … 6 = суббота) — чтобы «по будням» в расписании и в условии значило одно и то же;

date

локальная дата строкой YYYY-MM-DD (лексикографический порядок совпадает с хронологическим);

solar_phase

day | night по восходу/закату в координатах локации;

minutes_to_sunrise / minutes_to_sunset

целые минуты до события сегодня со знаком (отрицательное — событие уже прошло); этим выражается «за полчаса до заката» без арифметики в языке.

Существенные свойства:

  • Операнд НЕ создаёт триггера. Условие вычисляется ровно один раз, когда сработал триггер автоматизации; {"now":…} лишь читает часы в этот момент. «Каждый день в 22:00» — это по-прежнему расписание, а операнд времени отвечает на «сработало — а сейчас вообще ночь?».

  • Один снапшот «сейчас» на весь AST. Иначе два операнда одного условия разъехались бы через полночь, и правило «после 23:00 и раньше 00:30» стало бы невычислимым.

  • Зона обязательна и живёт в узле — ровно как timezone у cron-триггера: у локации таймзоны нет, а подразумевать серверную значило бы отдать пользователю правило, которое в его доме срабатывает не в тот час. Незагружаемая зона и неизвестное поле означают «значения нет» (упорядочивающее сравнение тогда ложно), как у любого другого операнда.

  • Солнечные поля требуют координат локации. Без них операнд значения не имеет (блок fail-safe ложен), а перенос такой автоматизации на хаб отдаёт блокер missing_coordinates.

  • Версия: schema_version=10. Старый воркер прочитал бы {"now":…} как отсутствующее свойство — «только ночью» превратилось бы в «никогда», причём молча.

Тип-модель

Константы типизированы самим JSON: число (24, 20.5) сравнивается численно, строка ("ON") — как строка. Отдельного «числового литерала без кавычек» не существует — тип несёт JSON.

  • Числовые сравнения (<, >, crosses_*) требуют приводимости к числу; иначе — ложь.

  • changed_to / changed_from_to нормализуют строки в нижний регистр (bool/enum ON/OFF сравниваются без учёта регистра).

Поверхностный синтаксис (грамматика, EBNF)

Запись display; регистр именованных токенов — SCREAMING_SNAKE (отделяет ключевые слова от имён свойств). Это НЕ ввод — только отображение AST.

expr        = or_expr ;
or_expr     = and_expr { "OR" and_expr } ;
and_expr    = unary { "AND" unary } ;
unary       = [ "NOT" ] primary ;
primary     = "(" expr ")" | comparison | named_call | exists_call ;

comparison  = operand sym_op operand ;               (* инфикс *)
sym_op      = "=" | "!=" | "<" | ">" | "<=" | ">=" ;

named_call  = NAME "(" operand { "," value } ")" ;   (* call: CROSSES_UP(temperature, 24, 2) *)
exists_call = "EXISTS" "(" property ")" ;
NAME        = "CHANGED_TO" | "CHANGED_FROM_TO" | "CROSSES_UP" | "CROSSES_DOWN" ;

operand     = property | value ;
property    = FUNC "(" var { "," digit } ")" | var ;  (* ROUND(temperature, 0) | temperature *)
FUNC        = "ROUND" | "BOOL" | "UPPER" | "LOWER" ;
var         = ident { "." ident } ;                   (* Switch.state *)
value       = number | string ;

Каноническая форма (AST) — краткий справочник

Полная схема — в JSON Schema. Значения op/fn на wire — lowercase.

// группа
{ "op": "and"|"or", "not": false, "children": [ <node>, ... ] }
// символьное сравнение
{ "op": "="|"!="|"<"|">"|"<="|">=", "left": <operand>, "right": <operand> }
// именованные операторы
{ "op": "exists", "not": false, "left": <property> }
{ "op": "changed_to",      "left": <property>, "to": <scalarOrArg> }
{ "op": "changed_from_to", "left": <property>, "from": <scalarOrArg>, "to": <scalarOrArg> }
{ "op": "crosses_up"|"crosses_down", "left": <property>, "threshold": <numberOrArg>, "hysteresis": <numberOrArg> }
// операнды
{ "var": "temperature" }                              // свойство payload триггера
{ "var": "temperature", "device_id": "<uuid>" }       // свойство НАЗВАННОГО устройства (v22)
{ "var": "temperature", "fn": "round", "digits": 0 }  // fn: round|bool|upper|lower
24    "ON"                                             // типизированные константы
{ "arg": "target_temp" }                              // ссылка на параметр (schema_version 8)

Семантика операторов

Оператор (wire / display)

Семантика

= != < > <= >=

Сравнение текущих значений операндов (числовое либо строковое по типу).

exists / EXISTS

Свойство присутствует в источнике состояния. not инвертирует.

changed_to / CHANGED_TO

tolower(current)==to и tolower(prev)!=to.

changed_from_to / CHANGED_FROM_TO

tolower(prev)==from и tolower(current)==to.

crosses_up / CROSSES_UP

Фронт вверх: prev < threshold и current >= threshold. При hysteresis>0 — детектор с пере-взводом (armed-флаг durable в Redis; холодный старт только инициализирует).

crosses_down / CROSSES_DOWN

Симметрично вниз (prev > threshold, current <= threshold).

Функции-обёртки операнда: round (digits 0..3), bool, upper, lower.

Параметры (schema_version 8)

Параметр — именованное типизированное значение автоматизации, которым пользователь влияет на её работу, не редактируя логику. Определение задаётся в редакторе, а значения спрашиваются в момент запуска: при включении ранее остановленной автоматизации (любого вида — по расписанию, по устройству, по регистру группы) и при добавлении разового запуска.

Определения — массив automation.arguments (опционально). Определение несёт метаданные (label/description/constraints) для UI и валидации:

{ "name": "550e8400-e29b-41d4-a716-446655440000", "type": "number",
  "label": "Целевая температура",
  "required": true,
  "preset": 22,
  "constraints": { "min": 5, "max": 35, "step": 0.5, "unit": "°C" } }
  • nameслужебный ключ, уникален в пределах автоматизации. По нему на параметр ссылаются условие, токены {{arg:…}} и duration_arg, и он же — ключ во всех картах значений. Человеческого смысла не несёт: редактор Rocket Home генерирует его сам (канонический lowercase-UUID) и пользователю не показывает — так ключ переживает переименование параметра и перестановку списка. Паттерн допускает две формы — ^(?:[a-z][a-z0-9_]*|<uuid>)$: прежние человеческие имена остаются валидными ключами (иначе сохранённые автоматизации перестали бы приниматься), а клиент вправе прислать своё имя. Uppercase-UUID отвергается: ключ и ссылка сравниваются точным равенством, регистронезависимости в исполнении нет. Клиенту следует показывать человеку label, а name использовать только как ключ. Подробности — ADR 0011.

  • labelназвание параметра для человека, единственное, чем его опознают и в интерфейсе, и в сообщениях 422 (ключ непрозрачен). Обязательно и уникально в пределах автоматизации (регистр и обрамляющие пробелы разных названий не создают); нарушение — 422 на arguments.N.label. Правило серверное и общее для всех писателей (ADR 0013). Роль названия может играть легаси-имя: у параметра с человеческим ключом отсутствующий label откатывается на name; у ключа, сгенерированного редактором (UUID), фолбэка нет. Строки СОХРАНЁННОЙ автоматизации, название которых не изменилось, правилу не подчиняются — чужая безымянная автоматизация (бот, MCP) остаётся редактируемой и сохраняемой.

  • typenumber | string | boolean | enum | datetime (datetime на wire = ISO-8601 строка).

  • required (опц., по умолчанию false) — обязательность: значение обязано быть в каждой точке ввода, иначе 422. Активной автоматизация с незаполненным обязательным параметром быть не может — запись выключает её (is_active: false), значения спрашивают при включении (значение засчитывается на любом уровне, включая собственные значения разовых расписаний).

  • presetпредустановленное значение, необязательное; матчит type и constraints. Им предзаполняется диалог ввода, и оно же — резерв исполнения. Без него у незаполненного параметра значения при исполнении нет вовсе.

  • Относительное значение у ``datetime``. Пользователь его не настраивает: редактор ставит токен, который резолвит в дату исполнения (в UTC) сам hmq2 — "today" для режима «дата без времени» (constraints.format=date) → YYYY-MM-DD и "today_midnight" для режима с временем → YYYY-MM-DDT00:00. "today_noon" (YYYY-MM-DDT12:00) — легаси-токен режима с временем: редактор его больше не ставит, но сохранённые автоматизации им полны, поэтому он остаётся валидным на запись и резолвится по-прежнему. Токен допустим только как preset: введённое значение всегда конкретное (иначе — 422).

  • constraints (опц., по типу): numbermin/max/step/unit; stringmin_length/max_length/pattern; enumoptions (непустой, обязателен для enum); datetimemin/max. Блок расширяемый (новые ключи аддитивны). hmq2 метаданные не читает.

  • Мета-формат «продолжительность»: { "type": "number", "constraints": { "format": "duration", "max": 43200 } } — значение целое число секунд, потолок 43200 (12 ч); в UI вводится через диалог часы/минуты, а не сырым числом. Отдельного базового типа нет: duration — это number с маркером format (наследует численные сравнения, пороги и {{arg:}}). Сервер требует целое в [0, 43200]; hmq2 подставляет число как есть.

Прежнее поле default декоммишено: присланное даёт 422 (обратная совместимость намеренно не соблюдается — иначе клиент молча писал бы значение «в никуда»).

Значения нет. Необязательный параметр без preset — законная форма, в том числе когда тело автоматизации на него ссылается (операнд условия и to/from/threshold/hysteresis, вложенное условие wait_until, токен {{arg:…}}, pause.duration_arg). Значения при исполнении тогда нет, и это означает:

Место

Поведение

{{arg:…}} внутри строки (текст, тема, топик, часть payload)

подставляется пустая строка

{{arg:…}} как самостоятельное значение в JSON-payload

подставляется null (payload остаётся валидным JSON)

операнд условия {"arg": …}

сравнивается как пустая строка: = "" истинно, = "ON" ложно, > 5 ложно

длительность (pause.duration_arg)

шаг не выполняется и пишется в лог: длительности «по умолчанию» нет, а подстановка нуля или бэкстопа означала бы неограниченное удержание нагрузки

Прежнее правило гарантии (ссылка требовала preset либо required: true, иначе 422 на arguments.N.preset) снято — оно навязывало заполнитель там, где пустое значение осмысленно.

Движок исполнения (hmq2) эту семантику реализует: подстановка в payload сохраняет JSON (null на месте самостоятельного значения), упорядочивающие сравнения с отсутствующим операндом ложны, wait_until на таком предикате ждёт, шаг с незаполненной длительностью пропускается с записью в лог, а планировщик подставляет значения и при пустой карте.

Подробности — ADR 0012 (модель обязательности — ADR 0010).

Три уровня значений

Уровень

Где

Когда вводится

Предустановленное значение

automation.arguments[].preset

в редакторе автоматизации

Текущие значения

automation.argument_values (карта {name: value})

при запуске ранее остановленной автоматизации; правятся без перезапуска

Значения разового расписания

timeTrigger.arguments (только schedule_type=one_time)

при добавлении разового запуска и при переактивации отработавшего

Эффективное значение при исполнении: time_trigger.arguments[name] ?? automation.argument_values[name] ?? preset.

Правила:

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

  • запуск автоматизации с обязательными параметрами без карты значений → 422: момент ввода — часть контракта запуска (в т.ч. для Bot/MCP-клиентов). Автоматизация из одних необязательных параметров запускается и без карты;

  • автоматизация, у обязательного параметра которой нет значения ни на одном уровне, не может быть активной: запись возвращает её с is_active: false, значения спрашивают при включении. Значения разовых расписаний тут засчитываются, но только если их несёт каждое расписание автоматизации: иначе один из запусков всё равно ушёл бы с пустым параметром;

  • у повторяющегося (cron/солнечного), device- и group_message-триггера своих значений нет — они исполняются на текущих значениях автоматизации; значения на таком триггере → 422;

  • is_active разового расписания принадлежит пользователю: тумблер автоматизации его не перезаписывает (иначе включение перевзводило бы отработавшие запуски). hmq2 фильтрует по обоим флагам, поэтому у выключенной автоматизации взведённое расписание не сработает;

  • переактивация отработавшего разового расписания требует нового времени: прошедшее целевое время планировщик уже не выберет.

Правило одно для всех типов, включая параметр-длительность (pause.duration_arg): проверки резолвимости значения у него нет — незаполненный параметр законен, см. «Значения нет».

Ссылки на значения. В условии — операнд-ссылка (каноническая форма):

{ "arg": "target_temp" }   // рядом с { "var": ... } и типизированной константой

argRef допустим везде, где ждётся операнд/константа/число: left/right сравнения, to/from переходов, threshold/hysteresis порогов. В числовых позициях параметр обязан быть number.

Примечание

В threshold/hysteresis работает только number: движок приводит эти позиции через ParseFloat, а значение datetime-параметра — ISO-строка, которая не парсится, поэтому сравнение не срабатывало бы никогда. Сервер такую пару отвергает (422 по ключу блока, схема 8.0.0); прежде она принималась «исторической широтой проверки», но и тогда была мёртвой. Проверка целиком серверная (ADR 0013): своих запретов на типы у редактора условий нет. Отдельно: нерезолвнутый или неположительный гистерезис-параметр не ошибка — полоса просто исчезает, и crosses_* работает как детектор фронта без антидребезга.

В строковых полях действий (payload, topic, body, subject, to) — плейсхолдеры (namespaced, во избежание коллизий имён):

  • {{arg:name}} — значение параметра.

  • {{prop:path}}значение свойства из payload сработавшего триггера (справочник сигналов — GET /devices/{id}/condition-signals). Payload есть только у триггера device и group_message (у группы это само сообщение шины, нормализованное к именам её регистров); на автоматизации только с расписанием такой плейсхолдер отвергается на записи (422).

  • {{prop:<device_id>.path}}device-квалифицированная форма: значение свойства конкретного устройства, захваченного предшествующим шагом wait_until (см. «Действия» → late-binding). Резолв ленивый, на исполнении, из captured_payload этого шага. Резолв — последний проход над шагом, поэтому всё, что не удалось подставить, резолвится в пустую строку: и «шаг отработал, но ничего не наблюдал» (молчащее устройство, таймаут), и ссылка вперёд на ещё не выполнившийся шаг. Сырой текст токена не уезжает ни в сообщение, ни на шину.

  • Путь *весь payload целиком, компактным JSON с отсортированными ключами: {{prop:*}} (payload триггера) и {{prop:<device_id>.*}} (захваченный payload; квалификатор обязан быть UUID устройства — nested.* это обычный путь, а не «весь payload»). Если устройство публикует не JSON-объект (голое ON, число, массив), * отдаёт этот payload как есть. Это payload конкретного сообщения, а не накопленное состояние: у z2m это обычно полный статус, но кнопка пришлёт только action, и в дамп попадают служебные ключи (linkquality, battery). Автоматизация с таким плейсхолдером стампится schema_version = 6. В полях topic и to путь * отвергается (422) — JSON-дамп вместо топика или адресата всегда ошибка автора.

Плейсхолдер заменяется строковым рендером значения (число → 50, строка → как есть, booltrue, datetime → ISO); JSON-кавычки в payload ставит автор ({"mode":"{{arg:mode}}"}). Синтаксис {{…}} зарезервирован.

Display. Параметр — первоклассный токен и в поверхностном синтаксисе: в условии рендерится чипом (leb-arg, глиф @Название) рядом со свойством/функцией/константой. Превью действия (DescribeTopicMessage) заменяет плейсхолдер на @<ключ>, а не на сырой {{…}}, — названий сервер не знает (в запросе их нет), поэтому подпись подставляет клиент, у которого есть определения параметров. Клиент, который этого не делает (бот, MCP), увидит служебный ключ.

Где что доступно (с v22). Условие вычисляется на device-, group_message- и time-триггерах, а у фоновой задачи — в начале каждого круга. Свойство БЕЗ адреса означает «то, что пришло со статусом», поэтому там, где статуса нет (только расписание, задача), оно отвергается на записи: сравнение с пустотой тихо ложно, а под != тихо истинно. Читать состояние в таких автоматизациях нужно адресным операндом (см. «Свойство другого устройства»). Оба follow-up’а прежней редакции — свойства произвольного устройства на расписании и снятие ограничения условия на time-триггерах — этим закрыты.

{{prop:…}} по-прежнему читается из payload сработавшего триггера, снятого в момент материализации run: для device/group_message он на руках, а на расписании его нет — голый плейсхолдер свойства там отвергается на записи, а не подставляется пустотой.

Регистры прибора: происхождение и две границы

Регистр — именованное значение состояния прибора. Появляется присвоением, и создать его вправе только сам прибор: его шаги, обработчики и фоновые задачи. Входящее сообщение регистров не заводит — иначе посторонний клиент лепил бы прибору поля своим /set.

Умение — регистр, объявленный через справочник (origin = declared): типизирован, валидируется по метаданным функции, участвует в панели, Алисе и матрице совместимости. Виртуальный регистр (origin = virtual) умением не объявлен — тип берётся из значения. Здесь живут фаза сеанса, счётчик кругов, «сколько парить». Цена названа вслух: словарной валидации у него нет, диапазона взять неоткуда.

У регистра две независимые границы, обе ставятся руками; у умения подняты, у виртуального опущены:

  • published — попадает ли регистр в состояние, которое прибор отдаёт;

  • externally_writable — вправе ли его писать входящее сообщение.

Независимы они не для симметрии. Регулируемая настройка («сколько парить») должна приходить снаружи и быть видна на карточке, не становясь умением; служебное состояние («фаза сеанса») не обязано быть видно, чтобы им пользовались собственные задачи прибора. Запрещена ровно одна комбинация: принимать запись, не публикуя (422) — это прибор, у которого выключатель есть, а лампочки нет, и клиент не может узнать, послушались его или нет.

Граница публикации действует на ПРОВОД, а не на движок. Собственные обработчики и задачи видят ВСЕ регистры; фильтр применяется к тому, что уходит на шину, в панель и Алисе — правило одно на все три выхода. Иначе условие «пока фаза = парение» читало бы signal-store, куда скрытый регистр не попал, получало бы «неизвестно» — а у фоновых задач «не знаю» это ЛОЖЬ (fail-safe), — и круг не запускался бы ни разу, без единой строки об ошибке. Прибор, которому нельзя иметь скрытое состояние, — не прибор, а витрина.

Реконсиляция умений виртуальных регистров не касается: сиротой не бывает то, у чего никогда не было умения. Регистр, который потом объявили умением, повышается в него — с типом из справочника, поднятыми границами и сохранённым значением.

Обработчик записи, привязанный к регистру (v27)

Логика группы обслуживала сообщение целиком: блоки «Если / Или если», работает первый подошедший. Этого хватало, пока сообщение было про один регистр, и переставало хватать на батче: сцена {"on":true,"ambience":"movie"} получала РОВНО ОДИН блок, и вторая половина исчезала молча.

Блок может назвать регистр (expose_name), обработчик записи которого он переопределяет. Дальше правило простое: на каждый регистр сообщения — свой обработчик, они складываются в цепочку в порядке объявления умений, внутри регистра работает первый подошедший блок.

  • Обработчик видит ВСЁ сообщение, а не только свой ключ. Условие вычисляется по целому payload, поэтому обработчик on вправе спросить про ambience и уступить ему — именно так батч перестаёт разъезжаться на две рассылки. Отбор по своему ключу запретил бы это ровно там, где нужно.

  • Умолчание — ПОРЕГИСТРОВОЕ. Регистр без переопределения обслуживает встроенный обработчик. До v27 сам факт наличия логики подавлял умолчание целиком, и прибор с одним описанным регистром переставал отвечать на остальные — то есть переопределение одного обработчика делало его наполовину немым.

  • Смешивать нельзя (422): в ветке «Запись» либо ВСЕ блоки привязаны к регистру, либо ни один. Два правила отбора за одно сообщение не объяснить, и автор не смог бы предсказать, что выполнится.

  • Грамматика сужена до команд (422 на прочее): device_command, set_group_expose, apply_default. Обработчик отвечает на присвоение, пока сообщение ждёт ответа; пауза, ожидание, сообщение, ветвление и регулятор означали бы обработчик, способный зависнуть на минуты. Долгое поведение выражается фоновой задачей.

  • Регистр обязан существовать и быть открытым для записи снаружи (422). Это не спорит с правилом «появляется присвоением»: обработчик отвечает на присвоение ИЗВНЕ, а извне пишутся только регистры с поднятой границей. Привязка к чему-то другому — не «ещё не появился», а обработчик, который не сработает НИКОГДА.

  • Не подошёл ни один обработчик — прогона нет вовсе: «обработчик, который ничего не записал, и есть отказ», а пустой прогон был бы записью в журнал без содержания.

Умолчание берётся по типу умения из словаря: диапазон у range, перечень значений у mode. До v27 дефолт приводил значение только к ТИПУ регистра, и {"brightness": 999} с шины ложился как есть — тогда как та же команда через REST панели получала 422. Одна команда, две двери, разный результат. Весь батч применяется одной транзакцией, а побудка задач и публикация — строго ПОСЛЕ коммита: иначе запись on разбудила бы задачу раньше, чем запишется duration.

``TOGGLE`` инвертирует булев регистр атомарно, в одном операторе с чтением: посчитай движок новое значение у себя, два одновременных нажатия дали бы одно переключение. Повтор ОДНОГО шага (ретрай доставки) инверсию не удваивает — команда из шага несёт ключ идемпотентности, и её повтор отбрасывается. Без этого «переключить» не делало бы НИЧЕГО ровно тогда, когда первая попытка не долетела.

Два шага обработчика

«Применить умолчание» (apply_default) — позвать встроенную запись СВОЕГО регистра. Без него переопределение — всегда «ВМЕСТО умолчания» и никогда «вдобавок»: автор, которому нужно лишь разослать команду участникам, сохранив обычную запись регистра, повторял бы её сам вместе со всей валидацией словаря. Живёт только внутри обработчика (422 где угодно ещё). Мягкий, как и сам дефолт: значение вне правил умения и ключ, которого в сообщении не было, пропускаются — обработчик отвечает на ЧУЖОЕ сообщение, и ронять цепочку из-за ошибки отправителя было бы наказанием не тому.

«Ответить» (reply) — обработчик ЧТЕНИЯ сам решает, когда прибор отвечает состоянием. До него /get отвечал сразу и безусловно, ещё до того как логика чтения что-либо сделает: порядок «сначала ответ, потом работа» превращал ответ в снимок ПРОШЛОГО. Только ветка «Чтение» (422 в записи: команда — не вопрос) и обязателен в КАЖДОМ её блоке, если хоть один его несёт: как только логика взяла ответ на себя, немедленный ответ движка подавляется, и блок без «Ответить» оставил бы обращение вовсе без ответа.

Запасной путь сохранён. Прогон может не завестись — занят (инвариант «один активный прогон»), ни один блок не подошёл, определение выключили между сообщением и стартом, — и тогда отвечает движок. Ответ, живущий ТОЛЬКО внутри прогона, делал бы прибор молчащим ровно тогда, когда он занят.

У обоих шагов нет полей автора: группу, регистр и источник значения впечатывает сервер из блока, в котором шаг стоит. Ошибиться в них негде, а спроси мы их у автора — место шага и его содержимое смогли бы разойтись.

Команда группе доходит до группы (v26)

Шаг «Действие», нацеленный на устройство-ГРУППУ, попадает в её вход — тот же, куда приходит сообщение с шины: запускает логику направления «Запись» (или встроенный дефолт, если логики нет) и будит её фоновые задачи. Группа объявлена first-class устройством, и «включить группу» обязано работать тем же шагом, что и «включить лампу».

{ "type": "device_command", "device_id": "<uuid группы>", "payload": "{\"state\":\"ON\"}" }

Что было сломано. Публикация из движка — СЕРВЕРНАЯ, а хук групповой плоскости (plugins/bridge/group_plane.go) зовётся только на КЛИЕНТСКИХ пакетах. Команда шага уезжала подписчикам топика и не запускала ничего: ни логику, ни задачи. При этом шаг завершался успехом, и в журнале прогона стояло step_succeeded — цепочка шла дальше, а баня оставалась холодной. Отсюда все обходные пути: включать группу приходилось записью её регистра (set_group_expose) мимо её собственных правил, а длинные цепочки — держать снаружи прибора.

Как чинится. Движок подаёт команду ин-процессной инъекцией в тот же канал, которым уже пользуется Алиса (InjectGroupCommand). Обещание шага при этом не меняется: «команда подана», а не «прибор послушался», — ровно то же, что даёт публикация на шину.

  • Адресов два, и оба работают: device_id (его кладёт редактор) и голый topic (generic/{friendly}/set — форма внешних клиентов, у которых устройства в Janus нет). Вердикт «этот адрес принадлежит группе» кэшируется вместе с ОТРИЦАТЕЛЬНЫМ: обычных устройств на порядки больше, и без кэша негативов каждый шаг добавлял бы запрос в БД.

  • ``/get`` тоже доезжает и остаётся вопросом: направление берётся из хвоста адреса. Иначе вопрос стал бы записью и дал бы вторую публикацию состояния на одно обращение.

  • Версия: schema_version = 26 у автоматизации, чей шаг адресует группу. Штамп обязателен именно из-за тишины прежнего отказа: воркер без v26 «выполнил» бы шаг, опубликовав в пустоту, и пошёл бы по цепочке дальше. Со штампом он fail-safe скипает автоматизацию целиком.

  • Порядок выката: hmq2 ПЕРВЫМ. Штамп гейтит СТАРТ прогона; выкати Janus раньше — автоматизация со штампом 26 не подберётся ни одним воркером, включая её негрупповые шаги.

  • Хаб: такая автоматизация исполняется только в облаке (блокер group_grammar). Порога версии раннера у неё НЕТ и быть не может: на хабе от группы есть лишь read-only зеркало регистров, и «с такого-то раннера поедет» было бы обещанием, которого никто не сдержит.

  • Чего команда пока НЕ умеет: TOGGLE. Значение пишет не сам прибор, а встроенный дефолт, и инвертировать ему нечем — строка TOGGLE в булев регистр молча пропускается, поэтому редактор этот вариант для групп не предлагает.

Payload команды группе на записи НЕ проверяется против её регистров: что с ним делать, решает логика группы, а её в момент сохранения шага может ещё не быть. Ключ, которого у группы нет, дефолт пропускает молча.

Подробности — ADR 0034.

Ветка гашения (on_stop) — свойство фоновой ЗАДАЧИ

Что послать, когда сеанс задачи разбирают: выключили группу, выключили задачу, круги подряд отказывали, либо круг отказал у задачи с исходом «закончить сеанс». Ветка играется внутри того же прогона и только при живой лизе — подробности в разделе «Фоновая задача» выше.

{ "name": "Баня · сеанс",
  "on_stop": [ { "type": "device_command", "device_id": "…", "payload": "{\"state\":\"OFF\"}" } ],
  "triggers": [ { "kind": "task", … } ] }

Ключ on_stop — верхнего уровня (не внутри входа), но осмыслен только при входе вида ``task``: у автоматизации без такого входа он отвергается 422 с указанием, где описывать долгое поведение.

Грамматика ветки сужена до команд (device_command, set_group_expose), не больше десяти шагов. Пауза, ожидание или сообщение здесь означали бы гашение, которое само способно зависнуть, — а гасят ровно тогда, когда ждать уже нельзя. Незнакомый шаг пропускается с предупреждением: остальные команды обязаны уйти. Пустая ветка законна и остаётся самым частым состоянием — «оставить как есть» это выбор, а не недозаполненная форма.

Ветка не делает остановку транзакционной. Она снимает нагрузку, но не откатывает необратимое: отправленное письмо остаётся отправленным, открытый клапан — если его нет в ветке — открытым.

Мажор 24 (ветка у ЛЮБОЙ автоматизации) ОТОЗВАН 01.09.2026; номер занят навсегда и не переиспользуется. Он лечил симптом: длинную цепочку приходилось держать снаружи прибора, потому что команда группе из шага молча не доходила, — и обычному прогону понадобилось гашение. Дыру закрывает отдельная волна, а долгое поведение возвращается внутрь прибора, где ветка была с самого начала. Штамп schema_version = 24 больше не выставляется, порог раннера 1.19.0 снят. См. ADR 0032 (отозван).

Хранение переезд пережило: ветка лежит в automations.on_stop, а не в строке входа — возвращать triggers[].on_stop значило бы восстанавливать двойной источник ради отката. В бандле хаба ветка по-прежнему едет внутри входа задачи. set_group_expose в локальную ветку не попадает — регистр группы на хабе read-only зеркало, и автоматизация с таким шагом непереносима (блокер group_grammar).

Версионирование и расширяемость (SemVer)

Контракт версионируется семантически. Мажор несёт automation.schema_version (дефолт 1). JSON Schema — файл automation-contract.v1.schema.json (внутренняя version растёт, $id сохраняется для непрерывности ссылок). Актуальная версия схемы — 43.0.0: длина спящей паузы, взятая из РЕГИСТРА прибора (duration_from.expose), стала живой — пока шаг спит, он перечитывает настройку и переставляет свой срок, в любую сторону. Форма записи не изменилась ни на поле; изменилась семантика, поэтому стамп грамматики — 30, порог версии раннера — 1.28.0 (ADR 0039).

До неё — 42.0.0: снят порог наименьшей длительности сегмента (duration_from.min_seconds); грамматика не менялась, стамп остался прежним.

До неё — 41.0.0: снят режим seconds у длины паузы (duration_from.mode). Мажор 28 остался без носителей вовсе, порог 1.22.0 снят; штамп 28 больше не ставится, а сохранённые автоматизации переписаны тождественным отображением миграцией.

До неё — 39.0.0: фоновая задача сказана как ЦИКЛ. Блоки означают «Пока верно», сеанс кончается, когда не держится ни одно условие, у блока появился момент сверки (check), у шага — исход отказа (on_failure), а блок без действий стал законным способом сказать «держись, ничего не делай». Исход отказа круга у ВХОДА задачи снят. Стамп грамматики — 29, порог версии раннера — 1.24.0 (ADR 0038).

До неё — 38.0.0: снятие условия продолжения сеанса (triggers[].while, ADR 0037); грамматика при этом не менялась, стамп 23 остался за двумя другими носителями волны. До неё — 37.0.0: исход отказа круга у фоновой задачи (triggers[].on_failure, снят волной 29) и режим seconds у длины паузы (duration_from.mode); стамп грамматики — 28, порог раннера — 1.22.0.

Предыдущая — 36.0.0: обработчик записи, привязанный к РЕГИСТРУ (condition_blocks[].expose_name), и два его шага — «Применить умолчание» (apply_default) и «Ответить» (reply). Стамп грамматики — 27, порога версии раннера нет: логика группы на хаб не едет вовсе. До неё — 35.0.0: команда группе из шага доходит до группы; стамп грамматики — 26, порога версии раннера тоже нет (такая автоматизация исполняется только в облаке). local-bundle не меняется. До неё — 34.0.0: мажоры 24 и 25 ОТОЗВАНЫ. Ветка гашения снова бывает только у фоновой задачи (on_stop у не-задачи → 422), шаг «Регистр группы» снова пишет ОДИН регистр (пачки writes[] нет), у адресного операнда условия нет окна свежести (max_age_seconds). Штампы schema_version 24 и 25 больше не выставляются, номера заняты навсегда — следующая волна берёт 26. local-bundle1.21.0: раннеры 1.19.0 и 1.20.0 отозваны вместе со своими грамматиками, но однажды выкаченный на хабы номер назад не отдают, иначе хаб получил бы «обновление» в прошлое. Обе снятые волны лечили симптомы одной дыры — команда группе из шага автоматизации молча не доходила; см. ADR 0032 и 0033 (оба отозваны).

Отозванные: 33.0.0 — пачка регистров группы (set_group_expose.writes[]) и окно свежести адресного операнда, стамп 25, раннер 1.20.0 (ADR 0033). 32.0.0 — ветка гашения у любой автоматизации, стамп 24, раннер 1.19.0 (ADR 0032).

Предыдущая действующая — 31.0.0: ветвление внутри цепочки (шаг branch) — тело шага это те же блоки «Если → Тогда», а выбор делается в тот момент, когда цепочка до шага дошла; стамп грамматики — 23. Предыдущая — 30.0.0: условие продолжения сеанса фоновой задачи (triggers[].while) — конструкция СНЯТА в 39.0.0 (ADR 0037). До неё — 29.0.0: исход ожидания по таймауту (on_timeout), с которого волна 23 и началась.

Мажор 23 несли ТРИ возможности, осталось две — исход ожидания (ADR 0029) и ветвление внутри цепочки (ADR 0030); условие продолжения сеанса задачи (ADR 0031) снято. Номер волны и порог версии раннера 1.18.0 остались за выжившими: волна не сдвигается вниз оттого, что одна её конструкция ушла. Обе оставшиеся об одном — куда прогон идёт дальше и когда заканчивается.

До 29.0.0 — 28.0.0. Ниже перечислены не все промежуточные бампы файла схемы: номер файла растёт и от правок описаний, а грамматику несёт schema_version, и именно он — предмет этого раздела. До 25.1.0: перечень кодов переносимости пополнился парой блокеров фоновой задачи на хабе (group_gate_unreachable, group_mirror_unreachable); грамматика не менялась. До неё — 25.0.0: дути-цикл убран из контракта целиком. Схем startDutyCycleAction / stopDutyCycleAction больше нет, значения start_duty_cycle / stop_duty_cycle из перечисления типов шага убраны, читать их тоже нечем — совместимости не оставлено намеренно, потому что сохранённых строк с этими типами не существует. Грамматика при этом не менялась: стамп остаётся schema_version = 20. Тем же заходом волна дошла до хаба: local-bundle 1.12.0 → 1.13.0 (вид входа task с гейтом и веткой гашения, шаг control, pause.duration_from), раннер — 1.14.0.

До 25.0.0 — 24.0.0: фоновая задача группы. Периодическая нагрузка перестала быть примитивом «одно устройство, два состояния, круг» и стала отдельной автоматизацией владельца (group_role = 'task') с триггером task, цепочкой шагов, шагом control и длительностью из значения (pause.duration_from). Стамп — schema_version = 20. См. ADR 0024.

До 24.0.0 — 23.0.0: окно свежести ожидания. Оба режима wait_until засчитывают только статус, полученный после входа в шаг (условный режим больше не завершается на сколь угодно старом retained-значении — поведенческий разрыв, мажор), а новое поле last_status_max_age_seconds («принимать последний полученный статус, если он не старше N») явно возвращает последний уже полученный статус в игру; стамп — schema_version = 19.

До 23.0.0 — 22.0.0: у правила отмены появился второй способ адресации — авторский топик (cancel_on.topic) вместо устройства, и при нём условие стало необязательным («любое сообщение в этот адрес»); адрес остался ровно один из двух, стамп — schema_version = 18.

До 22.0.0 — 21.0.0: разовый запуск со своими значениями параметров переносится на хаб (сборщик печёт вхождению свою копию блоков), грамматика не менялась. До неё 20.0.0: снят шаг stop_run («Завершение») — обрывать цепочку по условию больше нечем, type: "stop_run" отвергается 422, а schema_version = 17 больше не штампуется.

До 20.0.0 — 19.0.0: эскалация уведомления снята, поле escalation убрано из контракта, а присланный ключ вырезается на записи молча; schema_version = 11 больше не штампуется, номер занят навсегда. До неё 18.0.0: шаг «Действие» с подтипами (stop_run — завершение прогона, публикация в произвольный топик), шаг guard снят; стамп — schema_version = 17 (в 20.0.0 снят и он). До неё 16.0.0: вопрос жильцу стал РЕЖИМОМ шага message (mode = confirm | prompt), отдельный шаг ask снят, а отказ на кнопке стал настраиваемым (on_decline); стамп — schema_version = 15.

До 16.0.0 — 15.0.0: продление при повторном срабатывании переехало с автоматизации на спящий шаг (then_actions[].retrigger) и стало условным; поле retrigger_policy снято, стамп — schema_version = 13.

Мажор 13 выпущен ПОСЛЕ 14, и это не опечатка. Воркер занял его раньше, чем Janus начал эмитить поле шага (в его knownSchemaVersion 13 значился с самого начала волны), поэтому номер мажора и версия файла схемы с этого момента не в лок-степе: 15.0.0 стампит 13.

До 15.0.0 — 14.0.0: MQTT-плоскость группы. До неё 12.0.0: шаг ask — вопрос жильцу, на котором прогон ЖДЁТ ответа (стампил schema_version = 12; в 16.0.0 шаг снят, номер мажора занят навсегда). 17.0.0 — правило отмены переехало с автоматизации на ждущий шаг (then_actions[].cancel_on, schema_version=16): ключ automation.cancel_on снят, старая форма отвергается 422 с указанием нового места. До неё 11.0.0: правило отмены прогона по событию (тогда ещё automation.cancel_on) и эскалация уведомления (escalation у шага message), обе конструкции стампили schema_version = 11; обе сняты, номер мажора занят навсегда. До неё 10.0.0: операнд времени в условии ({"now":…,"tz":…}), шаг guard и поле retrigger_policy — волна из трёх конструкций под общим мажором 10. До неё 9.3.0: перечень кодов переносимости (local_eligibility), грамматика не менялась; 9.2.0 объединила описание сериализации с мажором 9 «исполняется вне облака». До них — 8.2.0: схема догнала то, что код и так сериализовал. Появились startDutyCycleAction.hold_arg и stopDutyCycleAction.off_payload (оба принимались и работали, но были описаны только прозой), а read-only-проекция перестала проходить «зайцем» через additionalProperties: поля, которые проставляет сервер (id, location_id, user_id, broken_at, next_due_at_utc, last_run, created_at, updated_at, schema_version, argument_values, у триггеров — id и is_active), помечены readOnly. Грамматика не менялась: readOnly в JSON Schema 2020-12 — аннотация, на валидацию не влияющая. В OpenAPI readOnly — нативный механизм, и строгий клиент отвергает такое поле в теле запроса, поэтому в YAML-спеках оно проставлено только там, где схема участвует исключительно в ответе (Automation). На схемах, общих для чтения и записи (AutomationTrigger, шаги с серверными адресами), серверные поля описаны прозой: иначе прочитанную автоматизацию нельзя было бы отправить назад без правки, а именно этот сценарий мы документируем и проверяем round-trip-тестом. До неё 8.1.0: дни недели в cron_expression приведены к диапазону 0–6, воскресенье — 0 (семёрку принимал janus, но не движок исполнения). До неё 8.0.0: название параметра стало обязательным и уникальным на СЕРВЕРЕ (непустой label либо человеческий ключ в роли названия), поэтому определение со сгенерированным ключом и без названия больше не принимается; заодно числовая позиция условия сужена до number. Ограничением схемы правило не выражается — сохранённые строки с прежним названием сервер грандфазерит и продолжает отдавать в ответах, а такое исключение зависит от состояния БД, поэтому и обязательность, и уникальность описаны в argumentDef.label прозой. До неё 7.3.0 ввела относительный токен today_midnight («сегодня 00:00») для datetime-параметра, оставив today_noon валидным легаси. 7.2.0 сняла правило гарантии: ссылка на необязательный параметр без preset допустима, семантика «значения нет» описана в argumentDef. Предыдущая, 7.1.0, делала служебный ключ параметра непрозрачным: паттерн argumentDef.name/argumentRef.arg расширен до «человеческое легаси-имя ИЛИ канонический lowercase-UUID», прежние ключи остаются валидными. До неё, 7.0.2, вводила обязательность параметра как валидатор ввода: argumentDef.required независим от необязательного argumentDef.preset, правило гарантии для ссылок; ТЕКУЩИЕ значения в automation.argument_values, свой набор — только у разового расписания.

schema_version стампится сервером по факту использования, не по объявлению. Автоматизация, которая ссылается на параметр (argRef в условии, токен {{arg:…}} в строке действия, pause.duration_arg), получает schema_version = 8; иначе остаётся 1. Просто объявить arguments без ссылок — версия остаётся 1 (старый hmq2 исполняет литерально). hmq2 стампит по факту грамматики и растит knownSchemaVersion по мере фич (3 — группа, 4 — снятый дути-цикл, 5 — снятый reading-реконсилятор, 6 — плейсхолдер «весь payload», 7 — ожидание любого статуса и секундный таймаут, 8 — значения параметров на уровне автоматизации, 10 — операнд времени (и снятый guard), 11 — снятая эскалация уведомления, 13 — продление на спящем шаге, 16 — отмена по событию на ждущем шаге, 17 — снятый шаг stop_run, 19 — окно свежести ожидания, 20 — фоновая задача группы); встретив schema_version новее известной, hmq2 пропускает автоматизацию целиком (fail-safe), причём проверка версии идёт до чтения новых колонок.

Мажор 9 — «исполняется вне облака» (Локальное исполнение автоматизаций (Zigbee2MQTT)). Грамматику 9 не расширяет: автоматизация с execution_place = local штампится max(9, грамматика), чтобы поды hmq2 без гейтинга по execution_place (включая откаченные) fail-safe-скипали её на device- и многошаговом тайм-пути. Fast-path планировщика (одношаговая тайм-автоматизация) версию не проверяет — его закрывает next_due_at_utc = NULL, который janus проставляет при переносе. Возврат в облако пересчитывает штамп по фактической грамматике (понижение законно).

Порядок выката колонок: колонки automations.arguments / automations.argument_values / automation_time_triggers.arguments добавляет только Janus-миграция (инвариант «эволюция схемы в Janus»). Janus выкатывается первым — иначе новый hmq2 упрётся в несуществующую argument_values. Старый hmq2 её игнорирует и fail-safe-скипает schema_version=8, поэтому Janus-first безопасен: автоматизации с параметрами не исполняются до апдейта hmq2, но и не мисфайрят на предустановленных значениях вместо введённых.

  • MAJOR (schema_version растёт): удаление/переименование op/fn/типов действий/видов триггеров, смена формы узла/семантики/типизации. Требует координации hmq2 + backfill.

  • MINOR (версия не меняется, аддитивно): новый op/fn/вид узла, новый тип действия/канал/ режим расписания, новое опциональное поле с безопасным дефолтом.

  • PATCH: правки документации/сообщений валидации; wire не меняется.

Forward-compat / fail-safe (инвариант всех потребителей):

  • Бэкенд-валидатор строго отвергает неизвестные значения на записи — мусор/термы новее схемы Janus в БД не попадают.

  • hmq2, встретив терм новее (окно выката, Janus впереди), трактует его как ошибку вычисления → блок не срабатывает + лог, НЕ падает. Поэтому MINOR-добавления rollout-safe: сначала выкатывается hmq2 (умеет новое), затем Janus начинает эмитить.

  • Фронт-билдер и PHP-валидатор идут в одном деплое (набор op/fn синхронен), поэтому фронт не встречает op, который бэкенд отверг бы на записи. Оговорка: при устаревшем SPA-бандле в окне MINOR-выката (условие с новым op уже сохранено более свежим Janus, а у пользователя закэширован старый бандл) билдер сейчас молча отбрасывает незнакомый узел на загрузке (astToTreecreateEmptyRoot), из-за чего пересохранение может ослабить гейт. Смягчение — hard-refresh; hardening (opaque-passthrough незнакомых узлов или save-guard) — отдельный follow-up.

Процедура добавления оператора/функции (MINOR): 1) bump minor JSON Schema + значение (lowercase); 2) фронт *_CODES + label + formatConditionHtml; 3) PHP-валидатор; 4) вычислитель hmq2; 5) документация. Без bump schema_version, без PG-миграции (значения — строки в JSON). Порядок выката: hmq2 → Janus.